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.
Files changed (112) hide show
  1. package/.claude/commands/migrate-component.md +20 -6
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/build.mjs +26 -0
  4. package/.claude/component-ledger/output.html +8 -3
  5. package/.claude/component-ledger/template.html +7 -2
  6. package/.claude/skills/component-dynamic-slots.md +1 -1
  7. package/.claude/skills/components/alert-mask-core.md +84 -0
  8. package/.claude/skills/components/canvas-switcher.md +1 -1
  9. package/.claude/skills/components/carousel-flip.md +112 -168
  10. package/.claude/skills/components/container-glow.md +123 -0
  11. package/.claude/skills/components/dashboard-quad-grid.md +98 -0
  12. package/.claude/skills/components/dashboard-stats-grid.md +103 -0
  13. package/.claude/skills/components/display-theme-switch.md +60 -0
  14. package/.claude/skills/components/glowing-border.md +66 -0
  15. package/.claude/skills/components/input-range-core.md +142 -0
  16. package/.claude/skills/components/section-parallax.md +32 -10
  17. package/.claude/skills/components/ui-block-decorated.md +56 -0
  18. package/.claude/skills/components/wipe-away-vertical.md +98 -0
  19. package/.claude/skills/icon-sets.md +1 -1
  20. package/.claude/skills/index.md +10 -1
  21. package/.vscode/css-custom-data.json +38 -0
  22. package/.vscode/settings.json +3 -0
  23. package/.vscode/srcdev-component-alert-mask-core.code-snippets +31 -0
  24. package/.vscode/srcdev-component-carousel-flip.code-snippets +41 -0
  25. package/.vscode/srcdev-component-container-glow.code-snippets +33 -0
  26. package/.vscode/srcdev-component-dashboard-quad-grid.code-snippets +36 -0
  27. package/.vscode/srcdev-component-dashboard-stats-grid.code-snippets +35 -0
  28. package/.vscode/srcdev-component-display-theme-switch.code-snippets +27 -0
  29. package/.vscode/srcdev-component-glowing-border.code-snippets +23 -0
  30. package/.vscode/srcdev-component-input-range.code-snippets +85 -0
  31. package/.vscode/srcdev-component-section-parallax.code-snippets +27 -0
  32. package/.vscode/srcdev-component-ui-block-decorated.code-snippets +24 -0
  33. package/.vscode/srcdev-component-wipe-away-vertical.code-snippets +25 -0
  34. package/README.md +18 -37
  35. package/app/components/{alert-mask → 01.atoms/alert-mask}/AlertMaskCore.vue +16 -17
  36. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +46 -0
  37. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +79 -0
  38. package/app/components/{alert-mask → 01.atoms/alert-mask}/tests/AlertMaskCore.spec.ts +10 -10
  39. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +86 -0
  40. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +299 -0
  41. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +175 -0
  42. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +131 -0
  43. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +14 -0
  44. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +61 -0
  45. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +132 -0
  46. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +165 -0
  47. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +94 -0
  48. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +3 -0
  49. package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +44 -0
  50. package/app/components/{parallax → 01.atoms/animations/section-parallax}/SectionParallax.vue +11 -10
  51. package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +98 -0
  52. package/app/components/01.atoms/animations/section-parallax/tests/SectionParallax.spec.ts +102 -0
  53. package/app/components/01.atoms/animations/section-parallax/tests/__snapshots__/SectionParallax.spec.ts.snap +9 -0
  54. package/app/components/01.atoms/animations/view-timeline/CONSUMER-STYLING.md +70 -0
  55. package/app/components/01.atoms/animations/view-timeline/WipeAwayVertical.vue +181 -0
  56. package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +269 -0
  57. package/app/components/01.atoms/animations/view-timeline/tests/WipeAwayVertical.spec.ts +127 -0
  58. package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +1 -1
  59. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +1 -1
  60. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +42 -0
  61. package/app/components/{layout-grids/LayoutGridA.vue → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue} +23 -13
  62. package/app/components/{layout-grids/stories/LayoutGridA.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts} +151 -23
  63. package/app/components/{layout-grids/tests/LayoutGridA.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts} +25 -25
  64. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +41 -0
  65. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +133 -0
  66. package/app/components/{layout-grids/stories/LayoutGridB.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts} +24 -24
  67. package/app/components/{layout-grids/tests/LayoutGridB.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts} +23 -23
  68. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +39 -0
  69. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +141 -0
  70. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +148 -0
  71. package/app/components/01.atoms/ui-block-decorated/tests/UiBlockDecorated.spec.ts +80 -0
  72. package/app/components/01.atoms/ui-block-decorated/tests/__snapshots__/UiBlockDecorated.spec.ts.snap +3 -0
  73. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +6 -6
  74. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +34 -0
  75. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +102 -0
  76. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +115 -0
  77. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +86 -0
  78. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +46 -0
  79. package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/CarouselFlip.vue +63 -42
  80. package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/stories/CarouselFlip.stories.ts +96 -11
  81. package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/tests/CarouselFlip.spec.ts +80 -0
  82. package/app/components/05.forms/form-field/FormField.vue +1 -1
  83. package/app/components/05.forms/form-wrapper/FormWrapper.vue +1 -1
  84. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +45 -0
  85. package/app/components/05.forms/input-range/InputRangeCore.vue +79 -143
  86. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +126 -0
  87. package/app/components/05.forms/input-range/tests/InputRangeCore.spec.ts +124 -0
  88. package/app/components/05.forms/input-range/variants/InputRangeDefault.vue +9 -32
  89. package/app/components/05.forms/input-range/variants/stories/InputRangeDefault.stories.ts +108 -0
  90. package/app/components/05.forms/input-range/variants/tests/InputRangeDefault.spec.ts +140 -0
  91. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +135 -0
  92. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +16 -2
  93. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -4
  94. package/app/types/components/container-glow.d.ts +8 -0
  95. package/app/types/components/index.ts +1 -1
  96. package/package.json +3 -2
  97. package/app/components/05.forms/input-range-fancy/InputRangeFancyCore.vue +0 -426
  98. package/app/components/05.forms/input-range-fancy/InputRangeFancyWithLabel.vue +0 -94
  99. package/app/components/carousels/CarouselBasic.vue +0 -317
  100. package/app/components/carousels/CarouselInfinite.vue +0 -358
  101. package/app/components/container-glow/ContainerGlowCore.vue +0 -296
  102. package/app/components/container-glow/stories/ContainerGlowCore.stories.ts +0 -337
  103. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +0 -189
  104. package/app/components/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +0 -154
  105. package/app/components/glowing-border/GlowingBorder.vue +0 -142
  106. package/app/components/glowing-border/stories/GlowingBorder.stories.ts +0 -141
  107. package/app/components/layout-grids/LayoutGridB.vue +0 -134
  108. package/app/components/test-storybook/TestStorybook.vue +0 -49
  109. package/app/components/test-storybook/stories/TestStorybook.stories.ts +0 -28
  110. package/app/components/ui-block-decorated/UiBlockDecorated.vue +0 -167
  111. package/app/components/view-timeline/WipeAwayVertical.vue +0 -180
  112. 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.
@@ -5,10 +5,10 @@
5
5
  :class="[elementClasses]"
6
6
  :data-button-layout="buttonLayout"
7
7
  role="region"
8
- aria-label="Image carousel"
8
+ :aria-label="ariaLabel"
9
9
  >
10
10
  <div aria-live="polite" aria-atomic="true" class="sr-only">
11
- Item {{ currentActiveIndex + 1 }} of {{ itemCount }}
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="Carousel items"
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="`Jump to item ${index}`"
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
- type="button"
53
- class="btn-action btn-prev"
54
- aria-label="Go to previous item"
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="Go to next item" @click.prevent="actionNext()">
60
- <Icon name="ic:outline-keyboard-arrow-right" class="arrows-icon" />
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))`; // Good
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)))`; // Good
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 = carouselInitComplete.value && userHasInteracted.value;
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: 10px;
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: 10px;
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(--_carousel-display-max-width);
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(--_carousel-container-max-inline-size) + var(--_carousel-item-track-gap) -
470
- (2 * var(--_carousel-item-edge-preview-width))
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) + var(--_carousel-item-edge-preview-width)
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
- transition: transform v-bind(transitionSpeedStr) ease;
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(--_carousel-display-max-width);
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: 10px;
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: 1px solid light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%));
543
- border-radius: 100vw;
544
- padding: 8px;
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: 24px;
548
- height: 24px;
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: 10px;
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: "Components/UI/CarouselFlip",
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
- --_carousel-item-track-gap: 12px;
184
- --_carousel-container-max-inline-size: 800px;
185
- --_carousel-item-edge-preview-width: 12px;
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
- --_carousel-item-track-gap: 24px;
191
- --_carousel-item-edge-preview-width: 80px;
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
- --_carousel-item-track-gap: 12px;
431
- --_carousel-container-max-inline-size: 800px;
432
- --_carousel-item-edge-preview-width: 12px;
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
- --_carousel-item-track-gap: 24px;
438
- --_carousel-item-edge-preview-width: 80px;
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", () => {
@@ -18,7 +18,7 @@ interface Props {
18
18
  styleClassPassthrough?: string;
19
19
  }
20
20
 
21
- const props = withDefaults(defineProps<Props>(), {
21
+ withDefaults(defineProps<Props>(), {
22
22
  width: "narrow",
23
23
  fieldHasError: false,
24
24
  hasGutter: true,
@@ -9,7 +9,7 @@ interface Props {
9
9
  width?: "narrow" | "medium" | "wide";
10
10
  }
11
11
 
12
- const props = withDefaults(defineProps<Props>(), {
12
+ withDefaults(defineProps<Props>(), {
13
13
  width: "narrow",
14
14
  });
15
15
  </script>