diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 000000000..5b88cf2bf --- /dev/null +++ b/NOTES.md @@ -0,0 +1,11 @@ +Designs: +https://www.figma.com/design/CDhWDARLb36ftkQce1LyLC/Breakpoints?node-id=1-25&m=dev + +https://www.figma.com/design/CDhWDARLb36ftkQce1LyLC/Breakpoints?node-id=1-26&p=f&m=dev + +Read in Recording: +https://drive.google.com/file/d/17tiGmE4aax-faoo5Rjue2Idbx3OGCFGa/view?usp=sharing + +Relevant Links +https://www.library.ucla.edu/ +https://digital.library.ucla.edu/catalog/ark:/21198/z1x98m6j \ No newline at end of file diff --git a/packages/vue-component-library/src/index.ts b/packages/vue-component-library/src/index.ts index 4ea6900d3..2f9aa210d 100644 --- a/packages/vue-component-library/src/index.ts +++ b/packages/vue-component-library/src/index.ts @@ -97,6 +97,7 @@ export { default as NavSearch } from './lib-components/NavSearch.vue' export { default as NavSecondary } from './lib-components/NavSecondary.vue' export { default as PageAnchor } from './lib-components/PageAnchor.vue' export { default as PullQuote } from './lib-components/PullQuote.vue' +export { default as YearRangeFilter } from './lib-components/YearRangeFilter.vue' export { default as ResponsiveImage } from './lib-components/ResponsiveImage.vue' export { default as ResponsiveVideo } from './lib-components/ResponsiveVideo.vue' export { default as RichText } from './lib-components/RichText.vue' diff --git a/packages/vue-component-library/src/lib-components/YearRangeFilter.vue b/packages/vue-component-library/src/lib-components/YearRangeFilter.vue new file mode 100644 index 000000000..d5697d7b0 --- /dev/null +++ b/packages/vue-component-library/src/lib-components/YearRangeFilter.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/packages/vue-component-library/src/stories/FooterMain.stories.js b/packages/vue-component-library/src/stories/FooterMain.stories.js index a214038c2..472bb7248 100644 --- a/packages/vue-component-library/src/stories/FooterMain.stories.js +++ b/packages/vue-component-library/src/stories/FooterMain.stories.js @@ -10,39 +10,39 @@ export default { const mockFTVAFooterPrimary = { socialItems: [ { - id: "4343807", - name: "Facebook", - to: "https://www.facebook.com/UCLAFTVArchive/", + id: '4343807', + name: 'Facebook', + to: 'https://www.facebook.com/UCLAFTVArchive/', classes: null, - target: "1" + target: '1' }, { - id: "4343808", - name: "Instagram", - to: "https://www.instagram.com/uclaftvarchive/", + id: '4343808', + name: 'Instagram', + to: 'https://www.instagram.com/uclaftvarchive/', classes: null, - target: "1" + target: '1' }, { - id: "4343809", - name: "Bluesky", - to: "https://bsky.app/profile/uclaftvarchive.bsky.social", + id: '4343809', + name: 'Bluesky', + to: 'https://bsky.app/profile/uclaftvarchive.bsky.social', classes: null, - target: "1" + target: '1' }, { - id: "4343810", - name: "YouTube", - to: "https://www.youtube.com/channel/UCKwx-Ugwnha7SvfyiHBV9iQ", - classes: "", - target: "1" + id: '4343810', + name: 'YouTube', + to: 'https://www.youtube.com/channel/UCKwx-Ugwnha7SvfyiHBV9iQ', + classes: '', + target: '1' }, { - id: "4343811", - name: "Letterboxd", - to: "https://letterboxd.com/uclaftvarchive/", + id: '4343811', + name: 'Letterboxd', + to: 'https://letterboxd.com/uclaftvarchive/', classes: null, - target: "1" + target: '1' }, ], diff --git a/packages/vue-component-library/src/stories/FooterPrimary.stories.js b/packages/vue-component-library/src/stories/FooterPrimary.stories.js index 2f1f267c1..fe70f5bd6 100644 --- a/packages/vue-component-library/src/stories/FooterPrimary.stories.js +++ b/packages/vue-component-library/src/stories/FooterPrimary.stories.js @@ -61,39 +61,39 @@ const mock = { const mockFTVAFooterPrimary = { socialItems: [ { - id: "4343807", - name: "Facebook", - to: "https://www.facebook.com/UCLAFTVArchive/", + id: '4343807', + name: 'Facebook', + to: 'https://www.facebook.com/UCLAFTVArchive/', classes: null, - target: "1" + target: '1' }, { - id: "4343808", - name: "Instagram", - to: "https://www.instagram.com/uclaftvarchive/", + id: '4343808', + name: 'Instagram', + to: 'https://www.instagram.com/uclaftvarchive/', classes: null, - target: "1" + target: '1' }, { - id: "4343809", - name: "Bluesky", - to: "https://bsky.app/profile/uclaftvarchive.bsky.social", + id: '4343809', + name: 'Bluesky', + to: 'https://bsky.app/profile/uclaftvarchive.bsky.social', classes: null, - target: "1" + target: '1' }, { - id: "4343810", - name: "YouTube", - to: "https://www.youtube.com/channel/UCKwx-Ugwnha7SvfyiHBV9iQ", - classes: "", - target: "1" + id: '4343810', + name: 'YouTube', + to: 'https://www.youtube.com/channel/UCKwx-Ugwnha7SvfyiHBV9iQ', + classes: '', + target: '1' }, { - id: "4343811", - name: "Letterboxd", - to: "https://letterboxd.com/uclaftvarchive/", + id: '4343811', + name: 'Letterboxd', + to: 'https://letterboxd.com/uclaftvarchive/', classes: null, - target: "1" + target: '1' }, ], diff --git a/packages/vue-component-library/src/stories/YearRangeFilter.spec.js b/packages/vue-component-library/src/stories/YearRangeFilter.spec.js new file mode 100644 index 000000000..77d771117 --- /dev/null +++ b/packages/vue-component-library/src/stories/YearRangeFilter.spec.js @@ -0,0 +1,12 @@ +describe('YearRangeFilter', () => { + describe('Default', () => { + beforeEach(() => { + cy.visit('/iframe.html?id=funkhaus-yearrangefilter--default') + }) + + it('renders the component', () => { + cy.get('.year-range-filter').should('exist') + cy.percySnapshot('YearRangeFilter: Default') + }) + }) +}) diff --git a/packages/vue-component-library/src/stories/YearRangeFilter.stories.js b/packages/vue-component-library/src/stories/YearRangeFilter.stories.js new file mode 100644 index 000000000..56d0332ac --- /dev/null +++ b/packages/vue-component-library/src/stories/YearRangeFilter.stories.js @@ -0,0 +1,154 @@ +import { computed } from 'vue' +import YearRangeFilter from '../lib-components/YearRangeFilter.vue' +import router from '@/router' + +export default { + title: 'Funkhaus / YearRangeFilter', + component: YearRangeFilter, + argTypes: { + minValue: { + control: { type: 'number' }, + description: 'Current minimum value', + }, + maxValue: { + control: { type: 'number' }, + description: 'Current maximum value', + }, + step: { + control: { type: 'number' }, + description: 'Step size for the range', + }, + disabled: { + control: { type: 'boolean' }, + description: 'Whether the component is disabled', + }, + minParam: { + control: { type: 'text' }, + description: 'Query parameter name for minimum value (default: year_gte)', + }, + maxParam: { + control: { type: 'text' }, + description: 'Query parameter name for maximum value (default: year_lte)', + }, + clearPagination: { + control: { type: 'boolean' }, + description: 'Whether to clear pagination (page) parameter when filters change', + }, + }, + parameters: { + docs: { + description: { + component: 'A range selector component with dual input fields and a range slider.', + }, + }, + }, +} + +function Template(args) { + router.push({ query: { page: '2' } }) + return { + components: { YearRangeFilter }, + provide() { + return { + theme: computed(() => 'dlc'), + } + }, + setup() { + // Mock router for Storybook demonstration + // reactively read the router query + const currentQuery = computed(() => router.currentRoute.value.query) + + // make a canonical querystring to display (sorted for stability) + const queryString = computed(() => { + const params = new URLSearchParams() + // sort keys for consistent output + const entries = Object.entries(currentQuery.value).sort(([a], [b]) => a.localeCompare(b)) + for (const [key, val] of entries) { + if (Array.isArray(val)) + for (const v of val) params.append(key, String(v)) + else if (val != null) + params.set(key, String(val)) + } + const s = params.toString() + return s ? `?${s}` : '' + }) + + return { args, currentQuery, queryString } + }, + template: ` +
+ + +
+
Router querystring
+
+ {{ queryString }} +
+ +
Router query (JSON)
+
{{ currentQuery }}
+
+
+ `, + } +} + +export const Default = Template.bind({}) +Default.args = { + minValue: 20, + maxValue: 80, + step: 1, + disabled: false, +} + +export const YearRange = Template.bind({}) +YearRange.args = { + minValue: 1950, + maxValue: 2000, + step: 1, + disabled: false, +} + +export const Disabled = Template.bind({}) +Disabled.args = { + minValue: 30, + maxValue: 70, + step: 1, + disabled: true, +} + +export const RouterIntegration = Template.bind({}) +RouterIntegration.args = { + minValue: 1900, + maxValue: 2024, + step: 1, + disabled: false, + minParam: 'year_gte', + maxParam: 'year_lte', + clearPagination: true, +} +RouterIntegration.parameters = { + docs: { + description: { + story: 'Demonstrates router integration with custom parameter names and pagination clearing.', + }, + }, +} + +export const CustomParameters = Template.bind({}) +CustomParameters.args = { + minValue: 1900, + maxValue: 2024, + step: 1, + disabled: false, + minParam: 'year_gte', + maxParam: 'year_lte', + clearPagination: false, +} +CustomParameters.parameters = { + docs: { + description: { + story: 'Example with custom parameter names and pagination preservation enabled.', + }, + }, +} diff --git a/packages/vue-component-library/src/styles/dlc/_year-range-filter.scss b/packages/vue-component-library/src/styles/dlc/_year-range-filter.scss new file mode 100644 index 000000000..9eb0fb726 --- /dev/null +++ b/packages/vue-component-library/src/styles/dlc/_year-range-filter.scss @@ -0,0 +1,257 @@ +.year-range-filter, +.year-range-filter.dlc { + display: flex; + flex-direction: column; + gap: 16px; + + padding: 10px 8px 7px 13px; + + // Number Range inputs + .range-inputs { + display: flex; + align-items: center; + gap: 8px; + } + + .range-number-input { + width: 70px; + padding: 8px 0; + border: 1px solid transparent; + border-radius: 5px; + background-color: var(--color-secondary-grey-01); + color: var(--color-secondary-blue-01); + font-family: var(--font-primary); + font-size: 16px; + font-weight: 500; + line-height: 1; + text-align: center; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: var(--color-default-blue-03); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + .range-separator { + background-color: var(--color-secondary-blue-01); + width: 11px; + height: 2px; + border-radius: 2px; + } + + .limit-button { + margin-left: 4px; + padding: 10px 20px; + border: none; + border-radius: 30px; + background-color: var(--color-secondary-blue-01); + color: var(--color-white); + font-family: var(--font-secondary); + font-size: 18px; + line-height: 1; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + + &:focus { + outline: 2px solid var(--color-secondary-blue-02); + } + + &:disabled { + cursor: not-allowed; + } + } + + .range-slider-container { + position: relative; + display: flex; + align-items: center; + + max-width: 100%; + } + + .range-track { + position: relative; + width: 100%; + height: 10px; + background-color: var(--color-secondary-grey-01); + border-radius: 5px; + touch-action: none; + } + + .range-slider { + position: absolute; + top: 50%; + left: 0; + width: 100%; + height: 10px; + margin: 0; + padding: 0; + background: transparent; + cursor: pointer; + transform: translateY(-50%); + -webkit-appearance: none; + appearance: none; + pointer-events: none; + + &:focus { + outline: none; + } + + // Webkit slider thumb + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--color-secondary-grey-03); + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + pointer-events: auto; + } + + // Firefox slider thumb + &::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--color-secondary-grey-03); + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + pointer-events: auto; + } + + // Webkit slider track + &::-webkit-slider-track { + background: transparent; + height: 4px; + } + + // Firefox slider track + &::-moz-range-track { + background: transparent; + height: 4px; + border: none; + } + + &:disabled { + cursor: not-allowed; + + &::-moz-range-thumb { + cursor: not-allowed; + } + &::-webkit-slider-thumb { + cursor: not-allowed; + } + } + } + + // Ensure min slider is always below max slider to prevent visual overlap + .range-slider--min { + z-index: 1; + } + + .range-slider--max { + z-index: 2; + } + + // When the user explicitly targets a handle, raise that handle above the other + .range-track.active-min { + .range-slider--min { + z-index: 3; + &::-webkit-slider-thumb { + transform: scale(1.25); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.05); + } + &::-moz-range-thumb { + transform: scale(1.25); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.05); + } + } + .range-slider--max { + z-index: 2; + } + } + + .range-track.active-max { + .range-slider--max { + z-index: 3; + &::-webkit-slider-thumb { + transform: scale(1.25); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.05); + } + &::-moz-range-thumb { + transform: scale(1.25); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.05); + } + } + .range-slider--min { + z-index: 2; + } + } + + // When thumbs are overlapping (close range), nudge the active thumb horizontally + .range-track.overlap-min { + .range-slider--min { + &::-webkit-slider-thumb { + transform: scale(1.25) translateX(-10px); + } + &::-moz-range-thumb { + transform: scale(1.25) translateX(-10px); + } + } + } + + .range-track.overlap-max { + .range-slider--max { + &::-webkit-slider-thumb { + transform: scale(1.25) translateX(10px); + } + &::-moz-range-thumb { + transform: scale(1.25) translateX(10px); + } + } + } + + // Hovers + @media #{$has-hover} { + .limit-button { + &:hover:not(:disabled) { + background-color: var(--color-secondary-blue-02); + } + } + + .range-slider { + &::-webkit-slider-thumb:hover { + transform: scale(1.1); + border: 2px solid var(--color-secondary-blue-03); + } + + &::-moz-range-thumb:hover { + transform: scale(1.1); + border: 2px solid var(--color-secondary-blue-03); + } + + &:focus::-webkit-slider-thumb { + transform: scale(1.2); + } + &:focus::-moz-range-thumb { + transform: scale(1.2); + } + + &:active::-webkit-slider-thumb { + transform: scale(1.25); + } + &:active::-moz-range-thumb { + transform: scale(1.25); + } + } + } +}