Skip to content

Commit dd62ba0

Browse files
authored
Merge pull request #207 from rei/filmstrip
Filmstrip
2 parents cb12645 + 7976586 commit dd62ba0

33 files changed

Lines changed: 6525 additions & 1070 deletions

package-lock.json

Lines changed: 2652 additions & 1047 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@
132132
},
133133
"dependencies": {
134134
"@rei/cdr-tokens": "^12.7.0",
135+
"@vueuse/core": "^12.7.0",
136+
"radix-vue": "^1.9.16",
135137
"tabbable": "^4.0.0"
136138
},
137139
"peerDependencies": {

rollupOptions.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import browserTargets from './browserTargets.mjs';
44
export default {
55
// Externalize peerDependencies
66
external: (id) =>
7-
['vue', 'core-js', '@rei/cdr-tokens', 'tabbable'].some(
7+
['vue', 'core-js', '@rei/cdr-tokens', 'tabbable', 'radix-vue', '@vueuse/core'].some(
88
(dep) => dep === id || id.startsWith(`${dep}/`),
99
),
1010
output: {

src/components/examples.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import checkbox from 'componentsdir/checkbox/examples/checkboxes.vue';
99
import chip from 'componentsdir/chip/examples/Chip.vue';
1010
import choreographer from 'componentsdir/choreographer/examples/Choreographer.vue';
1111
import container from 'componentsdir/container/examples/Container.vue';
12+
import filmstrip from 'componentsdir/filmstrip/examples/Filmstrip.vue';
1213
import formGroup from 'componentsdir/formGroup/examples/FormGroup.vue';
1314
import fulfillmentTile from 'componentsdir/fulfillmentTile/examples/FulfillmentTile.vue';
1415
import grid from 'componentsdir/grid/examples/Grid.vue';
@@ -58,6 +59,7 @@ export default {
5859
chip,
5960
choreographer,
6061
container,
62+
filmstrip,
6163
fulfillmentTile,
6264
formGroup,
6365
grid,
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
<template>
2+
<div
3+
v-if="hasFilmstripFrames"
4+
ref="CdrFilmstripContainer"
5+
v-bind="dataAttributes"
6+
>
7+
<slot name="heading" />
8+
<CdrFilmstripEngine
9+
:class="classAttr"
10+
:id="filmstripId"
11+
:description="description"
12+
:frames="frames"
13+
:frames-gap="framesGap"
14+
:frames-to-show="framesToShow"
15+
:frames-to-scroll="framesToScroll"
16+
:focus-selector="focusSelector"
17+
@ariaMessage="$emit('ariaMessage', $event)"
18+
@arrowClick="onArrowClick"
19+
>
20+
<template #frame="{ ...frameProps }: Record<string, unknown>">
21+
<component
22+
:is="filmstripConfig.component"
23+
v-bind="frameProps"
24+
/>
25+
</template>
26+
</CdrFilmstripEngine>
27+
</div>
28+
</template>
29+
30+
<script setup lang="ts">
31+
import CdrFilmstripEngine from './CdrFilmstripEngine.vue';
32+
import { useResizeObserver, useDebounceFn } from '@vueuse/core';
33+
import type {
34+
CdrFilmstripFrame,
35+
CdrFilmstripArrowClickPayload,
36+
CdrFilmstripResizePayload,
37+
CdrFilmstripEventEmitter,
38+
CdrFilmstripConfig,
39+
CdrFilmstrip,
40+
} from './interfaces';
41+
import { computed, h, provide, ref, useAttrs, useId, watch } from 'vue';
42+
import { CdrFilmstripEventKey } from '../../types/symbols';
43+
44+
/**
45+
* Responsive, accessible filmstrip for displaying a horizontal list of content frames.
46+
* @uses CdrFilmstripEngine
47+
**/
48+
defineOptions({ name: 'CdrFilmstrip' });
49+
50+
/**
51+
* Emits an event with a specified name and optional payload.
52+
* These events are used for communication between the filmstrip component and its consumers.
53+
*
54+
* @event arrowClick - Emitted when a navigation arrow is clicked.
55+
* @event ariaMessage - Emitted to update screen readers with the current frame information.
56+
*/
57+
const emit = defineEmits<{
58+
(e: string, payload?: unknown): void;
59+
}>();
60+
61+
/**
62+
* Provides a centralized event emitter function for the filmstrip and its frames.
63+
* This enables child components to dispatch events upward.
64+
*
65+
* @param {string} eventName - The name of the event to emit.
66+
* @param {unknown} payload - The data payload to emit with the event.
67+
*/
68+
const emitEvent: CdrFilmstripEventEmitter = (eventName, payload) => {
69+
emit(eventName, payload);
70+
};
71+
72+
const attrs = useAttrs();
73+
/**
74+
* Extracts the class attribute from the component's attributes.
75+
* This class is applied to the CdrFilmstripEngine for styling purposes.
76+
*/
77+
const classAttr = attrs.class || '';
78+
79+
/**
80+
* Provides the event emitter function to child components via dependency injection.
81+
* This allows descendant components to trigger events on the filmstrip component.
82+
*/
83+
provide(CdrFilmstripEventKey, emitEvent);
84+
85+
/**
86+
* Defines the props for the CdrFilmstrip component.
87+
*
88+
* @prop {unknown} model - The data model representing the filmstrip content.
89+
* @prop {Function} adapter - A function that transforms the model into a filmstrip configuration.
90+
*
91+
* @default
92+
* model: {}
93+
* adapter: A function that logs a warning and returns a default filmstrip configuration.
94+
*/
95+
const props = withDefaults(defineProps<CdrFilmstrip<unknown>>(), {
96+
model: () => ({}),
97+
adapter: () => {
98+
console.warn(`No adapter provided for CdrFilmstrip`);
99+
const filmstripConfig: CdrFilmstripConfig<unknown> = {
100+
frames: [],
101+
filmstripId: 'empty-filmstrip',
102+
component: h('div'),
103+
description: 'An empty filmstrip',
104+
};
105+
return filmstripConfig;
106+
},
107+
});
108+
109+
// Reference to the filmstrip container element.
110+
const CdrFilmstripContainer = ref<HTMLElement | null>(null);
111+
const FRAMES_TO_SHOW_DEFAULT = 6;
112+
113+
/**
114+
* Resolves and transforms the filmstrip model.
115+
* The adapter function is applied to the model to obtain a consistent filmstrip configuration.
116+
*
117+
* @returns {CdrFilmstripConfig<unknown>} The transformed filmstrip configuration.
118+
*/
119+
const filmstripConfig = computed<CdrFilmstripConfig<unknown>>(() => props.adapter(props.model));
120+
121+
/**
122+
* Number of frames to display at a time.
123+
* Defaults to FRAMES_TO_SHOW_DEFAULT if the adapter does not specify a value.
124+
*
125+
* @default FRAMES_TO_SHOW_DEFAULT
126+
*/
127+
const framesToShow = ref<number>(filmstripConfig?.value?.framesToShow ?? FRAMES_TO_SHOW_DEFAULT);
128+
129+
/**
130+
* Number of frames to scroll at a time.
131+
* Typically matches the number of frames displayed unless overridden.
132+
*/
133+
const framesToScroll = ref<number>(framesToShow.value);
134+
135+
/**
136+
* Extracts frames from the resolved filmstrip model.
137+
*
138+
* @returns {CdrFilmstripFrame<never>[]} An array of frames to be rendered by the filmstrip engine.
139+
*/
140+
const frames = computed(() => filmstripConfig.value.frames as CdrFilmstripFrame<never>[]);
141+
142+
/**
143+
* Checks if the filmstrip has any frames to display.
144+
*
145+
* @returns {boolean} True if there is at least one frame, false otherwise.
146+
*/
147+
const hasFilmstripFrames = computed(() => frames.value.length > 0);
148+
149+
/**
150+
* Retrieves filmstrip metadata and generates a unique filmstrip ID.
151+
* The unique ID is used for accessibility and to prevent DOM conflicts.
152+
*
153+
* @returns {string} A unique identifier for the filmstrip.
154+
*/
155+
const filmstripId = computed(() => `${filmstripConfig.value.filmstripId}-${useId()}`);
156+
157+
/**
158+
* Retrieves the description for the filmstrip.
159+
* This description is used to provide context for screen readers.
160+
*
161+
* @returns {string} The filmstrip's description.
162+
*/
163+
const description = computed(() => filmstripConfig.value.description);
164+
165+
/**
166+
* Retrieves the gap between frames as defined in the filmstrip configuration.
167+
*
168+
* @returns {number} The gap (in pixels) between individual frames.
169+
*/
170+
const framesGap = computed(() => filmstripConfig?.value?.framesGap || 0);
171+
172+
/**
173+
* Determines if the filmstrip should use the default resize strategy.
174+
* This flag controls whether the component automatically adjusts the number
175+
* of frames displayed based on the window size.
176+
*
177+
* @returns {boolean} True if the default resize strategy is enabled, false otherwise.
178+
*/
179+
const useDefaultResizeStrategy = ref<boolean>(
180+
typeof filmstripConfig?.value?.useDefaultResizeStrategy === 'boolean'
181+
? filmstripConfig.value.useDefaultResizeStrategy
182+
: false,
183+
);
184+
185+
/**
186+
* Retrieves the focus selector for the filmstrip.
187+
* This selector determines which element within a frame should receive focus for accessibility.
188+
*
189+
* @returns {string} The CSS selector for the focusable element.
190+
*/
191+
const focusSelector = computed(() => filmstripConfig?.value?.focusSelector || ':first-child');
192+
193+
/**
194+
* Retrieves additional data attributes for the filmstrip container.
195+
*
196+
* @returns {Record<string, unknown>} An object containing data attributes to be applied to the container.
197+
*/
198+
const dataAttributes = computed(() => filmstripConfig.value?.dataAttributes || {});
199+
200+
/**
201+
* Handles arrow click events for navigating through the filmstrip.
202+
* Constructs an arrow click payload and emits the 'arrowClick' event.
203+
*
204+
* @param {CdrFilmstripArrowClickPayload} param0 - The arrow click event payload.
205+
*/
206+
function onArrowClick({ event, direction }: CdrFilmstripArrowClickPayload) {
207+
const arrowClickPayload: CdrFilmstripArrowClickPayload = {
208+
event,
209+
direction,
210+
model: props.model as Record<string, unknown>,
211+
};
212+
emit('arrowClick', arrowClickPayload);
213+
}
214+
215+
/**
216+
* Updates the number of frames displayed based on the current screen width.
217+
* This function implements a default resize strategy in the absence of a custom strategy.
218+
*/
219+
function defaultResizeStrategy() {
220+
const screenWidth = window.innerWidth;
221+
framesToShow.value = screenWidth >= 1024 ? 5 : screenWidth >= 768 ? 4 : 2;
222+
framesToScroll.value = Math.max(framesToShow.value - 1, 1);
223+
}
224+
225+
/**
226+
* Handles window resize events and updates the filmstrip layout accordingly.
227+
* The resize handling is debounced to improve performance.
228+
*/
229+
const onResize = useDebounceFn(() => {
230+
const resizePayload: CdrFilmstripResizePayload = {
231+
model: props.model as Record<string, unknown>,
232+
framesToShow: framesToShow,
233+
framesToScroll: framesToScroll,
234+
};
235+
emit('resize', resizePayload);
236+
if (useDefaultResizeStrategy.value) {
237+
defaultResizeStrategy();
238+
}
239+
}, 25);
240+
241+
/**
242+
* Sets up a resize observer on the filmstrip container element.
243+
* This ensures that the filmstrip layout updates automatically when the container size changes.
244+
*/
245+
watch(
246+
() => CdrFilmstripContainer.value,
247+
(el) => {
248+
if (el) {
249+
useResizeObserver(el, onResize);
250+
}
251+
},
252+
{ immediate: true },
253+
);
254+
</script>

0 commit comments

Comments
 (0)