diff --git a/packages/vue-component-library/src/index.ts b/packages/vue-component-library/src/index.ts index 2f9aa210d..041b8bc3b 100644 --- a/packages/vue-component-library/src/index.ts +++ b/packages/vue-component-library/src/index.ts @@ -48,6 +48,8 @@ export { default as DefinitionList } from './lib-components/DefinitionList.vue' export { default as DividerGeneral } from './lib-components/DividerGeneral.vue' export { default as DividerWayFinder } from './lib-components/DividerWayFinder.vue' export { default as DropdownSingleSelect } from './lib-components/DropdownSingleSelect.vue' +export { default as FilterSelections } from './lib-components/FilterSelections.vue' +export { default as RefineSearchPanel } from './lib-components/RefineSearchPanel.vue' export { default as FiltersDropdown } from './lib-components/FiltersDropdown.vue' export { default as FlexibleAssociatedTopicCards } from './lib-components/Flexible/AssociatedTopicCards.vue' export { default as FlexibleBannerFeatured } from './lib-components/Flexible/BannerFeatured.vue' diff --git a/packages/vue-component-library/src/lib-components/FilterSelections.vue b/packages/vue-component-library/src/lib-components/FilterSelections.vue new file mode 100644 index 000000000..052e27585 --- /dev/null +++ b/packages/vue-component-library/src/lib-components/FilterSelections.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/packages/vue-component-library/src/lib-components/FiltersDropdown.vue b/packages/vue-component-library/src/lib-components/FiltersDropdown.vue index d71ee7524..e19516050 100644 --- a/packages/vue-component-library/src/lib-components/FiltersDropdown.vue +++ b/packages/vue-component-library/src/lib-components/FiltersDropdown.vue @@ -28,6 +28,7 @@ interface SelectedFiltersTypes { } const selectedFilters = defineModel('selectedFilters', { type: Object as PropType, required: true, default: {} }) // FUNCTIONS + // calc # for UI '# selected' display const numOfSelectedFilters = computed(() => { let count = 0 @@ -38,6 +39,7 @@ const numOfSelectedFilters = computed(() => { } return count }) + // check if option is selected so we can display 'x' SVG function isSelected(searchField: string, option: string) { // check if selectedFilter object has any keys, fail gracefully if it doesn't @@ -46,12 +48,14 @@ function isSelected(searchField: string, option: string) { return selectedFilters.value[searchField].includes(option) } + // Clear Button Click / clear all selected filters function clearFilters() { for (const group of filterGroups) selectedFilters.value[group.searchField] = [] emit('update-display', selectedFilters.value) } + // Done Button Click / emit selected filters to parent function onDoneClick() { emit('update-display', selectedFilters.value) diff --git a/packages/vue-component-library/src/lib-components/RefineSearchPanel.vue b/packages/vue-component-library/src/lib-components/RefineSearchPanel.vue new file mode 100644 index 000000000..80b93d9ae --- /dev/null +++ b/packages/vue-component-library/src/lib-components/RefineSearchPanel.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/vue-component-library/src/stories/EffectSlideToggle.stories.js b/packages/vue-component-library/src/stories/EffectSlideToggle.stories.js new file mode 100644 index 000000000..e72438256 --- /dev/null +++ b/packages/vue-component-library/src/stories/EffectSlideToggle.stories.js @@ -0,0 +1,261 @@ +import { ref } from 'vue' +import EffectSlideToggle from '@/lib-components/EffectSlideToggle.vue' + +export default { + title: 'Funkhaus / EffectSlideToggle', + component: EffectSlideToggle, + argTypes: { + duration: { + control: { type: 'number', min: 100, max: 2000, step: 100 }, + description: 'Animation duration in milliseconds' + }, + easing: { + control: 'text', + description: 'CSS easing function for animation' + }, + opened: { + control: 'boolean', + description: 'Whether the toggle is initially opened' + } + } +} + +export function Default() { + return { + components: { EffectSlideToggle }, + template: ` +
+ + +
+

Content Area

+

This content slides in and out smoothly when you click the summary above.

+

The animation uses the Web Animations API for smooth transitions.

+
+
+
+ ` + } +} + +export function InitiallyOpened() { + return { + components: { EffectSlideToggle }, + template: ` +
+ + +
+

Pre-opened Content

+

This toggle starts in the opened state.

+
+
+
+ ` + } +} + +export function CustomDuration() { + return { + components: { EffectSlideToggle }, + template: ` +
+ + +
+

Slow Animation

+

This toggle uses a slower animation duration of 800ms.

+
+
+
+ ` + } +} + +export function CustomEasing() { + return { + components: { EffectSlideToggle }, + template: ` +
+ + +
+

Bouncy Animation

+

This toggle uses a bouncy easing function for a more playful effect.

+
+
+
+ ` + } +} + +export function WithFormContent() { + return { + components: { EffectSlideToggle }, + setup() { + const formData = ref({ + name: '', + email: '', + message: '' + }) + + return { formData } + }, + template: ` +
+ + +
+

Contact Information

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ Form Data: {{ formData }} +
+
+ ` + } +} + +export function WithLongContent() { + return { + components: { EffectSlideToggle }, + template: ` +
+ + +
+

Long Content Example

+

This toggle contains a lot of content to test how the animation handles different heights.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
    +
  • First item in a long list
  • +
  • Second item in a long list
  • +
  • Third item in a long list
  • +
  • Fourth item in a long list
  • +
  • Fifth item in a long list
  • +
+

More content to make this section even longer and test the animation behavior with substantial height changes.

+
+
+
+ ` + } +} + +export function EventHandlers() { + return { + components: { EffectSlideToggle }, + setup() { + const events = ref([]) + + const logEvent = (eventName) => { + events.value.unshift({ + event: eventName, + timestamp: new Date().toLocaleTimeString() + }) + // Keep only last 10 events + if (events.value.length > 10) + events.value = events.value.slice(0, 10) + } + + return { events, logEvent } + }, + template: ` +
+ + +
+

Event Logging

+

This toggle logs all animation events. Check the event log below to see the events being fired.

+
+
+ +
+

Event Log:

+
No events yet. Click the toggle above to see events.
+
+
+ {{ event.timestamp }} - {{ event.event }} +
+
+
+
+ ` + } +} diff --git a/packages/vue-component-library/src/stories/FilterDropdown.stories.css b/packages/vue-component-library/src/stories/FilterDropdown.stories.css new file mode 100644 index 000000000..99fa9bb31 --- /dev/null +++ b/packages/vue-component-library/src/stories/FilterDropdown.stories.css @@ -0,0 +1,250 @@ +/* FilterDropdown Story Styles */ + +.story-container { + max-width: 400px; + margin: 20px; +} + +.story-container-wide { + max-width: 500px; + margin: 20px; +} + +/* Selected Options Display */ +.selected-options-container { + margin-top: 20px; + padding: 16px; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; +} + +.selected-options-title { + margin: 0 0 12px 0; + color: #495057; + font-size: 14px; + font-weight: 600; +} + +.selected-options-json { + margin: 0; + font-size: 11px; + color: #6c757d; + background: #ffffff; + padding: 12px; + border-radius: 4px; + border: 1px solid #dee2e6; + overflow-x: auto; +} + +.date-range-display { + margin-top: 12px; + padding: 8px; + background: #e3f2fd; + border-radius: 4px; + border-left: 3px solid #2196f3; +} + +.date-range-label { + color: #1976d2; + font-weight: 600; +} + +.date-range-value { + color: #424242; +} + +/* Event Log Styles */ +.event-log-container { + margin-top: 20px; + padding: 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 12px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.event-log-title { + margin: 0 0 16px 0; + color: #ffffff; + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; +} + +.event-log-icon { + margin-right: 8px; +} + +.event-log-counter { + margin-left: auto; + background: rgba(255, 255, 255, 0.2); + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; +} + +.event-log-content { + max-height: 250px; + overflow-y: auto; + background: rgba(255, 255, 255, 0.95); + border-radius: 8px; + padding: 12px; + backdrop-filter: blur(10px); +} + +.event-item { + padding: 8px 12px; + margin-bottom: 6px; + background: #ffffff; + border-radius: 6px; + border-left: 3px solid #667eea; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; +} + +.event-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.event-type { + font-weight: 600; + color: #2d3748; + font-size: 13px; +} + +.event-timestamp { + color: #718096; + font-size: 11px; + font-family: "Monaco", "Menlo", monospace; +} + +.event-message { + color: #4a5568; + font-size: 12px; + line-height: 1.4; +} + +.event-log-empty { + text-align: center; + padding: 40px 20px; + color: #718096; +} + +.event-log-empty-icon { + font-size: 24px; + margin-bottom: 8px; +} + +.event-log-empty-text { + font-style: italic; + font-size: 14px; +} + +/* Slot Content Styles */ +.slot-content { + padding: 15px; +} + +/* Simple Info Display */ +.simple-info-container { + margin-top: 20px; + padding: 10px; + background: #f0f0f0; + border-radius: 4px; + font-size: 12px; +} + +.simple-info-item { + margin-bottom: 4px; +} + +.simple-info-label { + font-weight: 600; +} + +/* Story Description Styles */ +.story-description { + margin-bottom: 20px; + padding: 16px; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + border-left: 4px solid #007bff; +} + +.story-description h3 { + margin: 0 0 8px 0; + color: #495057; + font-size: 18px; + font-weight: 600; +} + +.story-description p { + margin: 0 0 8px 0; + color: #6c757d; + font-size: 14px; + line-height: 1.5; +} + +.story-description code { + background: #e9ecef; + padding: 2px 6px; + border-radius: 3px; + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + font-size: 12px; + color: #d63384; +} + +/* Comparison Layout Styles */ +.comparison-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 20px; +} + +.comparison-panel { + padding: 16px; + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.comparison-panel h4 { + margin: 0 0 16px 0; + color: #495057; + font-size: 16px; + font-weight: 600; + text-align: center; + padding-bottom: 8px; + border-bottom: 2px solid #e9ecef; +} + +.selection-summary { + margin-top: 12px; + padding: 8px 12px; + background: #e3f2fd; + border-radius: 4px; + font-size: 12px; + color: #1976d2; + text-align: center; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .comparison-container { + grid-template-columns: 1fr; + gap: 16px; + } + + .story-container-wide { + max-width: 100%; + margin: 10px; + } +} diff --git a/packages/vue-component-library/src/stories/FilterSelections.spec.js b/packages/vue-component-library/src/stories/FilterSelections.spec.js new file mode 100644 index 000000000..f51f63aa4 --- /dev/null +++ b/packages/vue-component-library/src/stories/FilterSelections.spec.js @@ -0,0 +1,12 @@ +describe('FilterSelections', () => { + describe('Default', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=funkhaus-filterselections--default') + }) + + it('renders the component', () => { + cy.get('.filter-selections').should('exist') + cy.percySnapshot('FilterSelections: Default') + }) + }) +}) diff --git a/packages/vue-component-library/src/stories/FilterSelections.stories.js b/packages/vue-component-library/src/stories/FilterSelections.stories.js new file mode 100644 index 000000000..8dbf2aeaf --- /dev/null +++ b/packages/vue-component-library/src/stories/FilterSelections.stories.js @@ -0,0 +1,456 @@ +import { computed } from 'vue' +import FilterSelections from '@/lib-components/FilterSelections.vue' +import YearRangeFilter from '@/lib-components/YearRangeFilter.vue' +import './FilterDropdown.stories.css' + +export default { + title: 'Funkhaus / FilterSelections', + component: FilterSelections, + argTypes: { + title: { + control: 'text', + description: 'Title of the filter selections' + }, + filters: { + control: 'object', + description: 'Array of filter objects with name, slotName, and optional options' + }, + selectedOptions: { + control: 'object', + description: 'Selected options object' + } + }, + parameters: { + docs: { + description: { + component: 'FilterSelections is a component that displays a list of filters and allows the user to select options.' + } + } + } +} + +// Shared filter data +const subjectFilter = { + name: 'Subject', + slotName: 'subject', + options: [ + { label: 'History', value: 'history', count: 15420 }, + { label: 'Literature', value: 'literature', count: 8930 }, + { label: 'Science', value: 'science', count: 12650 }, + { label: 'Art', value: 'art', count: 7820 }, + { label: 'Philosophy', value: 'philosophy', count: 4560 } + ], + showAll: true +} + +const resourceTypeFilter = { + name: 'Resource Type', + slotName: 'resourceType', + options: [ + { label: 'Still Image', value: 'still-image', count: 21958 }, + { label: 'Moving Image', value: 'moving-image', count: 8012 }, + { label: 'Text', value: 'text', count: 33486 }, + { label: 'Sound Recording', value: 'sound-recording', count: 12796 }, + { label: 'Cartographic', value: 'cartographic', count: 3615 }, + { label: 'Notated Music', value: 'notated-music', count: 744 }, + { label: 'Sound Recording-Nonmusical', value: 'sound-recording-nonmusical', count: 528 }, + { label: 'Sound Recording-Musical', value: 'sound-recording-musical', count: 388 }, + { label: 'Mixed Material', value: 'mixed-material', count: 104 }, + { label: 'Three Dimensional Object', value: 'three-dimensional-object', count: 68 } + ], + showAll: true +} + +const genreFilter = { + name: 'Genre', + slotName: 'genre', + options: [ + { label: 'Fiction', value: 'fiction', count: 12340 }, + { label: 'Non-fiction', value: 'non-fiction', count: 18760 }, + { label: 'Biography', value: 'biography', count: 8920 }, + { label: 'Academic', value: 'academic', count: 15680 }, + { label: 'Reference', value: 'reference', count: 5430 } + ], + showAll: true +} + +// Default filters with options for all filters +const defaultFilters = [ + subjectFilter, + resourceTypeFilter, + genreFilter, + { + name: 'Location', + slotName: 'location', + options: [ + { label: 'United States', value: 'united-states', count: 45670 }, + { label: 'Europe', value: 'europe', count: 23450 }, + { label: 'Asia', value: 'asia', count: 18920 }, + { label: 'Africa', value: 'africa', count: 8760 }, + { label: 'South America', value: 'south-america', count: 5430 } + ], + showAll: true + }, + { + name: 'Names', + slotName: 'names', + options: [ + { label: 'Smith, John', value: 'smith-john', count: 2340 }, + { label: 'Johnson, Mary', value: 'johnson-mary', count: 1890 }, + { label: 'Williams, Robert', value: 'williams-robert', count: 1560 }, + { label: 'Brown, Sarah', value: 'brown-sarah', count: 1230 }, + { label: 'Davis, Michael', value: 'davis-michael', count: 980 } + ], + showAll: true + }, + { + name: 'Date Range', + slotName: 'dateRange', + showAll: true + }, + { + name: 'Collection', + slotName: 'collection', + options: [ + { label: 'Digital Collections', value: 'digital', count: 45670 }, + { label: 'Archives', value: 'archives', count: 23450 }, + { label: 'Special Collections', value: 'special', count: 18920 }, + { label: 'Rare Books', value: 'rare-books', count: 8760 }, + { label: 'Manuscripts', value: 'manuscripts', count: 5430 } + ], + showAll: true + }, + { + name: 'Repository', + slotName: 'repository', + options: [ + { label: 'Main Library', value: 'main-library', count: 45670 }, + { label: 'Research Library', value: 'research-library', count: 23450 }, + { label: 'Special Collections', value: 'special-collections', count: 18920 }, + { label: 'Digital Archive', value: 'digital-archive', count: 8760 }, + { label: 'University Archives', value: 'university-archives', count: 5430 } + ], + showAll: true + }, + { + name: 'Program', + slotName: 'program', + options: [ + { label: 'Undergraduate', value: 'undergraduate', count: 45670 }, + { label: 'Graduate', value: 'graduate', count: 23450 }, + { label: 'Research', value: 'research', count: 18920 }, + { label: 'Community', value: 'community', count: 8760 }, + { label: 'Faculty', value: 'faculty', count: 5430 } + ], + showAll: true + } +] + +// Long filters with many options +const longFilters = [ + { + name: 'Very Long Filter Name That Might Wrap and Should Be Handled Gracefully', + slotName: 'longName', + options: Array.from({ length: 5 }, (_, i) => ({ + label: `This is an extremely long option name ${i + 1} that should test how the component handles text wrapping and layout`, + value: `long-option-${i + 1}`, + count: Math.floor(Math.random() * 10000) + })), + showAll: true + }, + { + name: 'Another Extremely Long Filter Name That Should Be Handled Gracefully in the UI', + slotName: 'anotherLongName', + options: Array.from({ length: 5 }, (_, i) => ({ + label: `Option ${i + 1} with very long descriptive text that might cause layout issues`, + value: `another-long-option-${i + 1}`, + count: Math.floor(Math.random() * 5000) + })), + showAll: true + } +] + +export function Default() { + return { + components: { FilterSelections, YearRangeFilter }, + data() { + return { + filters: defaultFilters, + selectedOptions: {}, + dateRange: { + minValue: 1950, + maxValue: 2000 + }, + eventLog: [] + } + }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + methods: { + onSelectionChange(selections) { + this.selectedOptions = selections + const totalSelections = Object.values(selections).reduce((sum, arr) => sum + arr.length, 0) + this.addToLog('📊 Selection Changed', `Total selections: ${totalSelections}`) + }, + onOptionSelected(filterName, option) { + this.addToLog('✅ Option Selected', `${filterName} → ${option.label}`) + }, + onOptionDeselected(filterName, option) { + this.addToLog('❌ Option Deselected', `${filterName} → ${option.label}`) + }, + onDateRangeChange(range) { + // For the story, only log; do not mutate props + this.addToLog('📅 Date Range Changed', `${range.min} - ${range.max}`) + }, + addToLog(type, message) { + const timestamp = new Date().toLocaleTimeString() + this.eventLog.unshift({ type, message, timestamp }) + // Keep only last 10 events + if (this.eventLog.length > 10) + this.eventLog = this.eventLog.slice(0, 10) + } + }, + template: ` +
+ + + + + + +
+

Selected Options Object:

+
{{ JSON.stringify(selectedOptions, null, 2) }}
+ +
+ + +
+

+ 📋 + Event Log + {{ eventLog.length }} events +

+
+
+
+ {{ event.type }} + {{ event.timestamp }} +
+
{{ event.message }}
+
+
+
🎯
+
No events yet. Interact with the filters to see events here.
+
+
+
+
+ ` + } +} + +export function Long() { + return { + components: { FilterSelections }, + data() { + return { filters: longFilters } + }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + template: ` +
+ +
+ ` + } +} + +// Sample filters including a date filter that will use the YearRangeFilter +const filtersWithDateRange = [ + subjectFilter, + { + name: 'Resource Type', + slotName: 'resourceType', + options: resourceTypeFilter.options.slice(0, 5), // Use first 5 options + showAll: true + }, + { + name: 'Date Range', + slotName: 'dateRange', + // No options - this will use the slot + }, + genreFilter +] + +export function WithYearRangeFilter() { + return { + components: { FilterSelections, YearRangeFilter }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + data() { + return { + filters: filtersWithDateRange, + selectedOptions: {}, + dateRange: { + minValue: 1950, + maxValue: 2000 + } + } + }, + methods: { + onSelectionChange(selections) { + this.selectedOptions = selections + }, + onOptionSelected(filterName, option) { + // Option selected event + }, + onOptionDeselected(filterName, option) { + // Option deselected event + }, + onDateRangeChange(range) { + // In this story, do not feed back into props; just handle externally + } + }, + template: ` +
+ + + + +
+

Selected Options Object:

+
{{ JSON.stringify(selectedOptions, null, 2) }}
+
+ Date Range: + {{ dateRange.minValue }} - {{ dateRange.maxValue }} +
+
+
+ ` + } +} + +export function MultipleCustomSlots() { + return { + components: { FilterSelections, YearRangeFilter }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + data() { + return { + filters: [ + { + name: 'Date Range', + slotName: 'dateRange', + }, + { + name: 'Price Range', + slotName: 'priceRange', + }, + { + name: 'Subject', + slotName: 'subject', + options: [ + { label: 'History', value: 'history', count: 15420 }, + { label: 'Literature', value: 'literature', count: 8930 }, + { label: 'Science', value: 'science', count: 12650 } + ], + showAll: true + } + ], + selectedOptions: {}, + dateRange: { minValue: 1950, maxValue: 2000 }, + priceRange: { minValue: 100, maxValue: 500 } + } + }, + methods: { + onSelectionChange(selections) { + this.selectedOptions = selections + }, + onDateRangeChange(range) { + // Only log/handle externally + }, + onPriceRangeChange(range) { + // Only log/handle externally + } + }, + template: ` +
+ + + + + + + + +
+ ` + } +} diff --git a/packages/vue-component-library/src/stories/RefineSearchPanel.spec.js b/packages/vue-component-library/src/stories/RefineSearchPanel.spec.js new file mode 100644 index 000000000..fd95e4925 --- /dev/null +++ b/packages/vue-component-library/src/stories/RefineSearchPanel.spec.js @@ -0,0 +1,12 @@ +describe('RefineSearchPanel', () => { + describe('Default', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=funkhaus-refinesearchpanel--default') + }) + + it('renders the component', () => { + cy.get('.refine-search-panel').should('exist') + cy.percySnapshot('RefineSearchPanel: Default') + }) + }) +}) diff --git a/packages/vue-component-library/src/stories/RefineSearchPanel.stories.js b/packages/vue-component-library/src/stories/RefineSearchPanel.stories.js new file mode 100644 index 000000000..a234dce1c --- /dev/null +++ b/packages/vue-component-library/src/stories/RefineSearchPanel.stories.js @@ -0,0 +1,457 @@ +import { computed } from 'vue' +import RefineSearchPanel from '../lib-components/RefineSearchPanel.vue' +import YearRangeFilter from '../lib-components/YearRangeFilter.vue' +import './FilterDropdown.stories.css' +import router from '@/router' + +export default { + title: 'Funkhaus / RefineSearchPanel', + component: RefineSearchPanel, + argTypes: { + filters: { + control: 'object', + description: 'Array of filter objects with name, slotName, and optional options' + } + } +} + +// Shared filter data +const subjectFilter = { + name: 'Subject', + slotName: 'subject', + facetField: 'subject_tesim', + options: [ + { label: 'History', value: 'history', count: 15420 }, + { label: 'Literature', value: 'literature', count: 8930 }, + { label: 'Science', value: 'science', count: 12650 }, + { label: 'Art', value: 'art', count: 7820 }, + { label: 'Philosophy', value: 'philosophy', count: 4560 } + ], + showAll: true +} + +const resourceTypeFilter = { + name: 'Resource Type', + slotName: 'resourceType', + facetField: 'resource_type_tesim', + options: [ + { label: 'Still Image', value: 'still-image', count: 21958 }, + { label: 'Moving Image', value: 'moving-image', count: 8012 }, + { label: 'Text', value: 'text', count: 33486 }, + { label: 'Sound Recording', value: 'sound-recording', count: 12796 }, + { label: 'Cartographic', value: 'cartographic', count: 3615 }, + { label: 'Notated Music', value: 'notated-music', count: 744 }, + { label: 'Sound Recording-Nonmusical', value: 'sound-recording-nonmusical', count: 528 }, + { label: 'Sound Recording-Musical', value: 'sound-recording-musical', count: 388 }, + { label: 'Mixed Material', value: 'mixed-material', count: 104 }, + { label: 'Three Dimensional Object', value: 'three-dimensional-object', count: 68 } + ], + showAll: true +} + +const genreFilter = { + name: 'Genre', + slotName: 'genre', + facetField: 'genre_tesim', + options: [ + { label: 'Fiction', value: 'fiction', count: 12340 }, + { label: 'Non-fiction', value: 'non-fiction', count: 18760 }, + { label: 'Biography', value: 'biography', count: 8920 }, + { label: 'Academic', value: 'academic', count: 15680 }, + { label: 'Reference', value: 'reference', count: 5430 } + ], + showAll: true +} + +// Default filters with options for all filters +const defaultFilters = [ + subjectFilter, + resourceTypeFilter, + genreFilter, + { + name: 'Location', + slotName: 'location', + facetField: 'location_tesim', + options: [ + { label: 'United States', value: 'united-states', count: 45670 }, + { label: 'Europe', value: 'europe', count: 23450 }, + { label: 'Asia', value: 'asia', count: 18920 }, + { label: 'Africa', value: 'africa', count: 8760 }, + { label: 'South America', value: 'south-america', count: 5430 } + ], + showAll: true + }, + { + name: 'Names', + slotName: 'names', + facetField: 'names_tesim', + options: [ + { label: 'Smith, John', value: 'smith-john', count: 2340 }, + { label: 'Johnson, Mary', value: 'johnson-mary', count: 1890 }, + { label: 'Williams, Robert', value: 'williams-robert', count: 1560 }, + { label: 'Brown, Sarah', value: 'brown-sarah', count: 1230 }, + { label: 'Davis, Michael', value: 'davis-michael', count: 980 } + ], + showAll: true + }, + { + name: 'Date Range', + slotName: 'dateRange', + showAll: true + }, + { + name: 'Collection', + slotName: 'collection', + facetField: 'collection_tesim', + options: [ + { label: 'Digital Collections', value: 'digital', count: 45670 }, + { label: 'Archives', value: 'archives', count: 23450 }, + { label: 'Special Collections', value: 'special', count: 18920 }, + { label: 'Rare Books', value: 'rare-books', count: 8760 }, + { label: 'Manuscripts', value: 'manuscripts', count: 5430 } + ], + showAll: true + }, + { + name: 'Repository', + slotName: 'repository', + facetField: 'repository_tesim', + options: [ + { label: 'Main Library', value: 'main-library', count: 45670 }, + { label: 'Research Library', value: 'research-library', count: 23450 }, + { label: 'Special Collections', value: 'special-collections', count: 18920 }, + { label: 'Digital Archive', value: 'digital-archive', count: 8760 }, + { label: 'University Archives', value: 'university-archives', count: 5430 } + ], + showAll: true + }, + { + name: 'Program', + slotName: 'program', + options: [ + { label: 'Undergraduate', value: 'undergraduate', count: 45670 }, + { label: 'Graduate', value: 'graduate', count: 23450 }, + { label: 'Research', value: 'research', count: 18920 }, + { label: 'Community', value: 'community', count: 8760 }, + { label: 'Faculty', value: 'faculty', count: 5430 } + ], + showAll: true + } +] + +// Long filters with many options +const longFilters = [ + { + name: 'Very Long Filter Name That Might Wrap and Should Be Handled Gracefully', + slotName: 'longName', + options: Array.from({ length: 5 }, (_, i) => ({ + label: `This is an extremely long option name ${i + 1} that should test how the component handles text wrapping and layout`, + value: `long-option-${i + 1}`, + count: Math.floor(Math.random() * 10000) + })), + showAll: true + }, + { + name: 'Another Extremely Long Filter Name That Should Be Handled Gracefully in the UI', + slotName: 'anotherLongName', + options: Array.from({ length: 5 }, (_, i) => ({ + label: `Option ${i + 1} with very long descriptive text that might cause layout issues`, + value: `another-long-option-${i + 1}`, + count: Math.floor(Math.random() * 5000) + })), + showAll: true + } +] + +export function Default() { + router.push({ query: { page: '2' } }) + return { + components: { RefineSearchPanel, YearRangeFilter }, + data() { + return { + filters: defaultFilters, + selectedOptions: {}, + dateRange: { + minValue: 1950, + maxValue: 2000 + }, + eventLog: [] + } + }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + methods: { + onSelectionChange(selections) { + this.selectedOptions = selections + const totalSelections = Object.values(selections).reduce((sum, arr) => sum + arr.length, 0) + this.addToLog('📊 Selection Changed', `Total selections: ${totalSelections}`) + }, + onOptionSelected(filterName, option) { + this.addToLog('✅ Option Selected', `${filterName} → ${option.label}`) + }, + onOptionDeselected(filterName, option) { + this.addToLog('❌ Option Deselected', `${filterName} → ${option.label}`) + }, + onDateRangeChange(range) { + // For the story, only log; do not mutate props + this.addToLog('📅 Date Range Changed', `${range.min} - ${range.max}`) + }, + addToLog(type, message) { + const timestamp = new Date().toLocaleTimeString() + this.eventLog.unshift({ type, message, timestamp }) + // Keep only last 5 events + if (this.eventLog.length > 5) + this.eventLog = this.eventLog.slice(0, 5) + } + }, + template: ` +
+ + + + + + +
+

Selected Options Object:

+
{{ JSON.stringify(selectedOptions, null, 2) }}
+
+ URL Query: +
{{ JSON.stringify($route && $route.query ? $route.query : {}, null, 2) }}
+
+
+ + +
+

+ 📋 + Event Log + {{ eventLog.length }} events +

+
+
+
+ {{ event.type }} + {{ event.timestamp }} +
+
{{ event.message }}
+
+
+
🎯
+
No events yet. Interact with the filters to see events here.
+
+
+
+
+ ` + } +} + +export function Long() { + return { + components: { RefineSearchPanel }, + data() { + return { filters: longFilters } + }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + template: ` +
+ +
+ ` + } +} + +// Sample filters including a date filter that will use the YearRangeFilter +const filtersWithDateRange = [ + subjectFilter, + { + name: 'Resource Type', + slotName: 'resourceType', + options: resourceTypeFilter.options.slice(0, 5), // Use first 5 options + showAll: true + }, + { + name: 'Date Range', + slotName: 'dateRange', + // No options - this will use the slot + }, + genreFilter +] + +export function WithYearRangeFilter() { + return { + components: { RefineSearchPanel, YearRangeFilter }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + data() { + return { + filters: filtersWithDateRange, + selectedOptions: {}, + dateRange: { + minValue: 1950, + maxValue: 2000 + } + } + }, + methods: { + onSelectionChange(selections) { + this.selectedOptions = selections + }, + onOptionSelected(filterName, option) { + // Option selected event + }, + onOptionDeselected(filterName, option) { + // Option deselected event + }, + onDateRangeChange(range) { + // In this story, do not feed back into props; just log or handle externally + } + }, + template: ` +
+ + + + +
+

Selected Options Object:

+
{{ JSON.stringify(selectedOptions, null, 2) }}
+
+ URL Query: +
{{ JSON.stringify($route && $route.query ? $route.query : {}, null, 2) }}
+
+ +
+
+ ` + } +} + +export function MultipleCustomSlots() { + return { + components: { RefineSearchPanel, YearRangeFilter }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + data() { + return { + filters: [ + { + name: 'Date Range', + slotName: 'dateRange', + }, + { + name: 'Price Range', + slotName: 'priceRange', + }, + { + name: 'Subject', + slotName: 'subject', + options: [ + { label: 'History', value: 'history', count: 15420 }, + { label: 'Literature', value: 'literature', count: 8930 }, + { label: 'Science', value: 'science', count: 12650 } + ], + showAll: true + } + ], + selectedOptions: {}, + dateRange: { minValue: 1950, maxValue: 2000 }, + priceRange: { minValue: 100, maxValue: 500 } + } + }, + methods: { + onSelectionChange(selections) { + this.selectedOptions = selections + }, + onDateRangeChange(range) { + // Only log/handle externally in a real app + }, + onPriceRangeChange(range) { + // Only log/handle externally in a real app + } + }, + template: ` +
+ + + + + + + +
+

URL Query:

+
{{ JSON.stringify($route && $route.query ? $route.query : {}, null, 2) }}
+
+
+ ` + } +} diff --git a/packages/vue-component-library/src/styles/dlc/_filter-selections.scss b/packages/vue-component-library/src/styles/dlc/_filter-selections.scss new file mode 100644 index 000000000..7859212f1 --- /dev/null +++ b/packages/vue-component-library/src/styles/dlc/_filter-selections.scss @@ -0,0 +1,176 @@ +.filter-selections, +.filter-selections.dlc { + .filter-item { + &.is-opened { + .filter-summary { + background-color: var(--color-primary-blue-01); + } + } + } + + .filter-chevron { + font-size: 12px; + + @include animate-normal; + } + + .effect-slide-toggle.is-opened .filter-chevron { + transform: rotate(180deg); + } + + .filter-summary { + padding: 7px 15px; + + display: flex; + justify-content: space-between; + align-items: center; + + background-color: var(--color-white); + + color: var(--color-secondary-grey-05); + font-weight: 500; + font-size: 18px; + font-family: var(--font-secondary); + + cursor: pointer; + + @include animate-normal; + + &.is-filtered { + .filter-chevron { + transform: rotate(0deg); + } + } + } + + .filter-options { + position: relative; + } + + .filter-option { + padding: 7px 15px; + + display: flex; + justify-content: space-between; + align-items: center; + + color: var(--color-secondary-grey-05); + font-weight: 500; + font-size: 18px; + font-family: var(--font-secondary); + + cursor: pointer; + @include animate-normal; + + &.is-selected { + background-color: var(--color-secondary-blue-02); + color: var(--color-white); + z-index: 10; + + .option-count { + color: var(--color-primary-blue-02); + font-weight: 500; + } + } + } + + .option-checkbox { + position: absolute; + + opacity: 0; + + pointer-events: none; + } + + .option-right { + display: flex; + align-items: center; + gap: 8px; + } + + .option-count { + color: var(--color-secondary-grey-03); + font-size: 16px; + font-family: var(--font-secondary); + font-weight: 400; + + @include animate-normal; + } + + .option-remove { + display: flex; + align-items: center; + justify-content: center; + + width: 20px; + height: 20px; + + color: var(--color-white); + font-size: 30px; + font-weight: light; + + @include animate-normal; + } + + .see-all { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + + font-weight: 500; + color: var(--color-secondary-grey-05); + + .see-all-arrow { + translate: 0 -3px; + font-size: 22px; + line-height: 1; + @include animate-normal; + } + } + + // Hovers + @media #{$has-hover} { + .filter-summary:hover { + background-color: var(--color-primary-blue-01); + } + + .filter-option:hover, + .see-all:hover { + background-color: var(--color-secondary-blue-02); + color: var(--color-white); + + .see-all-arrow { + transform: translateX(4px); + } + + .option-count { + color: var(--color-primary-blue-02); + } + } + } + + // TransitionGroup animations for filter options + .filter-option-move, + .filter-option-leave-active, + .filter-option-enter-active { + @include animate-normal; + } + + .filter-option-enter-from { + opacity: 0; + } + + .filter-option-leave-to { + opacity: 0; + z-index: 1; + position: absolute; + } + + // Ensure leaving items are taken out of layout flow so that moving + // animations can be calculated correctly + .filter-option-leave-active { + position: absolute; + width: 100%; + } +} diff --git a/packages/vue-component-library/src/styles/dlc/_refine-search-panel.scss b/packages/vue-component-library/src/styles/dlc/_refine-search-panel.scss new file mode 100644 index 000000000..7b96cdbbe --- /dev/null +++ b/packages/vue-component-library/src/styles/dlc/_refine-search-panel.scss @@ -0,0 +1,51 @@ +.refine-search-panel, +.refine-search-panel.dlc { + max-width: 300px; + + border: 1px solid var(--color-secondary-grey-02); + border-radius: 8px; + + .filter-reveal-summary { + padding: 7px 15px; + + display: flex; + justify-content: space-between; + align-items: center; + + cursor: pointer; + + @include animate-normal; + + .filter-title { + margin: 0; + + color: var(--color-primary-blue-05); + font-size: 16px; + font-weight: medium; + text-transform: uppercase; + font-family: var(--font-primary); + } + + .filter-chevron { + font-size: 12px; + @include animate-normal; + } + } + + /* Rotate chevron when opened */ + &.is-opened .filter-chevron { + transform: rotate(180deg); + } + + // Hovers + @media #{$has-hover} { + .filter-reveal-summary:hover { + background-color: var(--color-primary-blue-01); + } + } + + // Breakpoints + @media #{$small} { + max-width: 100%; + } +}