Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rei/cedar",
"version": "15.6.1-beta.2",
"version": "15.6.1-beta.3",
"description": "REI Cedar Component Library",
"homepage": "https://rei.github.io/rei-cedar/",
"license": "MIT",
Expand Down
115 changes: 81 additions & 34 deletions src/components/filmstrip/CdrFilmstrip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ref="CdrFilmstripContainer"
v-bind="dataAttributes"
>
<!-- @slot Optional injection of a heading element for the filmstrip -->
<slot name="heading" />
<CdrFilmstripEngine
:class="classAttr"
Expand All @@ -14,8 +15,9 @@
:frames-to-show="framesToShow"
:frames-to-scroll="framesToScroll"
:focus-selector="focusSelector"
@ariaMessage="$emit('ariaMessage', $event)"
@arrowClick="onArrowClick"
@aria-message="$emit('ariaMessage', $event)"
@arrow-click="onArrowClick"
@scroll-navigate="onScrollNavigate"
>
<template #frame="{ ...frameProps }: Record<string, unknown>">
<component
Expand All @@ -37,24 +39,78 @@
CdrFilmstripEventEmitter,
CdrFilmstripConfig,
CdrFilmstrip,
CdrFilmstripScrollPayload,
CdrFilmstripAdapter,
} from './interfaces';
import { computed, h, provide, ref, useAttrs, useId, watch } from 'vue';
import { CdrFilmstripEventKey } from '../../types/symbols';

/**
* Responsive, accessible filmstrip for displaying a horizontal list of content frames.
*
* @uses CdrFilmstripEngine
**/
*/
defineOptions({ name: 'CdrFilmstrip' });

/**
* Emits an event with a specified name and optional payload.
* These events are used for communication between the filmstrip component and its consumers.
*
* @event arrowClick - Emitted when a navigation arrow is clicked.
* @event ariaMessage - Emitted to update screen readers with the current frame information.
*/
const props = withDefaults(defineProps<CdrFilmstrip<unknown>>(), {
/**
* Default model provided to the filmstrip.
* Returns an empty object when no model is passed.
* @returns {Record<string, unknown>}
* @default {}
*/
model: (): Record<string, unknown> => ({}),

/**
* Default adapter used by the filmstrip when no custom adapter is provided.
* Returns an empty filmstrip configuration using a generic wrapper element.
*
* @param {Record<string, unknown>} modelData - The raw model data passed to the adapter.
* @returns {CdrFilmstripConfig<Record<string, unknown>>} A valid empty configuration for the filmstrip.
* @default defaultAdapter
*/
adapter: (): CdrFilmstripAdapter<Record<string, unknown>> => {
return (_modelData: unknown): CdrFilmstripConfig<Record<string, unknown>> => {

Check warning on line 73 in src/components/filmstrip/CdrFilmstrip.vue

View workflow job for this annotation

GitHub Actions / build

'_modelData' is defined but never used
console.warn(`No adapter provided for CdrFilmstrip`);
return {
frames: [],
filmstripId: 'empty-filmstrip',
component: h('div'),
description: 'An empty filmstrip',
};
};
},
});

const emit = defineEmits<{
/**
* Emitted when a user clicks the navigation arrows.
* @param payload - The arrow click event metadata.
*/
(e: 'arrowClick', payload: CdrFilmstripArrowClickPayload): void;

/**
* Emitted when the filmstrip scrolls to a new frame.
* @param payload - The scroll event metadata including target index.
*/
(e: 'scrollNavigate', payload: CdrFilmstripScrollPayload): void;

/**
* Emitted when the layout changes due to screen or container resize.
* @param payload - The resize metadata including updated frame counts.
*/
(e: 'resize', payload: CdrFilmstripResizePayload): void;

/**
* Emitted to update screen readers with the current frame information.
* @param payload - A string message intended for screen readers.
*/
(e: 'ariaMessage', payload: string): void;

/**
* Emitted when a custom event is triggered.
* @param payload - The optional payload for a custom event.
*/
(e: string, payload?: unknown): void;
}>();

Expand Down Expand Up @@ -82,30 +138,6 @@
*/
provide(CdrFilmstripEventKey, emitEvent);

/**
* Defines the props for the CdrFilmstrip component.
*
* @prop {unknown} model - The data model representing the filmstrip content.
* @prop {Function} adapter - A function that transforms the model into a filmstrip configuration.
*
* @default
* model: {}
* adapter: A function that logs a warning and returns a default filmstrip configuration.
*/
const props = withDefaults(defineProps<CdrFilmstrip<unknown>>(), {
model: () => ({}),
adapter: () => {
console.warn(`No adapter provided for CdrFilmstrip`);
const filmstripConfig: CdrFilmstripConfig<unknown> = {
frames: [],
filmstripId: 'empty-filmstrip',
component: h('div'),
description: 'An empty filmstrip',
};
return filmstripConfig;
},
});

// Reference to the filmstrip container element.
const CdrFilmstripContainer = ref<HTMLElement | null>(null);
const FRAMES_TO_SHOW_DEFAULT = 6;
Expand Down Expand Up @@ -212,6 +244,21 @@
emit('arrowClick', arrowClickPayload);
}

/**
* Handles scroll navigation events in the filmstrip.
* Constructs a scroll payload and emits the 'scrollNavigate' event.
*
* @param {CdrFilmstripScrollPayload} param0 - The scroll event payload.
*/
function onScrollNavigate({ index, event }: CdrFilmstripScrollPayload): void {
const scrollPayload: CdrFilmstripScrollPayload = {
event,
index,
model: props.model as Record<string, unknown>,
};
emit('scrollNavigate', scrollPayload);
}

/**
* Updates the number of frames displayed based on the current screen width.
* This function implements a default resize strategy in the absence of a custom strategy.
Expand Down
49 changes: 44 additions & 5 deletions src/components/filmstrip/CdrFilmstripEngine.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
mapClasses(classObj, `${BASE_CLASS}__frame`),
classAttr ? `${classAttr}__frame` : null,
]"
@keydown.right="(e) => onShiftFocus(e, 'right')"
@keydown.left="(e) => onShiftFocus(e, 'left')"
>
<slot
name="frame"
Expand Down Expand Up @@ -101,9 +103,10 @@ import CdrButton from '../button/CdrButton.vue';
import { IconCaretLeft, IconCaretRight } from '../icon';

import type {
CdrFilmstripArrowClickPayload,
CdrFilmstripEngine,
CdrFilmstripArrow,
CdrFilmstripArrowClickPayload,
CdrFilmstripScrollPayload,
} from './interfaces';
import { computed, onMounted, onUnmounted, ref, useAttrs, useCssModule } from 'vue';

Expand Down Expand Up @@ -142,6 +145,7 @@ const classAttr = attrs.class || '';

const emit = defineEmits<{
(e: 'arrowClick', payload: CdrFilmstripArrowClickPayload): void;
(e: 'scrollNavigate', payload: CdrFilmstripScrollPayload): void;
(e: 'ariaMessage', message: string): void;
}>();

Expand All @@ -166,12 +170,12 @@ const currentIndex = ref(0);
// Currently focused frame index for keyboard navigation
const focusIndex = ref(0);

// Tracks if the filmstrip has been scrolled at least once
const hasScrolled = ref(false);

// Reactive state to determine if the container is hovered
const isContainerHovered = useElementHover(containerRef);

// Flag to track if scroll is programmatic to avoid emitting events unnecessarily
const isProgrammaticScroll = ref(false);

/**
* Calculates the width of each frame based on the container's width,
* the gap between frames, and any extra width defined.
Expand Down Expand Up @@ -279,6 +283,7 @@ const onArrowClick = (event: Event, direction: 'left' | 'right'): void => {
const proposedIndex = currentIndex.value + delta;

currentIndex.value = Math.max(0, Math.min(proposedIndex, props.frames.length - 1));
isProgrammaticScroll.value = true;
scrollToIndex(currentIndex.value);
};

Expand Down Expand Up @@ -335,6 +340,34 @@ const handleFocusIn = (e: FocusEvent): void => {
}
};

/**
* Handles left and right arrow key presses on the filmstrip container
* to focus on the previous or next frame. This function is used
* to implement keyboard navigation for the filmstrip.
* @param {Event} e - The keyboard event object.
* @param {string} direction - The direction of the arrow key press ('left' or 'right').
*/
function onShiftFocus(e: Event, direction: string): void {
e.preventDefault();

isProgrammaticScroll.value = true;

if (direction === 'left') {
focusIndex.value = focusIndex.value <= 0 ? props.frames.length - 1 : focusIndex.value - 1;
} else {
focusIndex.value = focusIndex.value >= props.frames.length - 1 ? 0 : focusIndex.value + 1;
}

if (framesItemsRef.value) {
const liEl = framesItemsRef.value[focusIndex.value];
const focusEl = liEl.querySelector(props.focusSelector) as HTMLElement;

if (focusEl) {
focusEl.focus();
}
}
}

/**
* Debounced scroll handler that updates the current frame index based on the viewport's scroll position.
* It determines the nearest frame index to the current scroll position, updates the internal state,
Expand All @@ -356,7 +389,13 @@ const debouncedHandleScroll = useDebounceFn((e: Event): void => {
announceFrames();
}

if (!hasScrolled.value) hasScrolled.value = true;
if (!isProgrammaticScroll.value) {
emit('scrollNavigate', {
event: e,
index: currentIndex.value,
});
}
isProgrammaticScroll.value = false;
}, 100);

onMounted(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/filmstrip/examples/Lifestyle/Example.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
class="lifestyle-filmstrip"
:model="lifestyleModelData"
:adapter="LifestyleAdapter"
@frameClick="onFrameClick"
@arrowClick="onArrowClick"
@frame-click="onFrameClick"
@arrow-click="onArrowClick"
@resize="onResize"
/>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
class="product-recommendation-filmstrip"
:model="ProductRecommendationModelData"
:adapter="ProductRecommendationAdapter"
@frameClick="onFrameClick"
@arrowClick="onArrowClick"
@ariaMessage="(msg) => console.log(msg)"
@frame-click="onFrameClick"
@arrow-click="onArrowClick"
@aria-message="(msg) => console.log(msg)"
@scroll-navigate="onScrollNavigate"
/>
</template>

Expand All @@ -15,7 +16,7 @@ import type { ProductRecommendation } from './index';
import ProductRecommendationModel from './mock.json';

const ProductRecommendationModelData = ProductRecommendationModel as ProductRecommendation;
import { onFrameClick, onArrowClick } from './handlers';
import { onFrameClick, onArrowClick, onScrollNavigate } from './handlers';
import ProductRecommendationAdapter from './adapter';
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CdrFilmstripArrowClickPayload } from '../../interfaces';
import type { CdrFilmstripArrowClickPayload, CdrFilmstripScrollPayload } from '../../interfaces';

import type { ProductRecommendation, ProductRecommendationFrameClickPayload } from '.';

Expand Down Expand Up @@ -43,3 +43,25 @@ export function onArrowClick(payload: unknown): void {

console.log('onArrowClick', { event, direction, analytics });
}

/**
* Handles scroll navigation events in the filmstrip.
* Extracts relevant analytics data based on the scrolled-to frame.
*
* @param {unknown} payload - The event payload containing scroll details.
*/
export function onScrollNavigate(payload: unknown): void {
const { index, event, model = {} } = payload as CdrFilmstripScrollPayload;
const { placementName, strategy } = model as Partial<ProductRecommendation>;

const format = (str?: string): string | undefined => str?.replace(/_/g, '-');

const analytics = {
rrPlacementName: format(placementName), // Formatted placement name
rrStrategy: format(strategy), // Formatted strategy type
scrollPosition: index, // Index scrolled to
linkName: `rr_${format(placementName)}_${format(strategy)}_scroll-${index}`, // Analytics label
};

console.log('onScrollNavigate', { index, model, event, analytics });
}
Loading