srcdev-nuxt-components 9.4.9 → 9.4.10
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 +20 -6
- package/.claude/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/build.mjs +26 -0
- package/.claude/component-ledger/output.html +8 -3
- package/.claude/component-ledger/template.html +7 -2
- package/.claude/skills/component-dynamic-slots.md +1 -1
- package/.claude/skills/components/alert-mask-core.md +84 -0
- package/.claude/skills/components/canvas-switcher.md +1 -1
- package/.claude/skills/components/carousel-flip.md +112 -168
- package/.claude/skills/components/container-glow.md +123 -0
- package/.claude/skills/components/dashboard-quad-grid.md +98 -0
- package/.claude/skills/components/dashboard-stats-grid.md +103 -0
- package/.claude/skills/components/display-theme-switch.md +60 -0
- package/.claude/skills/components/glowing-border.md +66 -0
- package/.claude/skills/components/input-range-core.md +142 -0
- package/.claude/skills/components/section-parallax.md +32 -10
- package/.claude/skills/components/ui-block-decorated.md +56 -0
- package/.claude/skills/components/wipe-away-vertical.md +98 -0
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/index.md +10 -1
- package/.vscode/css-custom-data.json +38 -0
- package/.vscode/settings.json +3 -0
- package/.vscode/srcdev-component-alert-mask-core.code-snippets +31 -0
- package/.vscode/srcdev-component-carousel-flip.code-snippets +41 -0
- package/.vscode/srcdev-component-container-glow.code-snippets +33 -0
- package/.vscode/srcdev-component-dashboard-quad-grid.code-snippets +36 -0
- package/.vscode/srcdev-component-dashboard-stats-grid.code-snippets +35 -0
- package/.vscode/srcdev-component-display-theme-switch.code-snippets +27 -0
- package/.vscode/srcdev-component-glowing-border.code-snippets +23 -0
- package/.vscode/srcdev-component-input-range.code-snippets +85 -0
- package/.vscode/srcdev-component-section-parallax.code-snippets +27 -0
- package/.vscode/srcdev-component-ui-block-decorated.code-snippets +24 -0
- package/.vscode/srcdev-component-wipe-away-vertical.code-snippets +25 -0
- package/README.md +18 -37
- package/app/components/{alert-mask → 01.atoms/alert-mask}/AlertMaskCore.vue +16 -17
- package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +46 -0
- package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +79 -0
- package/app/components/{alert-mask → 01.atoms/alert-mask}/tests/AlertMaskCore.spec.ts +10 -10
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +86 -0
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +299 -0
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +175 -0
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +131 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +14 -0
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +61 -0
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +132 -0
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +165 -0
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +94 -0
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +3 -0
- package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +44 -0
- package/app/components/{parallax → 01.atoms/animations/section-parallax}/SectionParallax.vue +11 -10
- package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +98 -0
- package/app/components/01.atoms/animations/section-parallax/tests/SectionParallax.spec.ts +102 -0
- package/app/components/01.atoms/animations/section-parallax/tests/__snapshots__/SectionParallax.spec.ts.snap +9 -0
- package/app/components/01.atoms/animations/view-timeline/CONSUMER-STYLING.md +70 -0
- package/app/components/01.atoms/animations/view-timeline/WipeAwayVertical.vue +181 -0
- package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +269 -0
- package/app/components/01.atoms/animations/view-timeline/tests/WipeAwayVertical.spec.ts +127 -0
- package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +42 -0
- package/app/components/{layout-grids/LayoutGridA.vue → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue} +23 -13
- package/app/components/{layout-grids/stories/LayoutGridA.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts} +151 -23
- package/app/components/{layout-grids/tests/LayoutGridA.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts} +25 -25
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +41 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +133 -0
- package/app/components/{layout-grids/stories/LayoutGridB.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts} +24 -24
- package/app/components/{layout-grids/tests/LayoutGridB.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts} +23 -23
- package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +39 -0
- package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +141 -0
- package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +148 -0
- package/app/components/01.atoms/ui-block-decorated/tests/UiBlockDecorated.spec.ts +80 -0
- package/app/components/01.atoms/ui-block-decorated/tests/__snapshots__/UiBlockDecorated.spec.ts.snap +3 -0
- package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +6 -6
- package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +34 -0
- package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +102 -0
- package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +115 -0
- package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +86 -0
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +46 -0
- package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/CarouselFlip.vue +63 -42
- package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/stories/CarouselFlip.stories.ts +96 -11
- package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/tests/CarouselFlip.spec.ts +80 -0
- package/app/components/05.forms/form-field/FormField.vue +1 -1
- package/app/components/05.forms/form-wrapper/FormWrapper.vue +1 -1
- package/app/components/05.forms/input-range/CONSUMER-STYLING.md +45 -0
- package/app/components/05.forms/input-range/InputRangeCore.vue +79 -143
- package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +126 -0
- package/app/components/05.forms/input-range/tests/InputRangeCore.spec.ts +124 -0
- package/app/components/05.forms/input-range/variants/InputRangeDefault.vue +9 -32
- package/app/components/05.forms/input-range/variants/stories/InputRangeDefault.stories.ts +108 -0
- package/app/components/05.forms/input-range/variants/tests/InputRangeDefault.spec.ts +140 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +135 -0
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +16 -2
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -4
- package/app/types/components/container-glow.d.ts +8 -0
- package/app/types/components/index.ts +1 -1
- package/package.json +3 -2
- package/app/components/05.forms/input-range-fancy/InputRangeFancyCore.vue +0 -426
- package/app/components/05.forms/input-range-fancy/InputRangeFancyWithLabel.vue +0 -94
- package/app/components/carousels/CarouselBasic.vue +0 -317
- package/app/components/carousels/CarouselInfinite.vue +0 -358
- package/app/components/container-glow/ContainerGlowCore.vue +0 -296
- package/app/components/container-glow/stories/ContainerGlowCore.stories.ts +0 -337
- package/app/components/display-theme-switch/DisplayThemeSwitch.vue +0 -189
- package/app/components/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +0 -154
- package/app/components/glowing-border/GlowingBorder.vue +0 -142
- package/app/components/glowing-border/stories/GlowingBorder.stories.ts +0 -141
- package/app/components/layout-grids/LayoutGridB.vue +0 -134
- package/app/components/test-storybook/TestStorybook.vue +0 -49
- package/app/components/test-storybook/stories/TestStorybook.stories.ts +0 -28
- package/app/components/ui-block-decorated/UiBlockDecorated.vue +0 -167
- package/app/components/view-timeline/WipeAwayVertical.vue +0 -180
- package/app/types/components/carousel-basic.d.ts +0 -19
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { nextTick, ref } from "vue";
|
|
4
|
+
import DisplayThemeSwitch from "../DisplayThemeSwitch.vue";
|
|
5
|
+
|
|
6
|
+
const { useSettingsStoreMock, setColourSchemeMock } = vi.hoisted(() => ({
|
|
7
|
+
useSettingsStoreMock: vi.fn(),
|
|
8
|
+
setColourSchemeMock: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
mockNuxtImport("useSettingsStore", () => useSettingsStoreMock);
|
|
12
|
+
|
|
13
|
+
describe("DisplayThemeSwitch", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
setColourSchemeMock.mockClear();
|
|
16
|
+
useSettingsStoreMock.mockReturnValue({
|
|
17
|
+
colourScheme: ref("system"),
|
|
18
|
+
setColourScheme: setColourSchemeMock,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("mounts without error", async () => {
|
|
23
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
24
|
+
await nextTick();
|
|
25
|
+
expect(wrapper.vm).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders TripleToggleSwitchCore with the colour-scheme-select class", async () => {
|
|
29
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
30
|
+
await nextTick();
|
|
31
|
+
expect(wrapper.find(".colour-scheme-select").exists()).toBe(true);
|
|
32
|
+
expect(wrapper.find(".triple-toggle-switch").exists()).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders default option labels", async () => {
|
|
36
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
37
|
+
await nextTick();
|
|
38
|
+
const labels = wrapper.findAll(".option-group .sr-only").map((el) => el.text());
|
|
39
|
+
expect(labels).toEqual(["System", "Light", "Dark"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders custom option labels via props", async () => {
|
|
43
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch, {
|
|
44
|
+
props: {
|
|
45
|
+
systemLabel: "Auto",
|
|
46
|
+
lightLabel: "Day",
|
|
47
|
+
darkLabel: "Night",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
await nextTick();
|
|
51
|
+
const labels = wrapper.findAll(".option-group .sr-only").map((el) => el.text());
|
|
52
|
+
expect(labels).toEqual(["Auto", "Day", "Night"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("applies custom option icons via props", async () => {
|
|
56
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch, {
|
|
57
|
+
props: {
|
|
58
|
+
systemIcon: "custom:system",
|
|
59
|
+
lightIcon: "custom:light",
|
|
60
|
+
darkIcon: "custom:dark",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
await nextTick();
|
|
64
|
+
const icons = wrapper.findAll(".option-icon").map((el) => el.attributes("class"));
|
|
65
|
+
expect(icons.some((c) => c?.includes("system"))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("applies the small class via styleClassPassthrough", async () => {
|
|
69
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch, {
|
|
70
|
+
props: { styleClassPassthrough: "small" },
|
|
71
|
+
});
|
|
72
|
+
await nextTick();
|
|
73
|
+
expect(wrapper.find(".triple-toggle-switch").classes()).toContain("small");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("calls setColourScheme when the selected option changes", async () => {
|
|
77
|
+
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
78
|
+
await nextTick();
|
|
79
|
+
|
|
80
|
+
const lightInput = wrapper.findAll("input.option-input")[1];
|
|
81
|
+
await lightInput?.setValue(true);
|
|
82
|
+
await nextTick();
|
|
83
|
+
|
|
84
|
+
expect(setColourSchemeMock).toHaveBeenCalledWith("light");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# CarouselFlip — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
| Token | Default | Controls |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `--carousel-flip-gap` | `1rem` | Gap between items inside the item track |
|
|
8
|
+
| `--carousel-flip-layout-gap` | `1rem` | Grid gap between the prev/carousel/next/controls areas |
|
|
9
|
+
| `--carousel-flip-display-max-width` | `80rem` | Max width of the item track and the controls bar |
|
|
10
|
+
| `--carousel-flip-item-max-width` | `80rem` | Max width of a single item, before subtracting the edge-preview width |
|
|
11
|
+
| `--carousel-flip-edge-preview-width` | `4rem` | How much of the neighbouring items peek in at each edge |
|
|
12
|
+
| `--carousel-flip-marker-gap` | `1rem` | Gap between marker (dot) buttons |
|
|
13
|
+
| `--carousel-flip-marker-active-colour` | `light-dark(var(--slate-10), var(--slate-00))` | Active marker background colour |
|
|
14
|
+
| `--carousel-flip-button-background-colour` | `light-dark(white, var(--slate-08))` | Prev/next button background colour |
|
|
15
|
+
| `--carousel-flip-button-border-width` | `0.1rem` | Prev/next button border width |
|
|
16
|
+
| `--carousel-flip-button-border-colour` | `light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%))` | Prev/next button border colour |
|
|
17
|
+
| `--carousel-flip-button-border-radius` | `100vw` | Prev/next button corner radius |
|
|
18
|
+
| `--carousel-flip-button-padding` | `0.8rem` | Prev/next button padding |
|
|
19
|
+
| `--carousel-flip-button-icon-size` | `2.4rem` | Prev/next button icon glyph size |
|
|
20
|
+
| `--carousel-flip-buttons-gap` | `1rem` | Gap between prev/next buttons in the `controls-grouped-right` layout |
|
|
21
|
+
| `--carousel-flip-overlay-controls-offset` | `1rem` | Distance of the controls bar from the bottom edge in the `overlay` layout |
|
|
22
|
+
| `--carousel-flip-focus-outline-width` | `0.2rem` | Marker button focus-visible outline width |
|
|
23
|
+
| `--carousel-flip-focus-outline-colour` | `var(--theme-ring)` | Marker button focus-visible outline colour |
|
|
24
|
+
| `--carousel-flip-focus-outline-offset` | `0.2rem` | Marker button focus-visible outline offset |
|
|
25
|
+
|
|
26
|
+
```css
|
|
27
|
+
.my-page {
|
|
28
|
+
--carousel-flip-button-background-colour: var(--theme-surface);
|
|
29
|
+
--carousel-flip-marker-active-colour: var(--brand-primary);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or scope to a single instance via `styleClassPassthrough`:
|
|
34
|
+
|
|
35
|
+
```vue
|
|
36
|
+
<CarouselFlip style-class-passthrough="hero-carousel">...</CarouselFlip>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- `--carousel-flip-display-max-width`/`--carousel-flip-item-max-width` previously had no declared
|
|
42
|
+
value anywhere in the component (referenced via `var()` with no fallback) — this meant the
|
|
43
|
+
edge-preview offset and max-width caps silently never applied in production. Fixed 2026-09-21;
|
|
44
|
+
see the component's skill doc for detail.
|
|
45
|
+
- `useFlipAnimation`/`useSpringEffect`/`transitionSpeed` control animation behaviour via props,
|
|
46
|
+
not tokens — use the props rather than CSS overrides for those.
|
package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/CarouselFlip.vue
RENAMED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
:class="[elementClasses]"
|
|
6
6
|
:data-button-layout="buttonLayout"
|
|
7
7
|
role="region"
|
|
8
|
-
aria-label="
|
|
8
|
+
:aria-label="ariaLabel"
|
|
9
9
|
>
|
|
10
10
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
|
11
|
-
|
|
11
|
+
{{ props.itemPositionAnnouncement(currentActiveIndex + 1, itemCount) }}
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
<div
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
class="item-container"
|
|
18
18
|
:class="{ 'allow-overflow': allowCarouselOverflow }"
|
|
19
19
|
role="group"
|
|
20
|
-
aria-label="
|
|
20
|
+
:aria-label="itemsAriaLabel"
|
|
21
21
|
>
|
|
22
22
|
<div
|
|
23
23
|
v-for="(item, index) in carouselDataIds"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<button
|
|
40
40
|
class="btn-marker"
|
|
41
41
|
:class="[{ active: displayActiveIndex === index - 1 }]"
|
|
42
|
-
:aria-label="
|
|
42
|
+
:aria-label="`${jumpToItemLabel} ${index}`"
|
|
43
43
|
@click.prevent="jumpToFrame(index - 1)"
|
|
44
44
|
></button>
|
|
45
45
|
</li>
|
|
@@ -48,16 +48,15 @@
|
|
|
48
48
|
</div>
|
|
49
49
|
|
|
50
50
|
<div class="buttons-container">
|
|
51
|
-
<button
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@click.prevent="actionPrevious()"
|
|
56
|
-
>
|
|
57
|
-
<Icon name="ic:outline-keyboard-arrow-left" class="arrows-icon" />
|
|
51
|
+
<button type="button" class="btn-action btn-prev" :aria-label="previousLabel" @click.prevent="actionPrevious()">
|
|
52
|
+
<slot name="prev-icon">
|
|
53
|
+
<Icon :name="prevIcon" class="arrows-icon" />
|
|
54
|
+
</slot>
|
|
58
55
|
</button>
|
|
59
|
-
<button type="button" class="btn-action btn-next" aria-label="
|
|
60
|
-
<
|
|
56
|
+
<button type="button" class="btn-action btn-next" :aria-label="nextLabel" @click.prevent="actionNext()">
|
|
57
|
+
<slot name="next-icon">
|
|
58
|
+
<Icon :name="nextIcon" class="arrows-icon" />
|
|
59
|
+
</slot>
|
|
61
60
|
</button>
|
|
62
61
|
</div>
|
|
63
62
|
</section>
|
|
@@ -75,6 +74,14 @@ interface Props {
|
|
|
75
74
|
useSpringEffect?: boolean;
|
|
76
75
|
buttonLayout?: "sides" | "controls-flanking" | "controls-grouped-right" | "overlay";
|
|
77
76
|
showControls?: boolean;
|
|
77
|
+
ariaLabel?: string;
|
|
78
|
+
itemsAriaLabel?: string;
|
|
79
|
+
previousLabel?: string;
|
|
80
|
+
nextLabel?: string;
|
|
81
|
+
jumpToItemLabel?: string;
|
|
82
|
+
itemPositionAnnouncement?: (current: number, total: number) => string;
|
|
83
|
+
prevIcon?: string;
|
|
84
|
+
nextIcon?: string;
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -86,8 +93,17 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
86
93
|
useSpringEffect: false,
|
|
87
94
|
buttonLayout: "sides",
|
|
88
95
|
showControls: true,
|
|
96
|
+
ariaLabel: "Image carousel",
|
|
97
|
+
itemsAriaLabel: "Carousel items",
|
|
98
|
+
previousLabel: "Go to previous item",
|
|
99
|
+
nextLabel: "Go to next item",
|
|
100
|
+
jumpToItemLabel: "Jump to item",
|
|
101
|
+
itemPositionAnnouncement: (current: number, total: number) => `Item ${current} of ${total}`,
|
|
102
|
+
prevIcon: "ic:outline-keyboard-arrow-left",
|
|
103
|
+
nextIcon: "ic:outline-keyboard-arrow-right",
|
|
89
104
|
});
|
|
90
105
|
|
|
106
|
+
|
|
91
107
|
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
92
108
|
|
|
93
109
|
const carouselWrapperRef = ref<HTMLDivElement | null>(null);
|
|
@@ -107,19 +123,11 @@ const transitionSpeedStr = props.transitionSpeed + "ms";
|
|
|
107
123
|
|
|
108
124
|
const itemWidth = ref(0);
|
|
109
125
|
const itemWidthOffsetStr = computed(() => {
|
|
110
|
-
// if (props.allowCarouselOverflow) {
|
|
111
126
|
if (props.useFlipAnimation) {
|
|
112
|
-
return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - var(--_carousel-item-track-gap))`;
|
|
127
|
+
return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - var(--_carousel-item-track-gap))`;
|
|
113
128
|
} else {
|
|
114
|
-
return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - (2 * var(--_carousel-item-track-gap)))`;
|
|
129
|
+
return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - (2 * var(--_carousel-item-track-gap)))`;
|
|
115
130
|
}
|
|
116
|
-
// } else {
|
|
117
|
-
// if (props.useFlipAnimation) {
|
|
118
|
-
// return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - var(--_carousel-item-track-gap))` // Goof
|
|
119
|
-
// } else {
|
|
120
|
-
// return `calc(-${initialItemOffset.value} * ${itemWidth.value}px - (2 * var(--_carousel-item-track-gap)))` // Good
|
|
121
|
-
// }
|
|
122
|
-
// }
|
|
123
131
|
});
|
|
124
132
|
const currentActiveIndex = ref(0);
|
|
125
133
|
|
|
@@ -256,7 +264,10 @@ const reorderItems = (skipAnimation: boolean = false) => {
|
|
|
256
264
|
item.style.transform = `translateX(${deltaX}px)`;
|
|
257
265
|
|
|
258
266
|
requestAnimationFrame(() => {
|
|
259
|
-
const shouldTransition =
|
|
267
|
+
const shouldTransition =
|
|
268
|
+
carouselInitComplete.value &&
|
|
269
|
+
userHasInteracted.value &&
|
|
270
|
+
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
260
271
|
let transitionProperties = "none";
|
|
261
272
|
|
|
262
273
|
if (shouldTransition) {
|
|
@@ -416,7 +427,7 @@ onMounted(() => {
|
|
|
416
427
|
<style lang="css">
|
|
417
428
|
@layer components {
|
|
418
429
|
.carousel-flip {
|
|
419
|
-
--_carousel-item-track-gap:
|
|
430
|
+
--_carousel-item-track-gap: var(--carousel-flip-gap, 1rem);
|
|
420
431
|
|
|
421
432
|
display: grid;
|
|
422
433
|
grid-template-columns: auto 1fr auto;
|
|
@@ -424,7 +435,7 @@ onMounted(() => {
|
|
|
424
435
|
grid-template-areas:
|
|
425
436
|
"prev carousel next"
|
|
426
437
|
". controls . ";
|
|
427
|
-
gap:
|
|
438
|
+
gap: var(--carousel-flip-layout-gap, 1rem);
|
|
428
439
|
opacity: 0;
|
|
429
440
|
|
|
430
441
|
&.mounted {
|
|
@@ -451,7 +462,7 @@ onMounted(() => {
|
|
|
451
462
|
position: relative;
|
|
452
463
|
isolation: isolate;
|
|
453
464
|
|
|
454
|
-
max-inline-size: var(--
|
|
465
|
+
max-inline-size: var(--carousel-flip-display-max-width, 80rem);
|
|
455
466
|
margin-inline: auto;
|
|
456
467
|
|
|
457
468
|
&.allow-overflow {
|
|
@@ -466,17 +477,20 @@ onMounted(() => {
|
|
|
466
477
|
margin-inline: auto;
|
|
467
478
|
|
|
468
479
|
max-inline-size: calc(
|
|
469
|
-
var(--
|
|
470
|
-
(2 * var(--
|
|
480
|
+
var(--carousel-flip-item-max-width, 80rem) + var(--_carousel-item-track-gap) -
|
|
481
|
+
(2 * var(--carousel-flip-edge-preview-width, 4rem))
|
|
471
482
|
);
|
|
472
483
|
|
|
473
484
|
translate: calc(
|
|
474
|
-
v-bind(itemWidthOffsetStr) - var(--_carousel-item-track-gap) +
|
|
485
|
+
v-bind(itemWidthOffsetStr) - var(--_carousel-item-track-gap) +
|
|
486
|
+
var(--carousel-flip-edge-preview-width, 4rem)
|
|
475
487
|
)
|
|
476
488
|
0;
|
|
477
489
|
|
|
478
490
|
&.loaded {
|
|
479
|
-
|
|
491
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
492
|
+
transition: transform v-bind(transitionSpeedStr) ease;
|
|
493
|
+
}
|
|
480
494
|
}
|
|
481
495
|
}
|
|
482
496
|
}
|
|
@@ -486,14 +500,14 @@ onMounted(() => {
|
|
|
486
500
|
display: flex;
|
|
487
501
|
align-items: center;
|
|
488
502
|
justify-content: center;
|
|
489
|
-
max-inline-size: var(--
|
|
503
|
+
max-inline-size: var(--carousel-flip-display-max-width, 80rem);
|
|
490
504
|
margin-inline: auto;
|
|
491
505
|
|
|
492
506
|
.markers-container {
|
|
493
507
|
.markers-list {
|
|
494
508
|
display: flex;
|
|
495
509
|
flex-direction: row;
|
|
496
|
-
gap:
|
|
510
|
+
gap: var(--carousel-flip-marker-gap, 1rem);
|
|
497
511
|
list-style-type: none;
|
|
498
512
|
margin: unset;
|
|
499
513
|
padding: unset;
|
|
@@ -507,7 +521,13 @@ onMounted(() => {
|
|
|
507
521
|
transition: background-color v-bind(transitionSpeedStr) linear;
|
|
508
522
|
|
|
509
523
|
&.active {
|
|
510
|
-
background-color: light-dark(var(--slate-10), var(--slate-00));
|
|
524
|
+
background-color: var(--carousel-flip-marker-active-colour, light-dark(var(--slate-10), var(--slate-00)));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
&:focus-visible {
|
|
528
|
+
outline: var(--carousel-flip-focus-outline-width, 0.2rem) solid
|
|
529
|
+
var(--carousel-flip-focus-outline-colour, var(--theme-ring));
|
|
530
|
+
outline-offset: var(--carousel-flip-focus-outline-offset, 0.2rem);
|
|
511
531
|
}
|
|
512
532
|
}
|
|
513
533
|
}
|
|
@@ -538,14 +558,15 @@ onMounted(() => {
|
|
|
538
558
|
cursor: pointer;
|
|
539
559
|
height: fit-content;
|
|
540
560
|
|
|
541
|
-
background-color: white;
|
|
542
|
-
border:
|
|
543
|
-
|
|
544
|
-
|
|
561
|
+
background-color: var(--carousel-flip-button-background-colour, light-dark(white, var(--slate-08)));
|
|
562
|
+
border: var(--carousel-flip-button-border-width, 0.1rem) solid
|
|
563
|
+
var(--carousel-flip-button-border-colour, light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%)));
|
|
564
|
+
border-radius: var(--carousel-flip-button-border-radius, 100vw);
|
|
565
|
+
padding: var(--carousel-flip-button-padding, 0.8rem);
|
|
545
566
|
|
|
546
567
|
.arrows-icon {
|
|
547
|
-
width:
|
|
548
|
-
height:
|
|
568
|
+
width: var(--carousel-flip-button-icon-size, 2.4rem);
|
|
569
|
+
height: var(--carousel-flip-button-icon-size, 2.4rem);
|
|
549
570
|
}
|
|
550
571
|
}
|
|
551
572
|
|
|
@@ -574,7 +595,7 @@ onMounted(() => {
|
|
|
574
595
|
.buttons-container {
|
|
575
596
|
display: flex;
|
|
576
597
|
align-items: center;
|
|
577
|
-
gap:
|
|
598
|
+
gap: var(--carousel-flip-buttons-gap, 1rem);
|
|
578
599
|
grid-area: controls;
|
|
579
600
|
justify-self: end;
|
|
580
601
|
}
|
|
@@ -586,7 +607,7 @@ onMounted(() => {
|
|
|
586
607
|
grid-area: carousel;
|
|
587
608
|
align-self: end;
|
|
588
609
|
z-index: 1;
|
|
589
|
-
padding-block-end: 1rem;
|
|
610
|
+
padding-block-end: var(--carousel-flip-overlay-controls-offset, 1rem);
|
|
590
611
|
}
|
|
591
612
|
}
|
|
592
613
|
}
|
|
@@ -19,6 +19,13 @@ interface CarouselFlipStoryArgs {
|
|
|
19
19
|
buttonLayout: "sides" | "controls-flanking" | "controls-grouped-right" | "overlay";
|
|
20
20
|
showControls: boolean;
|
|
21
21
|
styleClassPassthrough: string[];
|
|
22
|
+
ariaLabel: string;
|
|
23
|
+
itemsAriaLabel: string;
|
|
24
|
+
previousLabel: string;
|
|
25
|
+
nextLabel: string;
|
|
26
|
+
jumpToItemLabel: string;
|
|
27
|
+
prevIcon: string;
|
|
28
|
+
nextIcon: string;
|
|
22
29
|
// Mock data for stories
|
|
23
30
|
carouselData: CarouselItem[];
|
|
24
31
|
}
|
|
@@ -68,7 +75,7 @@ const sampleCarouselItems: CarouselItem[] = [
|
|
|
68
75
|
];
|
|
69
76
|
|
|
70
77
|
export default {
|
|
71
|
-
title: "
|
|
78
|
+
title: "Organisms/Image Galleries/Carousel Flip",
|
|
72
79
|
component: StorybookComponent,
|
|
73
80
|
argTypes: {
|
|
74
81
|
// Carousel Configuration
|
|
@@ -129,6 +136,56 @@ export default {
|
|
|
129
136
|
category: "Layout",
|
|
130
137
|
},
|
|
131
138
|
},
|
|
139
|
+
// Accessibility / localisation
|
|
140
|
+
ariaLabel: {
|
|
141
|
+
control: { type: "text" },
|
|
142
|
+
description: "Accessible name for the carousel region",
|
|
143
|
+
table: {
|
|
144
|
+
category: "Accessibility",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
itemsAriaLabel: {
|
|
148
|
+
control: { type: "text" },
|
|
149
|
+
description: "Accessible name for the item group",
|
|
150
|
+
table: {
|
|
151
|
+
category: "Accessibility",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
previousLabel: {
|
|
155
|
+
control: { type: "text" },
|
|
156
|
+
description: "Accessible label for the previous button",
|
|
157
|
+
table: {
|
|
158
|
+
category: "Accessibility",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
nextLabel: {
|
|
162
|
+
control: { type: "text" },
|
|
163
|
+
description: "Accessible label for the next button",
|
|
164
|
+
table: {
|
|
165
|
+
category: "Accessibility",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
jumpToItemLabel: {
|
|
169
|
+
control: { type: "text" },
|
|
170
|
+
description: "Accessible label prefix for marker buttons (index is appended)",
|
|
171
|
+
table: {
|
|
172
|
+
category: "Accessibility",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
prevIcon: {
|
|
176
|
+
control: { type: "text" },
|
|
177
|
+
description: "Iconify name for the previous button icon",
|
|
178
|
+
table: {
|
|
179
|
+
category: "Icons",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
nextIcon: {
|
|
183
|
+
control: { type: "text" },
|
|
184
|
+
description: "Iconify name for the next button icon",
|
|
185
|
+
table: {
|
|
186
|
+
category: "Icons",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
132
189
|
// Display data for reference
|
|
133
190
|
carouselData: {
|
|
134
191
|
control: { type: "object" },
|
|
@@ -147,6 +204,13 @@ export default {
|
|
|
147
204
|
buttonLayout: "sides",
|
|
148
205
|
showControls: true,
|
|
149
206
|
styleClassPassthrough: ["carousel-flip-demo"],
|
|
207
|
+
ariaLabel: "Image carousel",
|
|
208
|
+
itemsAriaLabel: "Carousel items",
|
|
209
|
+
previousLabel: "Go to previous item",
|
|
210
|
+
nextLabel: "Go to next item",
|
|
211
|
+
jumpToItemLabel: "Jump to item",
|
|
212
|
+
prevIcon: "ic:outline-keyboard-arrow-left",
|
|
213
|
+
nextIcon: "ic:outline-keyboard-arrow-right",
|
|
150
214
|
carouselData: sampleCarouselItems,
|
|
151
215
|
},
|
|
152
216
|
} as Meta<CarouselFlipStoryArgs>;
|
|
@@ -180,15 +244,15 @@ const Template: StoryFn<CarouselFlipStoryArgs> = (args) => ({
|
|
|
180
244
|
}
|
|
181
245
|
|
|
182
246
|
.carousel-flip-demo.carousel-flip {
|
|
183
|
-
--
|
|
184
|
-
--
|
|
185
|
-
--
|
|
247
|
+
--carousel-flip-gap: 12px;
|
|
248
|
+
--carousel-flip-item-max-width: 800px;
|
|
249
|
+
--carousel-flip-edge-preview-width: 12px;
|
|
186
250
|
}
|
|
187
251
|
|
|
188
252
|
@media (width >= 768px) {
|
|
189
253
|
.carousel-flip-demo.carousel-flip {
|
|
190
|
-
--
|
|
191
|
-
--
|
|
254
|
+
--carousel-flip-gap: 24px;
|
|
255
|
+
--carousel-flip-edge-preview-width: 80px;
|
|
192
256
|
}
|
|
193
257
|
}
|
|
194
258
|
|
|
@@ -311,6 +375,13 @@ const Template: StoryFn<CarouselFlipStoryArgs> = (args) => ({
|
|
|
311
375
|
:button-layout="args.buttonLayout"
|
|
312
376
|
:show-controls="args.showControls"
|
|
313
377
|
:style-class-passthrough="args.styleClassPassthrough"
|
|
378
|
+
:aria-label="args.ariaLabel"
|
|
379
|
+
:items-aria-label="args.itemsAriaLabel"
|
|
380
|
+
:previous-label="args.previousLabel"
|
|
381
|
+
:next-label="args.nextLabel"
|
|
382
|
+
:jump-to-item-label="args.jumpToItemLabel"
|
|
383
|
+
:prev-icon="args.prevIcon"
|
|
384
|
+
:next-icon="args.nextIcon"
|
|
314
385
|
>
|
|
315
386
|
<template v-for="(item, index) in filteredCarouselData" #[item.id] :key="index">
|
|
316
387
|
<div class="custom-carousel-item">
|
|
@@ -427,15 +498,15 @@ const RichContentTemplate: StoryFn<CarouselFlipStoryArgs> = (args) => ({
|
|
|
427
498
|
style.id = styleId;
|
|
428
499
|
style.textContent = `
|
|
429
500
|
.carousel-flip-demo.carousel-flip {
|
|
430
|
-
--
|
|
431
|
-
--
|
|
432
|
-
--
|
|
501
|
+
--carousel-flip-gap: 12px;
|
|
502
|
+
--carousel-flip-item-max-width: 800px;
|
|
503
|
+
--carousel-flip-edge-preview-width: 12px;
|
|
433
504
|
}
|
|
434
505
|
|
|
435
506
|
@media (width >= 768px) {
|
|
436
507
|
.carousel-flip-demo.carousel-flip {
|
|
437
|
-
--
|
|
438
|
-
--
|
|
508
|
+
--carousel-flip-gap: 24px;
|
|
509
|
+
--carousel-flip-edge-preview-width: 80px;
|
|
439
510
|
}
|
|
440
511
|
}
|
|
441
512
|
|
|
@@ -486,6 +557,13 @@ const RichContentTemplate: StoryFn<CarouselFlipStoryArgs> = (args) => ({
|
|
|
486
557
|
:button-layout="args.buttonLayout"
|
|
487
558
|
:show-controls="args.showControls"
|
|
488
559
|
:style-class-passthrough="args.styleClassPassthrough"
|
|
560
|
+
:aria-label="args.ariaLabel"
|
|
561
|
+
:items-aria-label="args.itemsAriaLabel"
|
|
562
|
+
:previous-label="args.previousLabel"
|
|
563
|
+
:next-label="args.nextLabel"
|
|
564
|
+
:jump-to-item-label="args.jumpToItemLabel"
|
|
565
|
+
:prev-icon="args.prevIcon"
|
|
566
|
+
:next-icon="args.nextIcon"
|
|
489
567
|
>
|
|
490
568
|
<template v-for="(item, index) in filteredCarouselData" #[item.id] :key="index">
|
|
491
569
|
<div
|
|
@@ -628,6 +706,13 @@ const AnimationShowcaseTemplate: StoryFn<CarouselFlipStoryArgs> = (args) => ({
|
|
|
628
706
|
:button-layout="args.buttonLayout"
|
|
629
707
|
:show-controls="args.showControls"
|
|
630
708
|
:style-class-passthrough="args.styleClassPassthrough"
|
|
709
|
+
:aria-label="args.ariaLabel"
|
|
710
|
+
:items-aria-label="args.itemsAriaLabel"
|
|
711
|
+
:previous-label="args.previousLabel"
|
|
712
|
+
:next-label="args.nextLabel"
|
|
713
|
+
:jump-to-item-label="args.jumpToItemLabel"
|
|
714
|
+
:prev-icon="args.prevIcon"
|
|
715
|
+
:next-icon="args.nextIcon"
|
|
631
716
|
>
|
|
632
717
|
<template v-for="(item, index) in filteredCarouselData" #[item.id] :key="index">
|
|
633
718
|
<div
|
|
@@ -3,6 +3,16 @@ import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
|
3
3
|
import { nextTick } from "vue";
|
|
4
4
|
import CarouselFlip from "../CarouselFlip.vue";
|
|
5
5
|
|
|
6
|
+
const stubMatchMedia = (prefersReducedMotion: boolean) => {
|
|
7
|
+
vi.stubGlobal(
|
|
8
|
+
"matchMedia",
|
|
9
|
+
vi.fn().mockReturnValue({
|
|
10
|
+
matches: prefersReducedMotion,
|
|
11
|
+
addEventListener: vi.fn(),
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
interface CarouselFlipInstance {
|
|
7
17
|
carouselWrapperRef: HTMLDivElement | null;
|
|
8
18
|
carouselContainerRef: HTMLDivElement | null;
|
|
@@ -115,6 +125,7 @@ describe("CarouselFlip", () => {
|
|
|
115
125
|
beforeEach(() => {
|
|
116
126
|
vi.clearAllMocks();
|
|
117
127
|
mockDirection.value = "";
|
|
128
|
+
stubMatchMedia(false);
|
|
118
129
|
});
|
|
119
130
|
|
|
120
131
|
afterEach(() => {
|
|
@@ -271,6 +282,75 @@ describe("CarouselFlip", () => {
|
|
|
271
282
|
|
|
272
283
|
expect(wrapper.attributes("data-button-layout")).toBe("overlay");
|
|
273
284
|
});
|
|
285
|
+
|
|
286
|
+
it("overrides accessible labels via props", async () => {
|
|
287
|
+
await createWrapper({
|
|
288
|
+
ariaLabel: "Featured looks",
|
|
289
|
+
itemsAriaLabel: "Look slides",
|
|
290
|
+
previousLabel: "Previous look",
|
|
291
|
+
nextLabel: "Next look",
|
|
292
|
+
jumpToItemLabel: "View look",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(wrapper.find('[role="region"]').attributes("aria-label")).toBe("Featured looks");
|
|
296
|
+
expect(wrapper.find('[role="group"]').attributes("aria-label")).toBe("Look slides");
|
|
297
|
+
expect(wrapper.find('[aria-label="Previous look"]').exists()).toBe(true);
|
|
298
|
+
expect(wrapper.find('[aria-label="Next look"]').exists()).toBe(true);
|
|
299
|
+
expect(wrapper.find('[aria-label="View look 1"]').exists()).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("overrides the screen-reader position announcement via prop", async () => {
|
|
303
|
+
await createWrapper({
|
|
304
|
+
itemPositionAnnouncement: (current: number, total: number) => `Slide ${current} / ${total}`,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(wrapper.find('[aria-live="polite"]').text()).toBe("Slide 1 / 6");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("overrides prev/next icons via props", async () => {
|
|
311
|
+
await createWrapper({ prevIcon: "mdi:chevron-left", nextIcon: "mdi:chevron-right" });
|
|
312
|
+
|
|
313
|
+
const prevButton = wrapper.find('[aria-label="Go to previous item"]');
|
|
314
|
+
const nextButton = wrapper.find('[aria-label="Go to next item"]');
|
|
315
|
+
|
|
316
|
+
expect(prevButton.html()).toContain("i-mdi:chevron-left");
|
|
317
|
+
expect(nextButton.html()).toContain("i-mdi:chevron-right");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("allows overriding the prev/next icon via slots", async () => {
|
|
321
|
+
wrapper = await mountSuspended(CarouselFlip, {
|
|
322
|
+
props: { carouselDataIds: mockCarouselData },
|
|
323
|
+
slots: {
|
|
324
|
+
"prev-icon": '<span data-testid="custom-prev">Prev</span>',
|
|
325
|
+
"next-icon": '<span data-testid="custom-next">Next</span>',
|
|
326
|
+
},
|
|
327
|
+
global: {
|
|
328
|
+
components: {
|
|
329
|
+
Icon: {
|
|
330
|
+
template: '<svg data-testid="icon" :class="$attrs.class"><title>{{ name }}</title></svg>',
|
|
331
|
+
props: ["name"],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(wrapper.find('[data-testid="custom-prev"]').exists()).toBe(true);
|
|
338
|
+
expect(wrapper.find('[data-testid="custom-next"]').exists()).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("Reduced Motion", () => {
|
|
343
|
+
it("skips the JS-driven transition when prefers-reduced-motion is set", async () => {
|
|
344
|
+
stubMatchMedia(true);
|
|
345
|
+
await createWrapper();
|
|
346
|
+
await nextTick();
|
|
347
|
+
|
|
348
|
+
const nextButton = wrapper.find('[aria-label="Go to next item"]');
|
|
349
|
+
await nextButton.trigger("click");
|
|
350
|
+
await nextTick();
|
|
351
|
+
|
|
352
|
+
expect(component.currentActiveIndex).toBe(1);
|
|
353
|
+
});
|
|
274
354
|
});
|
|
275
355
|
|
|
276
356
|
describe("Initial Setup", () => {
|