srcdev-nuxt-components 9.4.4 → 9.4.6
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/commands/migrate-component.md +106 -0
- package/.claude/component-ledger/artifact-url.txt +1 -0
- package/.claude/component-ledger/audit.json +1 -0
- package/.claude/component-ledger/build.mjs +111 -0
- package/.claude/component-ledger/output.html +559 -0
- package/.claude/component-ledger/template.html +559 -0
- package/.claude/hooks/refresh-component-ledger.sh +32 -0
- package/.claude/settings.json +10 -0
- package/.claude/skills/components/animated-svg-text.md +78 -0
- package/.claude/skills/components/breadcrumb.md +1 -0
- package/.claude/skills/components/canvas-switcher.md +61 -0
- package/.claude/skills/components/clip-element.md +67 -0
- package/.claude/skills/components/clipped-panel.md +64 -0
- package/.claude/skills/components/content-docs.md +2 -0
- package/.claude/skills/components/deep-expanding-menu-classic.md +94 -0
- package/.claude/skills/components/deep-expanding-menu.md +92 -0
- package/.claude/skills/components/display-banner.md +60 -0
- package/.claude/skills/components/display-tooltip-defined.md +139 -0
- package/.claude/skills/components/display-tooltip.md +85 -0
- package/.claude/skills/components/marquee-scroller.md +130 -0
- package/.claude/skills/components/navigation-items.md +1 -0
- package/.claude/skills/components/responsive-header.md +3 -0
- package/.claude/skills/components/site-header.md +4 -0
- package/.claude/skills/components/site-navigation.md +1 -0
- package/.claude/skills/components/tab-navigation.md +1 -0
- package/.claude/skills/composable-tooltips-guide.md +10 -0
- package/.claude/skills/index.md +8 -1
- package/.claude/skills/storybook-add-story.md +30 -0
- package/.vscode/srcdev-component-animated-svg-text.code-snippets +15 -0
- package/.vscode/srcdev-component-breadcrumb.code-snippets +13 -0
- package/.vscode/srcdev-component-canvas-switcher.code-snippets +9 -0
- package/.vscode/srcdev-component-clip-element.code-snippets +20 -0
- package/.vscode/srcdev-component-clipped-panel.code-snippets +23 -0
- package/.vscode/srcdev-component-content-docs.code-snippets +18 -0
- package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +9 -0
- package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +9 -0
- package/.vscode/srcdev-component-display-banner.code-snippets +30 -0
- package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +29 -0
- package/.vscode/srcdev-component-display-tooltip.code-snippets +38 -0
- package/.vscode/srcdev-component-marquee-scroller.code-snippets +93 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +12 -0
- package/.vscode/srcdev-component-site-header.code-snippets +17 -0
- package/.vscode/srcdev-component-site-navigation.code-snippets +30 -0
- package/.vscode/srcdev-component-slider-gallery.code-snippets +38 -0
- package/.vscode/srcdev-component-tab-navigation.code-snippets +30 -0
- package/app/components/01.atoms/animations/animated-svg-text/AnimatedSvgText.vue +58 -0
- package/app/components/01.atoms/animations/animated-svg-text/CONSUMER-STYLING.md +34 -0
- package/app/components/01.atoms/animations/animated-svg-text/stories/AnimatedSvgText.stories.ts +69 -0
- package/app/components/01.atoms/animations/animated-svg-text/tests/AnimatedSvgText.spec.ts +63 -0
- package/app/components/01.atoms/animations/animated-svg-text/tests/__snapshots__/AnimatedSvgText.spec.ts.snap +7 -0
- package/app/components/01.atoms/animations/clip-element/CONSUMER-STYLING.md +27 -0
- package/app/components/{clip-element → 01.atoms/animations/clip-element}/ClipElement.vue +16 -11
- package/app/components/01.atoms/animations/clip-element/stories/ClipElement.stories.ts +133 -0
- package/app/components/01.atoms/animations/clip-element/tests/ClipElement.spec.ts +70 -0
- package/app/components/01.atoms/animations/clip-element/tests/__snapshots__/ClipElement.spec.ts.snap +7 -0
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +94 -0
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +331 -0
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +151 -0
- package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +315 -0
- package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +28 -0
- package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +27 -0
- package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +111 -0
- package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +75 -0
- package/app/components/01.atoms/canvas-switcher/tests/CanvasSwitcher.spec.ts +67 -0
- package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +39 -0
- package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +68 -0
- package/app/components/01.atoms/clipped-panel/stories/ClippedPanel.stories.ts +105 -0
- package/app/components/01.atoms/clipped-panel/tests/ClippedPanel.spec.ts +67 -0
- package/app/components/01.atoms/clipped-panel/tests/__snapshots__/ClippedPanel.spec.ts.snap +3 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +8 -2
- package/app/components/01.atoms/display-banner/CONSUMER-STYLING.md +35 -0
- package/app/components/{display-banner → 01.atoms/display-banner}/DisplayBanner.vue +13 -35
- package/app/components/01.atoms/display-banner/stories/DisplayBanner.stories.ts +100 -0
- package/app/components/01.atoms/display-banner/tests/DisplayBanner.spec.ts +78 -0
- package/app/components/01.atoms/display-banner/tests/__snapshots__/DisplayBanner.spec.ts.snap +8 -0
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +62 -0
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +188 -0
- package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +94 -0
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +75 -0
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +12 -0
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +4 -1
- package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +42 -0
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +116 -0
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +126 -0
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +66 -0
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +17 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +44 -0
- package/app/components/{deep-expanding-menu → 02.molecules/navigation/deep-expanding-menu}/DeepExpandingMenu.vue +53 -64
- package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +79 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +92 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +17 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +40 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +200 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +79 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +82 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +18 -0
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +4 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +4 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +16 -4
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +4 -1
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +16 -3
- package/app/components/03.organisms/site-header/SiteHeader.vue +16 -1
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
- package/app/types/components/display-tooltip-defined.d.ts +10 -0
- package/app/types/components/index.ts +2 -0
- package/app/types/components/marquee-scroller.d.ts +10 -0
- package/package.json +1 -1
- package/app/components/animated-svg-text/AnimatedSvgText.vue +0 -89
- package/app/components/canvas-switcher/CanvasSwitcher.vue +0 -104
- package/app/components/clipped-panels/ClippedPanel.vue +0 -87
- package/app/components/deep-expanding-menu/DeepExpandingMenuOld.vue +0 -218
- package/app/components/display-details/DisplayDetailsCore.vue +0 -122
- package/app/components/display-tooltip/DisplayTooltip.vue +0 -170
- package/app/components/display-tooltip/DisplayTooltipDefined.vue +0 -103
- package/app/components/magnetic-navigation/MagneticNavigation.vue +0 -162
- package/app/components/marquee-scroller/MarqueeScroller.vue +0 -289
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import ClipElement from "../ClipElement.vue";
|
|
4
|
+
|
|
5
|
+
function setTopAndScroll(wrapper: Awaited<ReturnType<typeof mountSuspended>>, top: number) {
|
|
6
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
7
|
+
clipElement.getBoundingClientRect = () => ({ top } as DOMRect);
|
|
8
|
+
window.dispatchEvent(new Event("scroll"));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("ClipElement", () => {
|
|
12
|
+
it("mounts without error", async () => {
|
|
13
|
+
const wrapper = await mountSuspended(ClipElement);
|
|
14
|
+
expect(wrapper.vm).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders correct HTML structure (default props)", async () => {
|
|
18
|
+
const wrapper = await mountSuspended(ClipElement);
|
|
19
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders the wrapper and clipped-element structure", async () => {
|
|
23
|
+
const wrapper = await mountSuspended(ClipElement);
|
|
24
|
+
expect(wrapper.find(".clip-element-wrapper").exists()).toBe(true);
|
|
25
|
+
expect(wrapper.find(".clipped-element").exists()).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders slot content inside .clipped-element", async () => {
|
|
29
|
+
const wrapper = await mountSuspended(ClipElement, {
|
|
30
|
+
slots: { default: "<p class='slot-child'>Hello</p>" },
|
|
31
|
+
});
|
|
32
|
+
const content = wrapper.find(".clipped-element");
|
|
33
|
+
expect(content.find(".slot-child").exists()).toBe(true);
|
|
34
|
+
expect(content.find(".slot-child").text()).toBe("Hello");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("applies styleClassPassthrough classes to the wrapper", async () => {
|
|
38
|
+
const wrapper = await mountSuspended(ClipElement, {
|
|
39
|
+
props: { styleClassPassthrough: ["custom-class"] },
|
|
40
|
+
});
|
|
41
|
+
expect(wrapper.find(".clip-element-wrapper").classes()).toContain("custom-class");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("clips fully (0px inset) once the element has scrolled past maxClip", async () => {
|
|
45
|
+
const wrapper = await mountSuspended(ClipElement, { props: { maxClip: 100 } });
|
|
46
|
+
setTopAndScroll(wrapper, 150);
|
|
47
|
+
await wrapper.vm.$nextTick();
|
|
48
|
+
|
|
49
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
50
|
+
expect(clipElement.style.getPropertyValue("--_clip-path")).toBe("inset(0px 0 0 0)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("increases the clip offset as the element approaches the top of the viewport", async () => {
|
|
54
|
+
const wrapper = await mountSuspended(ClipElement, { props: { maxClip: 100 } });
|
|
55
|
+
setTopAndScroll(wrapper, 40);
|
|
56
|
+
await wrapper.vm.$nextTick();
|
|
57
|
+
|
|
58
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
59
|
+
expect(clipElement.style.getPropertyValue("--_clip-path")).toBe("inset(60px 0 0 0)");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps clipping past 0px once the element has scrolled above the viewport", async () => {
|
|
63
|
+
const wrapper = await mountSuspended(ClipElement, { props: { maxClip: 100 } });
|
|
64
|
+
setTopAndScroll(wrapper, -20);
|
|
65
|
+
await wrapper.vm.$nextTick();
|
|
66
|
+
|
|
67
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
68
|
+
expect(clipElement.style.getPropertyValue("--_clip-path")).toBe("inset(120px 0 0 0)");
|
|
69
|
+
});
|
|
70
|
+
});
|
package/app/components/01.atoms/animations/clip-element/tests/__snapshots__/ClipElement.spec.ts.snap
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ClipElement > renders correct HTML structure (default props) 1`] = `
|
|
4
|
+
"<div class="clip-element-wrapper">
|
|
5
|
+
<div class="clipped-element" style="--_clip-path: inset(0px 0 0 0);"></div>
|
|
6
|
+
</div>"
|
|
7
|
+
`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# MarqueeScroller — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
| Token | Default | Controls |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `--marquee-scroller-fade-width` | `10%` | Width of the transparent fade at each edge of the track (`mask-image`) |
|
|
8
|
+
| `--marquee-scroller-focus-outline-width` | `2px` | Outline width on the root region and the control button when keyboard-focused (colour is `--theme-ring`) |
|
|
9
|
+
| `--marquee-scroller-focus-outline-offset` | `2px` | Outline offset on the same two focus states |
|
|
10
|
+
| `--marquee-scroller-group-hover-filter` | `grayscale(1)` | Filter applied to every item when the pointer hovers anywhere over the scroller |
|
|
11
|
+
| `--marquee-scroller-item-hover-filter` | `grayscale(0)` | Filter applied to a single item when it is individually hovered (overrides the group filter for that item) |
|
|
12
|
+
| `--marquee-scroller-item-transition-duration` | `0.5s` | Transition duration for the item hover filter change |
|
|
13
|
+
| `--marquee-scroller-item-border-width` | `1px` | Item border width |
|
|
14
|
+
| `--marquee-scroller-item-border-colour` | `light-dark(var(--slate-10), var(--slate-00))` | Item border colour |
|
|
15
|
+
| `--marquee-scroller-item-border-radius` | `4px` | Item border radius |
|
|
16
|
+
| `--marquee-scroller-control-offset` | `8px` | Distance of the pause/play button from the top and end edges |
|
|
17
|
+
| `--marquee-scroller-control-background-colour` | `rgba(0, 0, 0, 0.7)` | Pause/play button background |
|
|
18
|
+
| `--marquee-scroller-control-background-colour-hover` | `rgba(0, 0, 0, 0.9)` | Pause/play button background on hover |
|
|
19
|
+
| `--marquee-scroller-control-text-colour` | `white` | Pause/play button icon colour |
|
|
20
|
+
| `--marquee-scroller-control-border-radius` | `4px` | Pause/play button border radius |
|
|
21
|
+
| `--marquee-scroller-control-padding` | `8px` | Pause/play button padding |
|
|
22
|
+
| `--marquee-scroller-control-font-size` | `14px` | Pause/play button icon font size |
|
|
23
|
+
| `--marquee-scroller-control-transition-duration` | `0.2s` | Pause/play button background-colour transition duration |
|
|
24
|
+
| `--marquee-scroller-control-border-width` | `1px` | Pause/play button border width in forced-colors/high-contrast mode (`prefers-contrast: high`). The border colour itself stays the system `ButtonText` keyword — see note below. |
|
|
25
|
+
|
|
26
|
+
```css
|
|
27
|
+
.my-page {
|
|
28
|
+
--marquee-scroller-item-border-colour: #b5651d;
|
|
29
|
+
--marquee-scroller-control-background-colour: #1a1a1a;
|
|
30
|
+
--marquee-scroller-fade-width: 20%;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## High-contrast mode colours are not tokenised
|
|
35
|
+
|
|
36
|
+
Under `prefers-contrast: high`, the control button switches to the system colour keywords
|
|
37
|
+
`ButtonFace`/`ButtonText` rather than the component's own background/text tokens. These aren't
|
|
38
|
+
exposed as overridable tokens on purpose — they're meant to track the user's OS-level high-contrast
|
|
39
|
+
theme, which a component-level override would defeat. Only the border *width* in that mode is a
|
|
40
|
+
token (`--marquee-scroller-control-border-width`), since it's a plain dimension with no such
|
|
41
|
+
constraint.
|
|
42
|
+
|
|
43
|
+
## Item size and spacing are prop-driven, not tokens
|
|
44
|
+
|
|
45
|
+
Item `width`/`height`/`gap` and the animation duration are set via the `itemConfig` and
|
|
46
|
+
`animationRuntime` props (not CSS custom properties), since they also drive the component's own
|
|
47
|
+
layout math (`aspect-ratio`, track width). Override those through props:
|
|
48
|
+
|
|
49
|
+
```vue
|
|
50
|
+
<MarqueeScroller
|
|
51
|
+
:item-config="{ width: '80px', height: '80px', gap: '24px' }"
|
|
52
|
+
animation-runtime="60s"
|
|
53
|
+
/>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Item content via slots
|
|
57
|
+
|
|
58
|
+
Each entry in `marqueeData` (`{ id, content }`) renders a slot named after its `id`. Provide the
|
|
59
|
+
markup for each item via that dynamically-named slot:
|
|
60
|
+
|
|
61
|
+
```vue
|
|
62
|
+
<MarqueeScroller :marquee-data="[{ id: 1, content: 'logo-a' }, { id: 2, content: 'logo-b' }]">
|
|
63
|
+
<template #1>
|
|
64
|
+
<img src="/logos/a.svg" alt="Logo A" />
|
|
65
|
+
</template>
|
|
66
|
+
<template #2>
|
|
67
|
+
<img src="/logos/b.svg" alt="Logo B" />
|
|
68
|
+
</template>
|
|
69
|
+
</MarqueeScroller>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Control button icon and copy
|
|
73
|
+
|
|
74
|
+
The pause/play button's icon and its `aria-label` copy are all overridable — see `playIcon`/
|
|
75
|
+
`pauseIcon`/`playLabel`/`pauseLabel` props, or the `toggle-icon` slot for a fully custom icon
|
|
76
|
+
(scoped with `isPaused`):
|
|
77
|
+
|
|
78
|
+
```vue
|
|
79
|
+
<MarqueeScroller
|
|
80
|
+
show-controls
|
|
81
|
+
play-icon="mdi:play-circle"
|
|
82
|
+
pause-icon="mdi:pause-circle"
|
|
83
|
+
play-label="Reproduire"
|
|
84
|
+
pause-label="Suspendre"
|
|
85
|
+
/>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```vue
|
|
89
|
+
<MarqueeScroller show-controls>
|
|
90
|
+
<template #toggle-icon="{ isPaused }">
|
|
91
|
+
<Icon :name="isPaused ? 'lucide:play' : 'lucide:pause'" />
|
|
92
|
+
</template>
|
|
93
|
+
</MarqueeScroller>
|
|
94
|
+
```
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="displayComponent"
|
|
4
|
+
ref="rootEl"
|
|
5
|
+
class="marquee-scroller"
|
|
6
|
+
:class="{ reverse: reverse, paused: isPaused, 'reduced-motion': prefersReducedMotion }"
|
|
7
|
+
role="region"
|
|
8
|
+
:aria-label="ariaLabel"
|
|
9
|
+
:aria-live="isPaused ? 'polite' : 'off'"
|
|
10
|
+
tabindex="0"
|
|
11
|
+
@keydown="handleKeydown"
|
|
12
|
+
@focus="handleFocus"
|
|
13
|
+
@blur="handleBlur"
|
|
14
|
+
>
|
|
15
|
+
<div class="sr-only">
|
|
16
|
+
{{ ariaDescription }}
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<button
|
|
20
|
+
v-if="showControls"
|
|
21
|
+
class="control-btn"
|
|
22
|
+
:aria-label="isPaused ? playLabel : pauseLabel"
|
|
23
|
+
type="button"
|
|
24
|
+
@click="togglePause"
|
|
25
|
+
>
|
|
26
|
+
<slot name="toggle-icon" :is-paused="isPaused">
|
|
27
|
+
<Icon :name="isPaused ? playIcon : pauseIcon" aria-hidden="true" />
|
|
28
|
+
</slot>
|
|
29
|
+
</button>
|
|
30
|
+
|
|
31
|
+
<div class="marquee-track" :aria-hidden="!isPaused">
|
|
32
|
+
<div ref="groupEl" class="marquee-group">
|
|
33
|
+
<template v-for="copy in repeatCount" :key="copy">
|
|
34
|
+
<div v-for="item in marqueeData" :key="`${copy}-${item.id}`" class="item">
|
|
35
|
+
<slot :name="item.id"></slot>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="marquee-group" aria-hidden="true">
|
|
40
|
+
<template v-for="copy in repeatCount" :key="copy">
|
|
41
|
+
<div v-for="item in marqueeData" :key="`duplicate-${copy}-${item.id}`" class="item">
|
|
42
|
+
<slot :name="item.id"></slot>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<script setup lang="ts">
|
|
51
|
+
import type { MarqueeItem, MarqueeItemConfig } from "~/types/components";
|
|
52
|
+
|
|
53
|
+
interface Props {
|
|
54
|
+
animationRuntime?: string;
|
|
55
|
+
reverse?: boolean;
|
|
56
|
+
marqueeData?: MarqueeItem[];
|
|
57
|
+
itemConfig?: MarqueeItemConfig;
|
|
58
|
+
/** aria-label on the root region — override for localisation. */
|
|
59
|
+
ariaLabel?: string;
|
|
60
|
+
/** Screen-reader-only instructions rendered inside the region — override for localisation. */
|
|
61
|
+
ariaDescription?: string;
|
|
62
|
+
showControls?: boolean;
|
|
63
|
+
respectReducedMotion?: boolean;
|
|
64
|
+
/** Iconify icon name shown on the control button while paused. Ignored if the toggle-icon slot is used. */
|
|
65
|
+
playIcon?: string;
|
|
66
|
+
/** Iconify icon name shown on the control button while playing. Ignored if the toggle-icon slot is used. */
|
|
67
|
+
pauseIcon?: string;
|
|
68
|
+
/** Control button aria-label while paused — override for localisation. */
|
|
69
|
+
playLabel?: string;
|
|
70
|
+
/** Control button aria-label while playing — override for localisation. */
|
|
71
|
+
pauseLabel?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
75
|
+
animationRuntime: "40s",
|
|
76
|
+
reverse: false,
|
|
77
|
+
marqueeData: () => [],
|
|
78
|
+
itemConfig: () => ({ width: "50px", height: "50px", gap: "16px" }),
|
|
79
|
+
ariaLabel: "Scrolling content",
|
|
80
|
+
ariaDescription: "Use spacebar to pause or play the animation.",
|
|
81
|
+
showControls: false,
|
|
82
|
+
respectReducedMotion: true,
|
|
83
|
+
playIcon: "mdi:play",
|
|
84
|
+
pauseIcon: "mdi:pause",
|
|
85
|
+
playLabel: "Play animation",
|
|
86
|
+
pauseLabel: "Pause animation",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const displayComponent = ref(false);
|
|
90
|
+
const isPaused = ref(false);
|
|
91
|
+
const isFocused = ref(false);
|
|
92
|
+
const prefersReducedMotion = ref(false);
|
|
93
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
94
|
+
const groupEl = ref<HTMLElement | null>(null);
|
|
95
|
+
const repeatCount = ref(1);
|
|
96
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
97
|
+
|
|
98
|
+
const height = computed(() => props.itemConfig.height || "50px");
|
|
99
|
+
const width = computed(() => props.itemConfig.width || "50px");
|
|
100
|
+
const gap = computed(() => props.itemConfig.gap || "16px");
|
|
101
|
+
const itemCount = computed(() => props.marqueeData.length * repeatCount.value);
|
|
102
|
+
|
|
103
|
+
// A single copy of marqueeData may not be wide enough to fill a wide container — if it
|
|
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.
|
|
106
|
+
const updateRepeatCount = () => {
|
|
107
|
+
if (!rootEl.value || !groupEl.value || props.marqueeData.length === 0) return;
|
|
108
|
+
const baseGroupWidth = groupEl.value.scrollWidth / repeatCount.value;
|
|
109
|
+
if (baseGroupWidth <= 0) return;
|
|
110
|
+
const needed = Math.max(1, Math.ceil(rootEl.value.offsetWidth / baseGroupWidth));
|
|
111
|
+
if (needed !== repeatCount.value) repeatCount.value = needed;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Check for reduced motion preference
|
|
115
|
+
const checkReducedMotion = () => {
|
|
116
|
+
if (typeof window !== "undefined" && props.respectReducedMotion) {
|
|
117
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
118
|
+
prefersReducedMotion.value = mediaQuery.matches;
|
|
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
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const togglePause = () => {
|
|
131
|
+
isPaused.value = !isPaused.value;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
135
|
+
if (event.key === " " || event.key === "Spacebar") {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
togglePause();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleFocus = () => {
|
|
142
|
+
isFocused.value = true;
|
|
143
|
+
if (props.respectReducedMotion) {
|
|
144
|
+
isPaused.value = true;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const handleBlur = () => {
|
|
149
|
+
isFocused.value = false;
|
|
150
|
+
if (!prefersReducedMotion.value) {
|
|
151
|
+
isPaused.value = false;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
watch(
|
|
156
|
+
() => [props.marqueeData, props.itemConfig],
|
|
157
|
+
() => {
|
|
158
|
+
repeatCount.value = 1;
|
|
159
|
+
nextTick(updateRepeatCount);
|
|
160
|
+
},
|
|
161
|
+
{ deep: true }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
onMounted(async () => {
|
|
165
|
+
displayComponent.value = true;
|
|
166
|
+
checkReducedMotion();
|
|
167
|
+
|
|
168
|
+
// Auto-pause if user prefers reduced motion
|
|
169
|
+
if (prefersReducedMotion.value) {
|
|
170
|
+
isPaused.value = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await nextTick();
|
|
174
|
+
updateRepeatCount();
|
|
175
|
+
|
|
176
|
+
if (typeof ResizeObserver !== "undefined" && rootEl.value) {
|
|
177
|
+
resizeObserver = new ResizeObserver(() => updateRepeatCount());
|
|
178
|
+
resizeObserver.observe(rootEl.value);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
onUnmounted(() => {
|
|
183
|
+
resizeObserver?.disconnect();
|
|
184
|
+
});
|
|
185
|
+
</script>
|
|
186
|
+
|
|
187
|
+
<style lang="css">
|
|
188
|
+
@layer components {
|
|
189
|
+
.marquee-scroller {
|
|
190
|
+
--_fade-width: var(--marquee-scroller-fade-width, 10%);
|
|
191
|
+
|
|
192
|
+
width: 100%;
|
|
193
|
+
height: v-bind(height);
|
|
194
|
+
overflow: hidden;
|
|
195
|
+
mask-image: linear-gradient(
|
|
196
|
+
to right,
|
|
197
|
+
transparent,
|
|
198
|
+
#000 var(--_fade-width) calc(100% - var(--_fade-width)),
|
|
199
|
+
transparent
|
|
200
|
+
);
|
|
201
|
+
position: relative;
|
|
202
|
+
|
|
203
|
+
/* Focus styles */
|
|
204
|
+
&:focus-visible {
|
|
205
|
+
outline: var(--marquee-scroller-focus-outline-width, 2px) solid var(--theme-ring);
|
|
206
|
+
outline-offset: var(--marquee-scroller-focus-outline-offset, 2px);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Paused state */
|
|
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 {
|
|
229
|
+
position: absolute;
|
|
230
|
+
width: 1px;
|
|
231
|
+
height: 1px;
|
|
232
|
+
padding: 0;
|
|
233
|
+
margin: -1px;
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
clip: rect(0, 0, 0, 0);
|
|
236
|
+
white-space: nowrap;
|
|
237
|
+
border: 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Control button */
|
|
241
|
+
.control-btn {
|
|
242
|
+
position: absolute;
|
|
243
|
+
inset-block-start: var(--marquee-scroller-control-offset, 8px);
|
|
244
|
+
inset-inline-end: var(--marquee-scroller-control-offset, 8px);
|
|
245
|
+
z-index: 10;
|
|
246
|
+
background: var(--marquee-scroller-control-background-colour, rgba(0, 0, 0, 0.7));
|
|
247
|
+
color: var(--marquee-scroller-control-text-colour, white);
|
|
248
|
+
border: none;
|
|
249
|
+
border-radius: var(--marquee-scroller-control-border-radius, 4px);
|
|
250
|
+
padding: var(--marquee-scroller-control-padding, 8px);
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
font-size: var(--marquee-scroller-control-font-size, 14px);
|
|
253
|
+
transition: background-color var(--marquee-scroller-control-transition-duration, 0.2s);
|
|
254
|
+
|
|
255
|
+
&:hover {
|
|
256
|
+
background-color: var(--marquee-scroller-control-background-colour-hover, rgba(0, 0, 0, 0.9));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
&:focus-visible {
|
|
260
|
+
outline: var(--marquee-scroller-focus-outline-width, 2px) solid var(--theme-ring);
|
|
261
|
+
outline-offset: 2px;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.marquee-track {
|
|
266
|
+
--_track-shift: calc(v-bind(itemCount) * (v-bind(width) + v-bind(gap)));
|
|
267
|
+
|
|
268
|
+
display: flex;
|
|
269
|
+
width: fit-content;
|
|
270
|
+
gap: v-bind(gap);
|
|
271
|
+
animation: marqueeMove v-bind(animationRuntime) linear infinite;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
&.reverse .marquee-track {
|
|
275
|
+
animation-direction: reverse;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.marquee-group {
|
|
279
|
+
display: flex;
|
|
280
|
+
gap: v-bind(gap);
|
|
281
|
+
flex-shrink: 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.item {
|
|
285
|
+
width: v-bind(width);
|
|
286
|
+
height: v-bind(height);
|
|
287
|
+
display: grid;
|
|
288
|
+
place-items: center;
|
|
289
|
+
aspect-ratio: 1 / 1;
|
|
290
|
+
transition: filter var(--marquee-scroller-item-transition-duration, 0.5s);
|
|
291
|
+
flex-shrink: 0;
|
|
292
|
+
|
|
293
|
+
border: var(--marquee-scroller-item-border-width, 1px) solid
|
|
294
|
+
var(--marquee-scroller-item-border-colour, light-dark(var(--slate-10), var(--slate-00)));
|
|
295
|
+
border-radius: var(--marquee-scroller-item-border-radius, 4px);
|
|
296
|
+
|
|
297
|
+
&:hover {
|
|
298
|
+
filter: var(--marquee-scroller-item-hover-filter, grayscale(0));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@keyframes marqueeMove {
|
|
304
|
+
from {
|
|
305
|
+
transform: translateX(0);
|
|
306
|
+
}
|
|
307
|
+
to {
|
|
308
|
+
/* Not -50%: the track's own gap between the two groups makes that overshoot the seam. */
|
|
309
|
+
transform: translateX(calc(-1 * var(--_track-shift)));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* High contrast mode support */
|
|
314
|
+
@media (prefers-contrast: high) {
|
|
315
|
+
.marquee-scroller {
|
|
316
|
+
.control-btn {
|
|
317
|
+
background: ButtonFace;
|
|
318
|
+
color: ButtonText;
|
|
319
|
+
border: var(--marquee-scroller-control-border-width, 1px) solid ButtonText;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* Respect user's motion preferences */
|
|
325
|
+
@media (prefers-reduced-motion: reduce) {
|
|
326
|
+
.marquee-scroller .marquee-track {
|
|
327
|
+
animation: none !important;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
</style>
|
package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import MarqueeScroller from "../MarqueeScroller.vue";
|
|
3
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
4
|
+
import type { MarqueeItem, MarqueeItemConfig } from "~/types/components";
|
|
5
|
+
|
|
6
|
+
// animationRuntime is a CSS duration string ("30s") on the component — Storybook has no native
|
|
7
|
+
// range control for that, so the story exposes a plain-number `animationRuntimeSeconds` arg and
|
|
8
|
+
// maps it to the real prop. See feedback_storybook_extra_args memory / StoryArgs pattern.
|
|
9
|
+
type StoryArgs = {
|
|
10
|
+
animationRuntimeSeconds?: number;
|
|
11
|
+
reverse?: boolean;
|
|
12
|
+
marqueeData?: MarqueeItem[];
|
|
13
|
+
itemConfig?: MarqueeItemConfig;
|
|
14
|
+
ariaLabel?: string;
|
|
15
|
+
ariaDescription?: string;
|
|
16
|
+
showControls?: boolean;
|
|
17
|
+
respectReducedMotion?: boolean;
|
|
18
|
+
playIcon?: string;
|
|
19
|
+
pauseIcon?: string;
|
|
20
|
+
playLabel?: string;
|
|
21
|
+
pauseLabel?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const logoData: MarqueeItem[] = [
|
|
25
|
+
{ id: 1, content: "Logo A" },
|
|
26
|
+
{ id: 2, content: "Logo B" },
|
|
27
|
+
{ id: 3, content: "Logo C" },
|
|
28
|
+
{ id: 4, content: "Logo D" },
|
|
29
|
+
{ id: 5, content: "Logo E" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const logoSlots = logoData
|
|
33
|
+
.map(
|
|
34
|
+
(item) => `
|
|
35
|
+
<template #${item.id}>
|
|
36
|
+
<div style="display: grid; place-items: center; width: 100%; height: 100%; background: light-dark(#eee, #333); font-weight: 600;">
|
|
37
|
+
${item.content}
|
|
38
|
+
</div>
|
|
39
|
+
</template>`
|
|
40
|
+
)
|
|
41
|
+
.join("\n");
|
|
42
|
+
|
|
43
|
+
function useStorySetup(args: StoryArgs) {
|
|
44
|
+
const componentArgs = computed(() => {
|
|
45
|
+
const { animationRuntimeSeconds, ...rest } = args;
|
|
46
|
+
return {
|
|
47
|
+
...rest,
|
|
48
|
+
animationRuntime: `${animationRuntimeSeconds ?? 30}s`,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
return { componentArgs };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meta: Meta<StoryArgs> = {
|
|
55
|
+
title: "Atoms/Effects/MarqueeScroller",
|
|
56
|
+
component: MarqueeScroller,
|
|
57
|
+
argTypes: {
|
|
58
|
+
animationRuntimeSeconds: {
|
|
59
|
+
control: { type: "range", min: 5, max: 90, step: 1 },
|
|
60
|
+
description: "Loop duration in seconds — maps to the `animationRuntime` prop (e.g. `30s`)",
|
|
61
|
+
table: { category: "Animation" },
|
|
62
|
+
},
|
|
63
|
+
reverse: {
|
|
64
|
+
control: "boolean",
|
|
65
|
+
description: "Reverses the scroll direction",
|
|
66
|
+
table: { category: "Animation" },
|
|
67
|
+
},
|
|
68
|
+
marqueeData: {
|
|
69
|
+
control: "object",
|
|
70
|
+
description: "Array of `{ id, content }` items — each `id` names a slot to fill with that item's markup",
|
|
71
|
+
table: { category: "Content" },
|
|
72
|
+
},
|
|
73
|
+
itemConfig: {
|
|
74
|
+
control: "object",
|
|
75
|
+
description: "`{ width, height, gap }` applied to every item and the track",
|
|
76
|
+
table: { category: "Layout" },
|
|
77
|
+
},
|
|
78
|
+
ariaLabel: {
|
|
79
|
+
control: "text",
|
|
80
|
+
table: { category: "Accessibility" },
|
|
81
|
+
},
|
|
82
|
+
ariaDescription: {
|
|
83
|
+
control: "text",
|
|
84
|
+
table: { category: "Accessibility" },
|
|
85
|
+
},
|
|
86
|
+
showControls: {
|
|
87
|
+
control: "boolean",
|
|
88
|
+
description: "Shows a visible pause/play button (required for WCAG 2.2.2 on any auto-scrolling content)",
|
|
89
|
+
table: { category: "Accessibility" },
|
|
90
|
+
},
|
|
91
|
+
respectReducedMotion: {
|
|
92
|
+
control: "boolean",
|
|
93
|
+
description: "Auto-pauses and disables the animation when the user has prefers-reduced-motion set",
|
|
94
|
+
table: { category: "Accessibility" },
|
|
95
|
+
},
|
|
96
|
+
playIcon: {
|
|
97
|
+
control: "text",
|
|
98
|
+
description: "Iconify icon name shown on the control button while paused",
|
|
99
|
+
table: { category: "Control button" },
|
|
100
|
+
},
|
|
101
|
+
pauseIcon: {
|
|
102
|
+
control: "text",
|
|
103
|
+
description: "Iconify icon name shown on the control button while playing",
|
|
104
|
+
table: { category: "Control button" },
|
|
105
|
+
},
|
|
106
|
+
playLabel: {
|
|
107
|
+
control: "text",
|
|
108
|
+
description: "Control button aria-label while paused — override for localisation",
|
|
109
|
+
table: { category: "Control button" },
|
|
110
|
+
},
|
|
111
|
+
pauseLabel: {
|
|
112
|
+
control: "text",
|
|
113
|
+
description: "Control button aria-label while playing — override for localisation",
|
|
114
|
+
table: { category: "Control button" },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
parameters: {
|
|
118
|
+
docs: {
|
|
119
|
+
description: {
|
|
120
|
+
component:
|
|
121
|
+
"An infinite horizontal scroller for logos, badges, or other small repeating items. Items are provided via `marqueeData` plus one dynamically-named slot per item id. Pauses on hover/focus, supports keyboard control (spacebar), and respects prefers-reduced-motion.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default meta;
|
|
128
|
+
type Story = StoryObj<StoryArgs>;
|
|
129
|
+
|
|
130
|
+
export const WithControls: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
animationRuntimeSeconds: 30,
|
|
133
|
+
reverse: false,
|
|
134
|
+
marqueeData: logoData,
|
|
135
|
+
itemConfig: { width: "120px", height: "60px", gap: "24px" },
|
|
136
|
+
showControls: true,
|
|
137
|
+
respectReducedMotion: true,
|
|
138
|
+
},
|
|
139
|
+
render: (args) => ({
|
|
140
|
+
components: { MarqueeScroller },
|
|
141
|
+
setup() {
|
|
142
|
+
const { componentArgs } = useStorySetup(args as StoryArgs);
|
|
143
|
+
return { componentArgs };
|
|
144
|
+
},
|
|
145
|
+
template: `
|
|
146
|
+
<MarqueeScroller v-bind="componentArgs">
|
|
147
|
+
${logoSlots}
|
|
148
|
+
</MarqueeScroller>
|
|
149
|
+
`,
|
|
150
|
+
}),
|
|
151
|
+
};
|