srcdev-nuxt-components 9.6.11 → 9.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/consumer/ids-from-useid.md +2 -1
- package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +24 -1
- package/.claude/skills/component-naming.md +1 -0
- package/.claude/skills/components/banner-video.md +24 -30
- package/.claude/skills/components/content-docs.md +18 -5
- package/.claude/skills/components/display-dialog.md +16 -6
- package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
- package/.claude/skills/components/display-tooltip.md +17 -6
- package/.claude/skills/components/form-field.md +15 -0
- package/.claude/skills/components/form-fieldset.md +18 -2
- package/.claude/skills/components/marquee-scroller.md +51 -14
- package/.claude/skills/components/skip-links.md +16 -0
- package/.claude/skills/composable-tooltips-guide.md +4 -4
- package/.claude/skills/index.md +6 -6
- package/.claude/skills/testing-add-unit-test.md +8 -0
- package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
- package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
- package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
- package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
- package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
- package/.vscode/srcdev-component-form-field.code-snippets +7 -6
- package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
- package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
- package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
- package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
- package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
- package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
- package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
- package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
- package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
- package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
- package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
- package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
- package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
- package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
- package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
- package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
- package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
- package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
- package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
- package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
- package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
- package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
- package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
- package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
- package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
- package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
- package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
- package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
- package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
- package/app/composables/tests/useTooltips.spec.ts +3 -3
- package/app/types/components/index.ts +1 -1
- package/package.json +1 -1
- package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
- package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
- /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
|
@@ -3,42 +3,48 @@
|
|
|
3
3
|
v-if="displayComponent"
|
|
4
4
|
ref="rootEl"
|
|
5
5
|
class="marquee-scroller"
|
|
6
|
-
:class="
|
|
6
|
+
:class="elementClasses"
|
|
7
|
+
:data-reverse="reverse ? '' : undefined"
|
|
8
|
+
:data-paused="isPaused ? '' : undefined"
|
|
9
|
+
:data-reduced-motion="prefersReducedMotion ? '' : undefined"
|
|
7
10
|
role="region"
|
|
8
11
|
:aria-label="ariaLabel"
|
|
9
12
|
:aria-live="isPaused ? 'polite' : 'off'"
|
|
10
13
|
tabindex="0"
|
|
11
14
|
@keydown="handleKeydown"
|
|
12
|
-
@
|
|
13
|
-
@
|
|
15
|
+
@focusin="handleFocusIn"
|
|
16
|
+
@focusout="handleFocusOut"
|
|
14
17
|
>
|
|
15
|
-
<div class="
|
|
18
|
+
<div class="marquee-scroller-description">
|
|
16
19
|
{{ ariaDescription }}
|
|
17
20
|
</div>
|
|
18
21
|
|
|
19
|
-
<
|
|
22
|
+
<InputButton
|
|
20
23
|
v-if="showControls"
|
|
21
|
-
class="control-btn"
|
|
22
|
-
:aria-label="isPaused ? playLabel : pauseLabel"
|
|
23
24
|
type="button"
|
|
25
|
+
variant="inline"
|
|
26
|
+
:button-text="isPaused ? playLabel : pauseLabel"
|
|
27
|
+
style-class-passthrough="marquee-scroller-control"
|
|
24
28
|
@click="togglePause"
|
|
25
29
|
>
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
<template #iconOnly>
|
|
31
|
+
<slot name="toggle-icon" :is-paused="isPaused">
|
|
32
|
+
<Icon :name="isPaused ? playIcon : pauseIcon" aria-hidden="true" />
|
|
33
|
+
</slot>
|
|
34
|
+
</template>
|
|
35
|
+
</InputButton>
|
|
36
|
+
|
|
37
|
+
<div class="marquee-scroller-track" :aria-hidden="isPaused ? undefined : 'true'">
|
|
38
|
+
<div ref="groupEl" class="marquee-scroller-group">
|
|
33
39
|
<template v-for="copy in repeatCount" :key="copy">
|
|
34
|
-
<div v-for="item in marqueeData" :key="`${copy}-${item.id}`" class="item">
|
|
40
|
+
<div v-for="item in marqueeData" :key="`${copy}-${item.id}`" class="marquee-scroller-item">
|
|
35
41
|
<slot :name="item.id"></slot>
|
|
36
42
|
</div>
|
|
37
43
|
</template>
|
|
38
44
|
</div>
|
|
39
|
-
<div class="marquee-group" aria-hidden="true">
|
|
45
|
+
<div class="marquee-scroller-group" aria-hidden="true" inert>
|
|
40
46
|
<template v-for="copy in repeatCount" :key="copy">
|
|
41
|
-
<div v-for="item in marqueeData" :key="`duplicate-${copy}-${item.id}`" class="item">
|
|
47
|
+
<div v-for="item in marqueeData" :key="`duplicate-${copy}-${item.id}`" class="marquee-scroller-item">
|
|
42
48
|
<slot :name="item.id"></slot>
|
|
43
49
|
</div>
|
|
44
50
|
</template>
|
|
@@ -65,10 +71,11 @@ interface Props {
|
|
|
65
71
|
playIcon?: string;
|
|
66
72
|
/** Iconify icon name shown on the control button while playing. Ignored if the toggle-icon slot is used. */
|
|
67
73
|
pauseIcon?: string;
|
|
68
|
-
/** Control button
|
|
74
|
+
/** Control button label while paused — override for localisation. */
|
|
69
75
|
playLabel?: string;
|
|
70
|
-
/** Control button
|
|
76
|
+
/** Control button label while playing — override for localisation. */
|
|
71
77
|
pauseLabel?: string;
|
|
78
|
+
styleClassPassthrough?: string | string[];
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -84,74 +91,83 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
84
91
|
pauseIcon: "mdi:pause",
|
|
85
92
|
playLabel: "Play animation",
|
|
86
93
|
pauseLabel: "Pause animation",
|
|
94
|
+
styleClassPassthrough: () => [],
|
|
87
95
|
});
|
|
88
96
|
|
|
97
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
98
|
+
|
|
99
|
+
const MAX_REPEAT_COUNT = 50;
|
|
100
|
+
|
|
89
101
|
const displayComponent = ref(false);
|
|
90
102
|
const isPaused = ref(false);
|
|
91
|
-
const isFocused = ref(false);
|
|
92
103
|
const prefersReducedMotion = ref(false);
|
|
93
104
|
const rootEl = ref<HTMLElement | null>(null);
|
|
94
105
|
const groupEl = ref<HTMLElement | null>(null);
|
|
95
106
|
const repeatCount = ref(1);
|
|
107
|
+
let pausedByFocus = false;
|
|
96
108
|
let resizeObserver: ResizeObserver | null = null;
|
|
109
|
+
let reducedMotionQuery: MediaQueryList | null = null;
|
|
97
110
|
|
|
98
111
|
const height = computed(() => props.itemConfig.height || "50px");
|
|
99
112
|
const width = computed(() => props.itemConfig.width || "50px");
|
|
100
113
|
const gap = computed(() => props.itemConfig.gap || "16px");
|
|
101
114
|
const itemCount = computed(() => props.marqueeData.length * repeatCount.value);
|
|
102
115
|
|
|
103
|
-
//
|
|
104
|
-
// isn't, the loop briefly shows empty space before snapping back into view. Repeat the
|
|
105
|
-
// data enough times per track group to always exceed the container's width.
|
|
116
|
+
// One copy of marqueeData may not fill a wide container; repeat it until a group covers the width.
|
|
106
117
|
const updateRepeatCount = () => {
|
|
107
118
|
if (!rootEl.value || !groupEl.value || props.marqueeData.length === 0) return;
|
|
108
119
|
const baseGroupWidth = groupEl.value.scrollWidth / repeatCount.value;
|
|
109
120
|
if (baseGroupWidth <= 0) return;
|
|
110
|
-
const needed = Math.max(1, Math.ceil(rootEl.value.offsetWidth / baseGroupWidth));
|
|
121
|
+
const needed = Math.min(MAX_REPEAT_COUNT, Math.max(1, Math.ceil(rootEl.value.offsetWidth / baseGroupWidth)));
|
|
111
122
|
if (needed !== repeatCount.value) repeatCount.value = needed;
|
|
112
123
|
};
|
|
113
124
|
|
|
114
|
-
|
|
125
|
+
const handleReducedMotionChange = (event: MediaQueryListEvent) => {
|
|
126
|
+
prefersReducedMotion.value = event.matches;
|
|
127
|
+
if (event.matches) isPaused.value = true;
|
|
128
|
+
};
|
|
129
|
+
|
|
115
130
|
const checkReducedMotion = () => {
|
|
116
|
-
if (typeof window
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Listen for changes
|
|
121
|
-
mediaQuery.addEventListener("change", (e) => {
|
|
122
|
-
prefersReducedMotion.value = e.matches;
|
|
123
|
-
if (e.matches) {
|
|
124
|
-
isPaused.value = true;
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
|
131
|
+
if (typeof window === "undefined" || !props.respectReducedMotion) return;
|
|
132
|
+
reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
133
|
+
prefersReducedMotion.value = reducedMotionQuery.matches;
|
|
134
|
+
reducedMotionQuery.addEventListener("change", handleReducedMotionChange);
|
|
128
135
|
};
|
|
129
136
|
|
|
130
137
|
const togglePause = () => {
|
|
131
138
|
isPaused.value = !isPaused.value;
|
|
139
|
+
pausedByFocus = false;
|
|
132
140
|
};
|
|
133
141
|
|
|
134
142
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
143
|
+
if (event.target !== event.currentTarget) return;
|
|
135
144
|
if (event.key === " " || event.key === "Spacebar") {
|
|
136
145
|
event.preventDefault();
|
|
137
146
|
togglePause();
|
|
138
147
|
}
|
|
139
148
|
};
|
|
140
149
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
if (
|
|
150
|
+
const handleFocusIn = (event: FocusEvent) => {
|
|
151
|
+
if ((event.target as Element | null)?.closest(".marquee-scroller-control")) return;
|
|
152
|
+
if (!isPaused.value) {
|
|
144
153
|
isPaused.value = true;
|
|
154
|
+
pausedByFocus = true;
|
|
145
155
|
}
|
|
146
156
|
};
|
|
147
157
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
if (!prefersReducedMotion.value)
|
|
151
|
-
|
|
152
|
-
}
|
|
158
|
+
const handleFocusOut = (event: FocusEvent) => {
|
|
159
|
+
if (rootEl.value?.contains(event.relatedTarget as Node | null)) return;
|
|
160
|
+
if (pausedByFocus && !prefersReducedMotion.value) isPaused.value = false;
|
|
161
|
+
pausedByFocus = false;
|
|
153
162
|
};
|
|
154
163
|
|
|
164
|
+
watch(
|
|
165
|
+
() => props.styleClassPassthrough,
|
|
166
|
+
() => {
|
|
167
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
155
171
|
watch(
|
|
156
172
|
() => [props.marqueeData, props.itemConfig],
|
|
157
173
|
() => {
|
|
@@ -165,7 +181,6 @@ onMounted(async () => {
|
|
|
165
181
|
displayComponent.value = true;
|
|
166
182
|
checkReducedMotion();
|
|
167
183
|
|
|
168
|
-
// Auto-pause if user prefers reduced motion
|
|
169
184
|
if (prefersReducedMotion.value) {
|
|
170
185
|
isPaused.value = true;
|
|
171
186
|
}
|
|
@@ -181,6 +196,7 @@ onMounted(async () => {
|
|
|
181
196
|
|
|
182
197
|
onUnmounted(() => {
|
|
183
198
|
resizeObserver?.disconnect();
|
|
199
|
+
reducedMotionQuery?.removeEventListener("change", handleReducedMotionChange);
|
|
184
200
|
});
|
|
185
201
|
</script>
|
|
186
202
|
|
|
@@ -188,6 +204,8 @@ onUnmounted(() => {
|
|
|
188
204
|
@layer components {
|
|
189
205
|
.marquee-scroller {
|
|
190
206
|
--_fade-width: var(--marquee-scroller-fade-width, 10%);
|
|
207
|
+
--_focus-outline: var(--marquee-scroller-focus-outline-width, 2px) solid
|
|
208
|
+
var(--marquee-scroller-focus-outline-colour, var(--theme-ring));
|
|
191
209
|
|
|
192
210
|
width: 100%;
|
|
193
211
|
height: v-bind(height);
|
|
@@ -200,32 +218,12 @@ onUnmounted(() => {
|
|
|
200
218
|
);
|
|
201
219
|
position: relative;
|
|
202
220
|
|
|
203
|
-
/* Focus styles */
|
|
204
221
|
&:focus-visible {
|
|
205
|
-
outline: var(--
|
|
222
|
+
outline: var(--_focus-outline);
|
|
206
223
|
outline-offset: var(--marquee-scroller-focus-outline-offset, 2px);
|
|
207
224
|
}
|
|
208
225
|
|
|
209
|
-
|
|
210
|
-
&.paused .marquee-track {
|
|
211
|
-
animation-play-state: paused;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/* Reduced motion - disable animation completely */
|
|
215
|
-
&.reduced-motion .marquee-track {
|
|
216
|
-
animation: none !important;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
&:hover .marquee-track {
|
|
220
|
-
animation-play-state: paused;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
&:hover .item {
|
|
224
|
-
filter: var(--marquee-scroller-group-hover-filter, grayscale(1));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/* Screen reader only text */
|
|
228
|
-
.sr-only {
|
|
226
|
+
.marquee-scroller-description {
|
|
229
227
|
position: absolute;
|
|
230
228
|
width: 1px;
|
|
231
229
|
height: 1px;
|
|
@@ -237,12 +235,13 @@ onUnmounted(() => {
|
|
|
237
235
|
border: 0;
|
|
238
236
|
}
|
|
239
237
|
|
|
240
|
-
|
|
241
|
-
.control-btn {
|
|
238
|
+
.marquee-scroller-control.input-button {
|
|
242
239
|
position: absolute;
|
|
243
240
|
inset-block-start: var(--marquee-scroller-control-offset, 8px);
|
|
244
241
|
inset-inline-end: var(--marquee-scroller-control-offset, 8px);
|
|
245
242
|
z-index: 10;
|
|
243
|
+
box-sizing: border-box;
|
|
244
|
+
min-height: auto;
|
|
246
245
|
background: var(--marquee-scroller-control-background-colour, rgba(0, 0, 0, 0.7));
|
|
247
246
|
color: var(--marquee-scroller-control-text-colour, white);
|
|
248
247
|
border: none;
|
|
@@ -252,41 +251,54 @@ onUnmounted(() => {
|
|
|
252
251
|
font-size: var(--marquee-scroller-control-font-size, 14px);
|
|
253
252
|
transition: background-color var(--marquee-scroller-control-transition-duration, 0.2s);
|
|
254
253
|
|
|
254
|
+
& .input-button-icon {
|
|
255
|
+
margin: 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
255
258
|
&:hover {
|
|
256
259
|
background-color: var(--marquee-scroller-control-background-colour-hover, rgba(0, 0, 0, 0.9));
|
|
257
260
|
}
|
|
258
261
|
|
|
259
262
|
&:focus-visible {
|
|
260
|
-
outline: var(--
|
|
261
|
-
outline-offset: 2px;
|
|
263
|
+
outline: var(--_focus-outline);
|
|
264
|
+
outline-offset: var(--marquee-scroller-focus-outline-offset, 2px);
|
|
262
265
|
}
|
|
263
266
|
}
|
|
264
267
|
|
|
265
|
-
.marquee-track {
|
|
268
|
+
.marquee-scroller-track {
|
|
266
269
|
--_track-shift: calc(v-bind(itemCount) * (v-bind(width) + v-bind(gap)));
|
|
267
270
|
|
|
268
271
|
display: flex;
|
|
269
272
|
width: fit-content;
|
|
270
273
|
gap: v-bind(gap);
|
|
271
|
-
animation:
|
|
272
|
-
}
|
|
274
|
+
animation: marquee-scroller-move v-bind(animationRuntime) linear infinite;
|
|
273
275
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
.marquee-scroller[data-reverse] & {
|
|
277
|
+
animation-direction: reverse;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.marquee-scroller[data-paused] &,
|
|
281
|
+
.marquee-scroller:hover & {
|
|
282
|
+
animation-play-state: paused;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.marquee-scroller[data-reduced-motion] & {
|
|
286
|
+
animation: none;
|
|
287
|
+
}
|
|
276
288
|
}
|
|
277
289
|
|
|
278
|
-
.marquee-group {
|
|
290
|
+
.marquee-scroller-group {
|
|
279
291
|
display: flex;
|
|
280
292
|
gap: v-bind(gap);
|
|
281
293
|
flex-shrink: 0;
|
|
282
294
|
}
|
|
283
295
|
|
|
284
|
-
.item {
|
|
296
|
+
.marquee-scroller-item {
|
|
285
297
|
width: v-bind(width);
|
|
286
298
|
height: v-bind(height);
|
|
287
299
|
display: grid;
|
|
288
300
|
place-items: center;
|
|
289
|
-
|
|
301
|
+
overflow-wrap: anywhere;
|
|
290
302
|
transition: filter var(--marquee-scroller-item-transition-duration, 0.5s);
|
|
291
303
|
flex-shrink: 0;
|
|
292
304
|
|
|
@@ -294,13 +306,17 @@ onUnmounted(() => {
|
|
|
294
306
|
var(--marquee-scroller-item-border-colour, var(--slate-10));
|
|
295
307
|
border-radius: var(--marquee-scroller-item-border-radius, 4px);
|
|
296
308
|
|
|
309
|
+
.marquee-scroller:hover & {
|
|
310
|
+
filter: var(--marquee-scroller-group-hover-filter, grayscale(1));
|
|
311
|
+
}
|
|
312
|
+
|
|
297
313
|
&:hover {
|
|
298
314
|
filter: var(--marquee-scroller-item-hover-filter, grayscale(0));
|
|
299
315
|
}
|
|
300
316
|
}
|
|
301
317
|
}
|
|
302
318
|
|
|
303
|
-
@keyframes
|
|
319
|
+
@keyframes marquee-scroller-move {
|
|
304
320
|
from {
|
|
305
321
|
transform: translateX(0);
|
|
306
322
|
}
|
|
@@ -310,21 +326,17 @@ onUnmounted(() => {
|
|
|
310
326
|
}
|
|
311
327
|
}
|
|
312
328
|
|
|
313
|
-
/* High contrast mode support */
|
|
314
329
|
@media (prefers-contrast: high) {
|
|
315
|
-
.marquee-scroller {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
border: var(--marquee-scroller-control-border-width, 1px) solid ButtonText;
|
|
320
|
-
}
|
|
330
|
+
.marquee-scroller .marquee-scroller-control.input-button {
|
|
331
|
+
background: ButtonFace;
|
|
332
|
+
color: ButtonText;
|
|
333
|
+
border: var(--marquee-scroller-control-border-width, 1px) solid ButtonText;
|
|
321
334
|
}
|
|
322
335
|
}
|
|
323
336
|
|
|
324
|
-
/* Respect user's motion preferences */
|
|
325
337
|
@media (prefers-reduced-motion: reduce) {
|
|
326
|
-
.marquee-scroller .marquee-track {
|
|
327
|
-
animation: none
|
|
338
|
+
.marquee-scroller .marquee-scroller-track {
|
|
339
|
+
animation: none;
|
|
328
340
|
}
|
|
329
341
|
}
|
|
330
342
|
}
|
package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { computed } from "vue";
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
2
|
import MarqueeScroller from "../MarqueeScroller.vue";
|
|
3
3
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
4
|
-
import
|
|
4
|
+
import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { MarqueeItem, MarqueeItemConfig, MediaCanvas } from "~/types/components";
|
|
5
6
|
|
|
6
7
|
// animationRuntime is a CSS duration string ("30s") on the component — Storybook has no native
|
|
7
8
|
// range control for that, so the story exposes a plain-number `animationRuntimeSeconds` arg and
|
|
@@ -19,6 +20,7 @@ type StoryArgs = {
|
|
|
19
20
|
pauseIcon?: string;
|
|
20
21
|
playLabel?: string;
|
|
21
22
|
pauseLabel?: string;
|
|
23
|
+
styleClassPassthrough?: string[];
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
const logoData: MarqueeItem[] = [
|
|
@@ -113,12 +115,31 @@ const meta: Meta<StoryArgs> = {
|
|
|
113
115
|
description: "Control button aria-label while playing — override for localisation",
|
|
114
116
|
table: { category: "Control button" },
|
|
115
117
|
},
|
|
118
|
+
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
116
119
|
},
|
|
120
|
+
decorators: [
|
|
121
|
+
(story, context) => ({
|
|
122
|
+
components: { story, CanvasSwitcher },
|
|
123
|
+
setup() {
|
|
124
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
125
|
+
return { canvasName };
|
|
126
|
+
},
|
|
127
|
+
template: `
|
|
128
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
129
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
130
|
+
</div>
|
|
131
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
132
|
+
<story />
|
|
133
|
+
</div>
|
|
134
|
+
`,
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
117
137
|
parameters: {
|
|
138
|
+
layout: "fullscreen",
|
|
118
139
|
docs: {
|
|
119
140
|
description: {
|
|
120
141
|
component:
|
|
121
|
-
"An infinite horizontal scroller for logos, badges, or other small repeating items. Items
|
|
142
|
+
"An infinite horizontal scroller for logos, badges, or other small repeating items. Items come from `marqueeData` plus one dynamically named slot per item id; each slot renders in fixed `itemConfig` boxes, and the data repeats until it fills the container. Pauses on hover and whenever focus is inside it (including focusable content in an item), toggles with the spacebar on the region or the optional `show-controls` button, and respects prefers-reduced-motion. All copy (`aria-label`, `aria-description`, `play-label`, `pause-label`) is a prop for localisation.",
|
|
122
143
|
},
|
|
123
144
|
},
|
|
124
145
|
},
|
|
@@ -136,6 +157,9 @@ export const WithControls: Story = {
|
|
|
136
157
|
showControls: true,
|
|
137
158
|
respectReducedMotion: true,
|
|
138
159
|
},
|
|
160
|
+
parameters: {
|
|
161
|
+
docs: { description: { story: "Five placeholder logos with the visible pause/play button. Hover pauses the track and greys every item except the hovered one." } },
|
|
162
|
+
},
|
|
139
163
|
render: (args) => ({
|
|
140
164
|
components: { MarqueeScroller },
|
|
141
165
|
setup() {
|
|
@@ -149,3 +173,108 @@ export const WithControls: Story = {
|
|
|
149
173
|
`,
|
|
150
174
|
}),
|
|
151
175
|
};
|
|
176
|
+
|
|
177
|
+
const linkData: MarqueeItem[] = [
|
|
178
|
+
{ id: 1, content: "Partner one" },
|
|
179
|
+
{ id: 2, content: "Partner two" },
|
|
180
|
+
{ id: 3, content: "Partner three" },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
export const FocusableItems: Story = {
|
|
184
|
+
args: {
|
|
185
|
+
animationRuntimeSeconds: 20,
|
|
186
|
+
marqueeData: linkData,
|
|
187
|
+
itemConfig: { width: "160px", height: "60px", gap: "24px" },
|
|
188
|
+
showControls: true,
|
|
189
|
+
ariaLabel: "Partner links",
|
|
190
|
+
},
|
|
191
|
+
render: (args) => ({
|
|
192
|
+
components: { MarqueeScroller },
|
|
193
|
+
setup() {
|
|
194
|
+
const { componentArgs } = useStorySetup(args as StoryArgs);
|
|
195
|
+
return { componentArgs, linkData };
|
|
196
|
+
},
|
|
197
|
+
template: `
|
|
198
|
+
<MarqueeScroller v-bind="componentArgs">
|
|
199
|
+
<template v-for="item in linkData" :key="item.id" #[item.id]>
|
|
200
|
+
<a href="#" style="font-size: 1.6rem;" @click.prevent>{{ item.content }}</a>
|
|
201
|
+
</template>
|
|
202
|
+
</MarqueeScroller>
|
|
203
|
+
`,
|
|
204
|
+
}),
|
|
205
|
+
parameters: {
|
|
206
|
+
docs: {
|
|
207
|
+
description: {
|
|
208
|
+
story:
|
|
209
|
+
"Links inside items. Tab into the scroller: it pauses as soon as focus is anywhere inside, the track stops being `aria-hidden`, and only the real copy of each link is reachable (the looping duplicate is `inert`). Tabbing out resumes it, unless you paused it yourself with the button or spacebar.",
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const stressData: MarqueeItem[] = Array.from({ length: 24 }, (_, index) => ({ id: index + 1, content: `Item ${index + 1}` }));
|
|
216
|
+
const stressCopy = [
|
|
217
|
+
"Unternehmensberatungsgesellschaftmitbeschränkterhaftung",
|
|
218
|
+
"https://example.com/a/very/long/unbroken/url/that/never/ends",
|
|
219
|
+
"😀 Emoji first",
|
|
220
|
+
"<b>not bold</b>",
|
|
221
|
+
"شركة الشمال",
|
|
222
|
+
"",
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
export const StressTest: Story = {
|
|
226
|
+
name: "Stress Test (Worst-Case Data)",
|
|
227
|
+
args: {
|
|
228
|
+
animationRuntimeSeconds: 8,
|
|
229
|
+
marqueeData: stressData,
|
|
230
|
+
itemConfig: { width: "90px", height: "70px", gap: "8px" },
|
|
231
|
+
showControls: true,
|
|
232
|
+
ariaLabel: "Bevorzugtekontaktmethodefürrückfragen 😀 <b>not bold</b> طريقة الاتصال",
|
|
233
|
+
ariaDescription: "Drücken Sie die Leertaste, um die Animation anzuhalten oder fortzusetzen.",
|
|
234
|
+
playLabel: "Animationfortsetzenundweiterlaufenlassen",
|
|
235
|
+
pauseLabel: "Animationanhaltenundstehenlassen",
|
|
236
|
+
},
|
|
237
|
+
render: (args) => ({
|
|
238
|
+
components: { MarqueeScroller },
|
|
239
|
+
setup() {
|
|
240
|
+
const { componentArgs } = useStorySetup(args as StoryArgs);
|
|
241
|
+
const single: MarqueeItem[] = [{ id: 1, content: "Only one" }];
|
|
242
|
+
const tiny: MarqueeItem[] = [{ id: 1, content: "a" }, { id: 2, content: "b" }];
|
|
243
|
+
return { componentArgs, args, stressCopy, single, tiny };
|
|
244
|
+
},
|
|
245
|
+
template: `
|
|
246
|
+
<div style="display: grid; gap: 2.4rem; font-size: 1.4rem;">
|
|
247
|
+
<p style="margin: 0;">24 items: unbroken German, a long URL, emoji, HTML-like and right-to-left text, an empty item and a broken image.</p>
|
|
248
|
+
<MarqueeScroller v-bind="componentArgs">
|
|
249
|
+
<template v-for="item in args.marqueeData" :key="item.id" #[item.id]>
|
|
250
|
+
<img v-if="item.id === 6" src="https://example.invalid/missing-logo.png" alt="Broken logo" width="60" height="40" />
|
|
251
|
+
<span v-else>{{ stressCopy[(item.id - 1) % stressCopy.length] }}</span>
|
|
252
|
+
</template>
|
|
253
|
+
</MarqueeScroller>
|
|
254
|
+
|
|
255
|
+
<p style="margin: 0;">A single item: repeats until it fills the container.</p>
|
|
256
|
+
<MarqueeScroller :marquee-data="single" :item-config="{ width: '120px', height: '50px', gap: '16px' }">
|
|
257
|
+
<template #1><span>Only one</span></template>
|
|
258
|
+
</MarqueeScroller>
|
|
259
|
+
|
|
260
|
+
<p style="margin: 0;">Tiny 4px items with no gap: the repeat count caps at 50 copies instead of filling the DOM.</p>
|
|
261
|
+
<MarqueeScroller :marquee-data="tiny" :item-config="{ width: '4px', height: '20px', gap: '0px' }">
|
|
262
|
+
<template #1><span></span></template>
|
|
263
|
+
<template #2><span></span></template>
|
|
264
|
+
</MarqueeScroller>
|
|
265
|
+
|
|
266
|
+
<p style="margin: 0;">Empty <code>marqueeData</code>: renders an empty region, no errors.</p>
|
|
267
|
+
<MarqueeScroller :marquee-data="[]"></MarqueeScroller>
|
|
268
|
+
</div>
|
|
269
|
+
`,
|
|
270
|
+
}),
|
|
271
|
+
parameters: {
|
|
272
|
+
initialCanvas: "mobileCanvas",
|
|
273
|
+
docs: {
|
|
274
|
+
description: {
|
|
275
|
+
story:
|
|
276
|
+
"Worst-case data across four scrollers. Check at every canvas width: unbroken text wraps inside its item box rather than spilling into the next one, markup renders as text, the broken image shows its alt text, the long localised labels stay inside the control button (they're its accessible name, visually hidden), the loop has no visible seam, and the empty scroller throws nothing. Item boxes are a fixed size, so slot content taller than `itemConfig.height` still overflows: size the content to the box.",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|