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);
+ }
+ }
+ }
+}