srcdev-nuxt-components 9.6.13 → 9.6.15

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 (74) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/components/sliding-toggle.md +108 -0
  10. package/.claude/skills/components/tabbed-content.md +8 -3
  11. package/.claude/skills/composable-sliding-indicator.md +60 -0
  12. package/.claude/skills/index.md +2 -0
  13. package/.claude/skills/storybook-add-story.md +22 -2
  14. package/.claude/skills/testing-add-playwright.md +12 -7
  15. package/.claude/skills/testing-add-unit-test.md +25 -0
  16. package/.claude/skills/theming-component-token-pattern.md +18 -0
  17. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  18. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  19. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  20. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  21. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  22. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  24. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  25. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  26. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  27. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  28. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  29. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  30. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  31. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  32. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  33. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  34. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  35. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  36. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  37. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  41. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  42. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  43. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  44. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  45. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  46. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  47. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  48. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  49. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  50. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  51. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  52. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  53. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  54. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  55. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  56. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  57. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  58. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  59. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  60. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  61. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  62. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  63. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  64. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  65. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  66. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  67. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  68. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  69. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  70. package/app/composables/useSlidingIndicator.ts +56 -0
  71. package/app/composables/useTabs.ts +4 -31
  72. package/app/types/components/index.ts +1 -0
  73. package/app/types/components/sliding-toggle.d.ts +5 -0
  74. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
 
5
5
  | Token | Default | Controls |
6
6
  |---|---|---|
7
- | `--display-qr-code-size` | the `size` prop (`256px`) | Width of the code; height follows via `aspect-ratio: 1 / 1` |
7
+ | `--display-qr-code-size` | the `size` prop (`256px`) | Width of the code; height follows via `aspect-ratio: 1 / 1`. Capped at `max-inline-size: 100%`, so it never overflows its container |
8
8
 
9
9
  Module colours are props, not tokens: `black-color` (default `currentColor`) and `white-color`
10
10
  (default `transparent`). They're written into the SVG by `nuxt-qrcode`. Because the dark modules
@@ -20,7 +20,19 @@ No private `--_` tokens.
20
20
  ## State hooks
21
21
 
22
22
  The root is the `nuxt-qrcode` SVG with class `.display-qr-code`, `role="img"` and an
23
- `aria-label` (the `aria-label` prop, default `"QR code"`). There are no state classes.
23
+ `aria-label` (the `aria-label` prop, default `"QR code"`).
24
+
25
+ When the value is too long to encode, the root is instead a `<span>` with classes
26
+ `.display-qr-code.display-qr-code-fallback` and a `data-error` attribute, holding the `fallback`
27
+ slot. It keeps the same size and passthrough classes, centres its content, and has no role or
28
+ label (the slot content speaks for itself).
29
+
30
+ | Hook | Meaning |
31
+ |---|---|
32
+ | `.display-qr-code-fallback`, `[data-error]` | The value couldn't be encoded; the `fallback` slot is showing |
33
+
34
+ > Changed 2026-10-19: the fallback root is new (an over-capacity value used to throw during
35
+ > render), and the code is now capped at its container's width.
24
36
 
25
37
  ---
26
38
 
@@ -45,4 +57,5 @@ Set the tokens above on an element you own (a page or section class, or a class
45
57
 
46
58
  ## Class passthrough
47
59
 
48
- `style-class-passthrough` adds classes to the root SVG. Reactive after mount.
60
+ `style-class-passthrough` adds classes to the root SVG, or to the fallback `<span>` when the value
61
+ can't be encoded. Reactive after mount.
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <Qrcode
3
+ v-if="!hasEncodeError"
3
4
  :value="qrValue"
4
5
  class="display-qr-code"
5
6
  :variant
@@ -10,6 +11,9 @@
10
11
  :aria-label="ariaLabel"
11
12
  :class="[elementClasses]"
12
13
  />
14
+ <span v-else class="display-qr-code display-qr-code-fallback" :class="[elementClasses]" data-error>
15
+ <slot name="fallback"></slot>
16
+ </span>
13
17
  </template>
14
18
 
15
19
  <script setup lang="ts">
@@ -36,6 +40,26 @@ const props = withDefaults(defineProps<Props>(), {
36
40
  styleClassPassthrough: () => [],
37
41
  });
38
42
 
43
+ const emit = defineEmits<{
44
+ error: [error: Error];
45
+ }>();
46
+
47
+ // The encoder throws while rendering when a value exceeds QR capacity (about 2.9KB).
48
+ const hasEncodeError = ref(false);
49
+
50
+ onErrorCaptured((error) => {
51
+ hasEncodeError.value = true;
52
+ emit("error", error instanceof Error ? error : new Error(String(error)));
53
+ return false;
54
+ });
55
+
56
+ watch(
57
+ () => props.qrValue,
58
+ () => {
59
+ hasEncodeError.value = false;
60
+ }
61
+ );
62
+
39
63
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
40
64
 
41
65
  watch(
@@ -49,6 +73,13 @@ watch(
49
73
  .display-qr-code {
50
74
  aspect-ratio: 1 / 1;
51
75
  width: var(--display-qr-code-size, v-bind(size));
76
+ max-inline-size: 100%;
77
+
78
+ &.display-qr-code-fallback {
79
+ display: inline-grid;
80
+ place-items: center;
81
+ overflow-wrap: anywhere;
82
+ }
52
83
  }
53
84
  }
54
85
  </style>
@@ -1,214 +1,247 @@
1
- import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import DisplayQrCodeComponent from "../DisplayQrCode.vue";
1
+ import { computed } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import DisplayQrCode from "../DisplayQrCode.vue";
3
4
  import type { QrCodeVariant } from "../../../../types/components";
4
5
 
5
- const meta: Meta<typeof DisplayQrCodeComponent> = {
6
+ type SvgVariant = QrCodeVariant["pixel"];
7
+
8
+ interface StoryArgs {
9
+ qrValue: string;
10
+ size: string;
11
+ radius: number;
12
+ blackColor: string;
13
+ whiteColor: string;
14
+ ariaLabel: string;
15
+ variantInner: SvgVariant;
16
+ variantMarker: SvgVariant;
17
+ variantPixel: SvgVariant;
18
+ }
19
+
20
+ const variantOptions: SvgVariant[] = ["default", "circle", "rounded", "dots", "pixelated"];
21
+
22
+ const variantArgType = (part: string) => ({
23
+ control: { type: "select" as const },
24
+ options: variantOptions,
25
+ description: `Story-only: sets \`variant.${part}\` on the \`variant\` prop.`,
26
+ table: { category: "Variant (story-only)" },
27
+ });
28
+
29
+ const meta: Meta<StoryArgs> = {
6
30
  title: "Atoms/QR Code/DisplayQrCode",
7
- component: DisplayQrCodeComponent,
31
+ component: DisplayQrCode,
8
32
  argTypes: {
9
33
  qrValue: {
10
34
  control: { type: "text" },
11
- description: "The value/content to encode in the QR code",
35
+ description: "The value to encode. Over about 2.9KB it can't be encoded, and the `fallback` slot renders instead.",
12
36
  table: { category: "Content" },
13
37
  },
14
38
  size: {
15
39
  control: { type: "text" },
16
- description: "Size of the QR code (CSS value like '256px', '10rem')",
40
+ description: "Width of the code as any CSS length; `--display-qr-code-size` overrides it. Never wider than its container.",
17
41
  table: { category: "Appearance" },
18
42
  },
19
43
  radius: {
20
- control: { type: "range", min: 0, max: 20, step: 1 },
21
- description: "Border radius of the QR code corners",
44
+ control: { type: "range", min: 0, max: 1, step: 0.05 },
45
+ description: "Corner rounding for the rounded, circle and dots shapes, from 0 to 1 (the library clamps anything outside).",
22
46
  table: { category: "Appearance" },
23
47
  },
24
48
  blackColor: {
25
49
  control: { type: "color" },
26
- description: "Color of the QR code foreground elements",
50
+ description: "Colour of the dark modules. Defaults to `currentColor`.",
27
51
  table: { category: "Appearance" },
28
52
  },
29
53
  whiteColor: {
30
54
  control: { type: "color" },
31
- description: "Color of the QR code background",
55
+ description: "Colour of the light modules. Defaults to `transparent`.",
32
56
  table: { category: "Appearance" },
33
57
  },
34
58
  ariaLabel: {
35
59
  control: { type: "text" },
36
- description: "Accessible name for the QR image (override for a more specific or translated label)",
60
+ description: "Accessible name for the QR image (override with a more specific or translated label)",
37
61
  table: { category: "Accessibility" },
38
62
  },
39
- variant: { table: { disable: true } },
40
- styleClassPassthrough: { table: { disable: true } },
63
+ variantInner: variantArgType("inner"),
64
+ variantMarker: variantArgType("marker"),
65
+ variantPixel: variantArgType("pixel"),
41
66
  },
42
67
  args: {
43
- qrValue: "https://github.com/srcdev/nuxt-components",
68
+ qrValue: "https://example.com",
44
69
  size: "256px",
45
70
  radius: 0,
46
71
  blackColor: "#000000",
47
72
  whiteColor: "transparent",
48
73
  ariaLabel: "QR code",
49
- styleClassPassthrough: [],
74
+ variantInner: "default",
75
+ variantMarker: "default",
76
+ variantPixel: "default",
77
+ },
78
+ parameters: {
79
+ docs: {
80
+ description: {
81
+ component:
82
+ "Renders a value as an SVG QR code with `role=\"img\"` and an `ariaLabel`. `size`, `radius`, `blackColor`, `whiteColor` and `variant` (separate shapes for the inner markers, outer markers and pixels) style it. A value too long to encode renders the `fallback` slot and emits `error` instead of breaking the page.",
83
+ },
84
+ },
50
85
  },
51
86
  };
52
87
 
53
88
  export default meta;
89
+ type Story = StoryObj<StoryArgs>;
90
+
91
+ const storySetup = (args: StoryArgs) => {
92
+ const componentArgs = computed(() => {
93
+ const { variantInner, variantMarker, variantPixel, ...rest } = args;
94
+ return { ...rest, variant: { inner: variantInner, marker: variantMarker, pixel: variantPixel } };
95
+ });
96
+ return { componentArgs };
97
+ };
54
98
 
55
- interface QrStoryArgs {
56
- qrValue: string;
57
- size: string;
58
- radius: number;
59
- blackColor: string;
60
- whiteColor: string;
61
- ariaLabel: string;
62
- styleClassPassthrough: string[];
63
- }
64
-
65
- const DisplayTemplate: StoryFn<QrStoryArgs> = (args, { parameters }) => ({
66
- components: { DisplayQrCodeComponent },
67
- setup() {
68
- const variant: QrCodeVariant = parameters?.variant || {
69
- inner: "default",
70
- marker: "default",
71
- pixel: "default",
72
- };
73
- return { args, variant };
74
- },
99
+ const render: Story["render"] = (args) => ({
100
+ components: { DisplayQrCode },
101
+ setup: () => storySetup(args),
75
102
  template: `
76
- <div style="padding: 40px; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border-radius: 8px;">
77
- <DisplayQrCodeComponent
78
- :qr-value="args.qrValue"
79
- :variant="variant"
80
- :radius="args.radius"
81
- :black-color="args.blackColor"
82
- :white-color="args.whiteColor"
83
- :size="args.size"
84
- :aria-label="args.ariaLabel"
85
- :style-class-passthrough="args.styleClassPassthrough"
86
- />
103
+ <div style="padding: 4rem; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border-radius: 0.8rem;">
104
+ <DisplayQrCode v-bind="componentArgs" />
87
105
  </div>
88
106
  `,
89
107
  });
90
108
 
91
- export const Default = DisplayTemplate.bind({});
109
+ const story = (description: string, args: Partial<StoryArgs> = {}): Story => ({
110
+ render,
111
+ args,
112
+ parameters: { docs: { description: { story: description } } },
113
+ });
92
114
 
93
- export const Website = DisplayTemplate.bind({});
94
- Website.args = { qrValue: "https://nuxt.com", radius: 8, blackColor: "#000000" };
95
- Website.parameters = { variant: { inner: "circle", marker: "rounded", pixel: "dots" } };
115
+ export const Default = story("Default styling. Use the Controls panel to try every prop, including the three variant shapes.");
96
116
 
97
- export const VariantDefault = DisplayTemplate.bind({});
98
- VariantDefault.args = { qrValue: "Default variant style", radius: 4 };
99
- VariantDefault.parameters = { variant: { inner: "default", marker: "default", pixel: "default" } };
117
+ export const Website = story("A URL with circle inner markers, rounded outer markers and dot pixels.", {
118
+ qrValue: "https://nuxt.com",
119
+ radius: 0.5,
120
+ variantInner: "circle",
121
+ variantMarker: "rounded",
122
+ variantPixel: "dots",
123
+ });
100
124
 
101
- export const VariantCircle = DisplayTemplate.bind({});
102
- VariantCircle.args = { qrValue: "Circle variant style", radius: 8, blackColor: "#1e40af" };
103
- VariantCircle.parameters = { variant: { inner: "circle", marker: "circle", pixel: "circle" } };
125
+ export const VariantCircle = story("Every part in the circle shape.", {
126
+ qrValue: "Circle variant style",
127
+ radius: 0.5,
128
+ blackColor: "#1e40af",
129
+ variantInner: "circle",
130
+ variantMarker: "circle",
131
+ variantPixel: "circle",
132
+ });
104
133
 
105
- export const VariantRounded = DisplayTemplate.bind({});
106
- VariantRounded.args = { qrValue: "Rounded variant style", radius: 12, blackColor: "#059669" };
107
- VariantRounded.parameters = { variant: { inner: "rounded", marker: "rounded", pixel: "rounded" } };
134
+ export const VariantRounded = story("Every part in the rounded shape, at full radius.", {
135
+ qrValue: "Rounded variant style",
136
+ radius: 1,
137
+ blackColor: "#059669",
138
+ variantInner: "rounded",
139
+ variantMarker: "rounded",
140
+ variantPixel: "rounded",
141
+ });
108
142
 
109
- export const VariantDots = DisplayTemplate.bind({});
110
- VariantDots.args = { qrValue: "Dots variant style", radius: 6, blackColor: "#dc2626" };
111
- VariantDots.parameters = { variant: { inner: "dots", marker: "dots", pixel: "dots" } };
143
+ export const VariantDots = story("Every part in the dots shape.", {
144
+ qrValue: "Dots variant style",
145
+ radius: 0.5,
146
+ blackColor: "#dc2626",
147
+ variantInner: "dots",
148
+ variantMarker: "dots",
149
+ variantPixel: "dots",
150
+ });
112
151
 
113
- export const VariantMixed = DisplayTemplate.bind({});
114
- VariantMixed.args = { qrValue: "Mixed variants for creative styling", radius: 10, blackColor: "#7c3aed" };
115
- VariantMixed.parameters = { variant: { inner: "circle", marker: "rounded", pixel: "dots" } };
152
+ export const VariantPixelated = story("Every part in the pixelated shape.", {
153
+ qrValue: "Pixelated variant style",
154
+ blackColor: "#7c3aed",
155
+ variantInner: "pixelated",
156
+ variantMarker: "pixelated",
157
+ variantPixel: "pixelated",
158
+ });
116
159
 
117
- export const WiFiConnection = DisplayTemplate.bind({});
118
- WiFiConnection.args = { qrValue: "WIFI:T:WPA;S:MyHomeNetwork;P:mySecurePassword123;H:false;", blackColor: "#1e40af", radius: 4 };
119
- WiFiConnection.parameters = { variant: { inner: "rounded", marker: "circle", pixel: "default" } };
160
+ export const WiFiConnection = story("A Wi-Fi join string, which phone cameras offer to connect to.", {
161
+ qrValue: "WIFI:T:WPA;S:NorthtownGuest;P:example-password;H:false;",
162
+ blackColor: "#1e40af",
163
+ radius: 0.25,
164
+ variantInner: "rounded",
165
+ variantMarker: "circle",
166
+ });
120
167
 
121
- export const VCardContact = DisplayTemplate.bind({});
122
- VCardContact.args = {
123
- qrValue: "BEGIN:VCARD\\nVERSION:3.0\\nFN:John Doe\\nORG:Acme Corp\\nTITLE:Software Engineer\\nTEL:+1234567890\\nEMAIL:john.doe@acme.com\\nURL:https://johndoe.dev\\nEND:VCARD",
168
+ export const VCardContact = story("A vCard contact. Longer values produce a denser code.", {
169
+ qrValue:
170
+ "BEGIN:VCARD\nVERSION:3.0\nFN:Alex Morgan\nORG:Northtown Studio\nTEL:+441234567890\nEMAIL:alex@example.com\nURL:https://example.com\nEND:VCARD",
124
171
  blackColor: "#059669",
125
- radius: 12,
126
- };
127
- VCardContact.parameters = { variant: { inner: "dots", marker: "rounded", pixel: "circle" } };
128
-
129
- export const SmallSize = DisplayTemplate.bind({});
130
- SmallSize.args = { qrValue: "Small QR for tight spaces", size: "128px", radius: 2, blackColor: "#f59e0b" };
131
- SmallSize.parameters = { variant: { inner: "dots", marker: "circle", pixel: "rounded" } };
132
-
133
- export const LargeSize = DisplayTemplate.bind({});
134
- LargeSize.args = { qrValue: "Large QR for posters and signage", size: "400px", radius: 16, blackColor: "#0ea5e9" };
135
- LargeSize.parameters = { variant: { inner: "circle", marker: "rounded", pixel: "dots" } };
136
-
137
- export const HighContrast = DisplayTemplate.bind({});
138
- HighContrast.args = { qrValue: "High contrast for accessibility", blackColor: "#000000", whiteColor: "#ffffff", radius: 0, size: "256px" };
139
- HighContrast.parameters = { variant: { inner: "default", marker: "default", pixel: "default" } };
140
-
141
- const PlaygroundTemplate: StoryFn<QrStoryArgs> = (args) => ({
142
- components: { DisplayQrCodeComponent },
143
- setup() {
144
- const qrValue = ref(args.qrValue);
145
- const variant = ref<QrCodeVariant>({ inner: "circle", marker: "rounded", pixel: "dots" });
146
- const radius = ref(args.radius);
147
- const blackColor = ref(args.blackColor);
148
- const whiteColor = ref(args.whiteColor);
149
- const sizeValue = ref(256);
150
- const size = computed(() => `${sizeValue.value}px`);
151
- const presetValues = [
152
- { label: "Website URL", value: "https://nuxt.com" },
153
- { label: "WiFi Network", value: "WIFI:T:WPA;S:MyNetwork;P:password123;" },
154
- { label: "Phone Number", value: "tel:+1234567890" },
155
- { label: "Email", value: "mailto:hello@example.com" },
156
- { label: "SMS", value: "sms:+1234567890?body=Hello!" },
157
- { label: "Location", value: "geo:40.7128,-74.0060" },
158
- ];
159
- const variantOptions = ["default", "circle", "rounded", "dots"];
160
- return { qrValue, variant, radius, blackColor, whiteColor, sizeValue, size, presetValues, variantOptions };
161
- },
162
- template: `
163
- <div style="padding: 40px;">
164
- <div style="display: grid; grid-template-columns: 1fr 300px; gap: 40px; align-items: start;">
165
- <div style="display: flex; align-items: center; justify-content: center; min-height: 400px; background: #f8fafc; border-radius: 8px; padding: 40px;">
166
- <DisplayQrCodeComponent :qrValue="qrValue" :variant="variant" :radius="radius" :blackColor="blackColor" :whiteColor="whiteColor" :size="size" />
172
+ radius: 0.75,
173
+ variantInner: "dots",
174
+ variantMarker: "rounded",
175
+ variantPixel: "circle",
176
+ });
177
+
178
+ export const SmallSize = story("`size` at 128px.", { size: "128px" });
179
+
180
+ export const LargeSize = story("`size` at 400px. In a narrower container the code shrinks to fit.", {
181
+ size: "400px",
182
+ radius: 0.5,
183
+ blackColor: "#0ea5e9",
184
+ variantInner: "circle",
185
+ variantMarker: "rounded",
186
+ variantPixel: "dots",
187
+ });
188
+
189
+ export const HighContrast = story("Black on an opaque white background, the most reliable combination for scanning.", {
190
+ whiteColor: "#ffffff",
191
+ });
192
+
193
+ export const StressTest: Story = {
194
+ name: "Stress Test (Worst-Case Data)",
195
+ render: () => ({
196
+ components: { DisplayQrCode },
197
+ setup() {
198
+ const nearCapacity = "x".repeat(2900);
199
+ const overCapacity = "https://example.com/?q=" + "x".repeat(4000);
200
+ return { nearCapacity, overCapacity };
201
+ },
202
+ template: `
203
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.4rem;">
204
+ <p style="margin: 0;">An empty value, a single character, and emoji with right-to-left text: each still renders a code.</p>
205
+ <div style="display: flex; flex-wrap: wrap; gap: 2.4rem;">
206
+ <DisplayQrCode qr-value="" size="12rem"></DisplayQrCode>
207
+ <DisplayQrCode qr-value="a" size="12rem"></DisplayQrCode>
208
+ <DisplayQrCode qr-value="😀 التوصيل المجاني" size="12rem"></DisplayQrCode>
167
209
  </div>
168
- <div style="background: white; padding: 24px; border-radius: 8px; border: 1px solid #e5e7eb;">
169
- <h3 style="margin: 0 0 20px 0; color: #374151; font-size: 18px;">QR Code Playground</h3>
170
- <div style="margin-bottom: 20px;">
171
- <label style="display: block; margin-bottom: 8px; font-weight: 500; color: #374151;">Quick Presets:</label>
172
- <select @change="qrValue = $event.target.value" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;">
173
- <option value="">Select a preset...</option>
174
- <option v-for="preset in presetValues" :key="preset.label" :value="preset.value">{{ preset.label }}</option>
175
- </select>
176
- </div>
177
- <div style="margin-bottom: 20px;">
178
- <label style="display: block; margin-bottom: 8px; font-weight: 500; color: #374151;">Custom Content:</label>
179
- <textarea v-model="qrValue" rows="3" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; font-family: monospace; font-size: 12px;" placeholder="Enter QR code content..." />
180
- </div>
181
- <div style="margin-bottom: 20px;">
182
- <label style="display: block; margin-bottom: 8px; font-weight: 500; color: #374151;">Style Variants:</label>
183
- <div style="display: grid; gap: 8px;">
184
- <div><label style="font-size: 12px; color: #6b7280;">Inner:</label><select v-model="variant.inner" style="width: 100%; padding: 4px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px;"><option v-for="option in variantOptions" :key="option" :value="option">{{ option }}</option></select></div>
185
- <div><label style="font-size: 12px; color: #6b7280;">Marker:</label><select v-model="variant.marker" style="width: 100%; padding: 4px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px;"><option v-for="option in variantOptions" :key="option" :value="option">{{ option }}</option></select></div>
186
- <div><label style="font-size: 12px; color: #6b7280;">Pixel:</label><select v-model="variant.pixel" style="width: 100%; padding: 4px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px;"><option v-for="option in variantOptions" :key="option" :value="option">{{ option }}</option></select></div>
187
- </div>
188
- </div>
189
- <div style="margin-bottom: 20px;">
190
- <label style="display: block; margin-bottom: 8px; font-weight: 500; color: #374151;">Appearance:</label>
191
- <div style="display: grid; gap: 12px;">
192
- <div><label style="font-size: 12px; color: #6b7280;">Size: {{ sizeValue }}px</label><input v-model.number="sizeValue" type="range" min="128" max="400" step="16" style="width: 100%;" /></div>
193
- <div><label style="font-size: 12px; color: #6b7280;">Radius: {{ radius }}px</label><input v-model.number="radius" type="range" min="0" max="20" step="1" style="width: 100%;" /></div>
194
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
195
- <div><label style="font-size: 12px; color: #6b7280;">Foreground:</label><input v-model="blackColor" type="color" style="width: 100%; height: 32px; border: 1px solid #d1d5db; border-radius: 4px;" /></div>
196
- <div><label style="font-size: 12px; color: #6b7280;">Background:</label><input v-model="whiteColor" type="color" style="width: 100%; height: 32px; border: 1px solid #d1d5db; border-radius: 4px;" /></div>
197
- </div>
198
- </div>
199
- </div>
210
+
211
+ <p style="margin: 0;">2,900 characters, just under capacity: the densest code the encoder makes.</p>
212
+ <DisplayQrCode :qr-value="nearCapacity" size="32rem"></DisplayQrCode>
213
+
214
+ <p style="margin: 0;">Over capacity: the <code>fallback</code> slot renders in place of the code (left), or nothing (right) when the slot is empty.</p>
215
+ <div style="display: flex; flex-wrap: wrap; gap: 2.4rem; align-items: start;">
216
+ <DisplayQrCode :qr-value="overCapacity" size="16rem" style="outline: 0.1rem dashed currentColor;">
217
+ <template #fallback>
218
+ <span style="padding: 1.2rem; text-align: center;">Thislinkistoolongtoshowasaqrcodepleaseusethebuttonbelow</span>
219
+ </template>
220
+ </DisplayQrCode>
221
+ <DisplayQrCode :qr-value="overCapacity" size="16rem" style="outline: 0.1rem dashed currentColor;"></DisplayQrCode>
200
222
  </div>
201
- </div>
202
- </div>
203
- `,
204
- });
205
223
 
206
- export const InteractivePlayground = PlaygroundTemplate.bind({});
207
- InteractivePlayground.args = {
208
- qrValue: "https://github.com/srcdev/nuxt-components",
209
- radius: 8,
210
- blackColor: "#000000",
211
- whiteColor: "transparent",
212
- size: "256px",
213
- styleClassPassthrough: [],
224
+ <p style="margin: 0;">An 80rem <code>size</code> inside an 18rem column: the code shrinks to the column.</p>
225
+ <div style="inline-size: 18rem; outline: 0.1rem dashed currentColor;">
226
+ <DisplayQrCode qr-value="https://example.com" size="80rem"></DisplayQrCode>
227
+ </div>
228
+
229
+ <p style="margin: 0;">Out-of-range radius (-1 and 5): the library clamps it to 0 and 1.</p>
230
+ <div style="display: flex; flex-wrap: wrap; gap: 2.4rem;">
231
+ <DisplayQrCode qr-value="https://example.com" size="12rem" :radius="-1" :variant="{ inner: 'rounded', marker: 'rounded', pixel: 'rounded' }"></DisplayQrCode>
232
+ <DisplayQrCode qr-value="https://example.com" size="12rem" :radius="5" :variant="{ inner: 'rounded', marker: 'rounded', pixel: 'rounded' }"></DisplayQrCode>
233
+ </div>
234
+ </div>
235
+ `,
236
+ }),
237
+ parameters: {
238
+ controls: { disable: true },
239
+ docs: {
240
+ story: { inline: false, height: "1400px" },
241
+ description: {
242
+ story:
243
+ "Worst-case values. Check: empty, single-character, emoji and right-to-left values all render a code; the near-capacity code renders without errors; an over-capacity value shows the `fallback` slot (with its unbroken text wrapping inside the box) or an empty box, never an error; an oversized `size` shrinks to its container; an out-of-range radius renders as 0 and 1.",
244
+ },
245
+ },
246
+ },
214
247
  };
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { defineComponent, h, nextTick } from "vue";
2
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
4
  import DisplayQrCode from "../DisplayQrCode.vue";
4
5
  import type { QrCodeVariant } from "~/types/components";
@@ -152,3 +153,60 @@ describe("DisplayQrCode accessibility", () => {
152
153
  expect(wrapper.attributes("aria-label")).toBe("Scan to open our booking page");
153
154
  });
154
155
  });
156
+
157
+ // Stands in for nuxt-qrcode, whose encoder throws "Data too long" while rendering over-capacity values.
158
+ const QrcodeStub = defineComponent({
159
+ props: { value: { type: String, required: true } },
160
+ setup(stubProps) {
161
+ return () => {
162
+ if (stubProps.value.length > 2953) throw new Error("Data too long");
163
+ return h("svg");
164
+ };
165
+ },
166
+ });
167
+
168
+ const mountWithEncoder = (props: { qrValue: string; styleClassPassthrough?: string }, slots: Record<string, string> = {}) =>
169
+ mountSuspended(DisplayQrCode, { props, slots, global: { stubs: { Qrcode: QrcodeStub } } });
170
+
171
+ describe("DisplayQrCode worst-case values", () => {
172
+ const tooLong = "x".repeat(5000);
173
+
174
+ it("renders an empty value without throwing", async () => {
175
+ const wrapper = await mountWithEncoder({ qrValue: "" });
176
+ expect(wrapper.attributes("role")).toBe("img");
177
+ expect(wrapper.attributes("data-error")).toBeUndefined();
178
+ });
179
+
180
+ it("encodes emoji and right-to-left text", async () => {
181
+ const wrapper = await mountWithEncoder({ qrValue: "😀 التوصيل المجاني" });
182
+ expect(wrapper.attributes("role")).toBe("img");
183
+ });
184
+
185
+ it("renders the fallback instead of throwing when the value exceeds QR capacity", async () => {
186
+ const wrapper = await mountWithEncoder({ qrValue: tooLong, styleClassPassthrough: "extra" }, { fallback: '<span class="custom-fallback">Too long to encode</span>' });
187
+ await nextTick();
188
+ expect(wrapper.find(".display-qr-code-fallback").exists()).toBe(true);
189
+ expect(wrapper.attributes("data-error")).toBeDefined();
190
+ expect(wrapper.attributes("role")).toBeUndefined();
191
+ expect(wrapper.classes()).toContain("extra");
192
+ expect(wrapper.find(".custom-fallback").text()).toBe("Too long to encode");
193
+ });
194
+
195
+ it("emits error when the value exceeds QR capacity", async () => {
196
+ const wrapper = await mountWithEncoder({ qrValue: tooLong });
197
+ await nextTick();
198
+ const events = wrapper.emitted("error");
199
+ expect(events).toHaveLength(1);
200
+ expect(events?.[0]?.[0]).toBeInstanceOf(Error);
201
+ });
202
+
203
+ it("recovers when the value changes to one that fits", async () => {
204
+ const wrapper = await mountWithEncoder({ qrValue: tooLong });
205
+ await nextTick();
206
+ expect(wrapper.find(".display-qr-code-fallback").exists()).toBe(true);
207
+ await wrapper.setProps({ qrValue: "https://example.com" });
208
+ await nextTick();
209
+ expect(wrapper.find(".display-qr-code-fallback").exists()).toBe(false);
210
+ expect(wrapper.attributes("role")).toBe("img");
211
+ });
212
+ });
@@ -58,7 +58,7 @@ box.
58
58
  | `--tabs-underline-indicator-height` | `0.4rem` | Thickness of the underline (`axis="x"`), also its width on `axis="y"` |
59
59
  | `--tabs-hover-underline-indicator-colour` | `--tabs-underline-indicator-colour` | Colour of the thin underline/sideline that follows the hovered tab (`trackHover` and `trackIndicator`) |
60
60
  | `--tabs-hover-underline-indicator-height` | `0.1rem` | Its thickness; `0` hides it. Sits under the active underline where they overlap |
61
- | `--tabs-hover-indicator-stretch` | `1` | Stretch of the hover highlight and hover underline as they move: `1` stretches (leading edge first, trailing edge catches up), `0` slides both edges together. Values in between shorten the stretch |
61
+ | `--tabs-indicator-stretch` | `1` | Stretch of every indicator (hover, active and both underlines) as it moves: `1` stretches (leading edge first, trailing edge catches up), `0` slides both edges together. Values in between shorten the stretch |
62
62
 
63
63
  ### More menu (`overflowMode="menu"`, `axis="x"`)
64
64
 
@@ -100,7 +100,7 @@ insets from the bar's start and end along the axis), `--_{kind}-duration`,
100
100
  `--_{kind}-start-delay` / `--_{kind}-end-delay`; each indicator reads them through its own
101
101
  `--_start` / `--_end` / `--_duration` / `--_start-delay` / `--_end-delay`. Not public API; use the
102
102
  `transitionDuration` prop for indicator speed (one edge moves for that long, then the other), and
103
- `--tabs-hover-indicator-stretch` to turn the hover stretch off. The More menu also uses
103
+ `--tabs-indicator-stretch` to turn the stretch off. The More menu also uses
104
104
  `--_anchor-name` (a per-instance anchor name) and the `--_anchor-*` position values
105
105
  `useAnchoredPopover` writes for browsers without CSS anchor positioning.
106
106
 
@@ -169,6 +169,19 @@ before.
169
169
 
170
170
  ---
171
171
 
172
+ ## Indicator motion and reduced motion
173
+
174
+ The indicators slide with `useSlidingIndicator` (shared with `SlidingToggle`): the leading edge
175
+ moves first and the trailing edge catches up. Under `prefers-reduced-motion` they snap.
176
+
177
+ > **Changed 2026-10-19**: the indicators used to slide even under `prefers-reduced-motion`.
178
+ >
179
+ > **Changed 2026-10-19**: `--tabs-hover-indicator-stretch` (hover only) is now
180
+ > `--tabs-indicator-stretch` and covers the active indicator and underline too. Rename it in your
181
+ > CSS; `0` now turns the stretch off for every indicator.
182
+
183
+ ---
184
+
172
185
  ## Indicator visibility is prop-driven
173
186
 
174
187
  Whether each indicator renders is set by `trackHover` / `trackActive` / `trackIndicator`, not
@@ -354,8 +354,8 @@ watch(
354
354
  --_start: var(--_hovered-start, 0px);
355
355
  --_end: var(--_hovered-end, 100%);
356
356
  --_duration: var(--_hovered-duration, 0ms);
357
- --_start-delay: calc(var(--_hovered-start-delay, 0ms) * var(--tabs-hover-indicator-stretch, 1));
358
- --_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-hover-indicator-stretch, 1));
357
+ --_start-delay: calc(var(--_hovered-start-delay, 0ms) * var(--tabs-indicator-stretch, 1));
358
+ --_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-indicator-stretch, 1));
359
359
  }
360
360
 
361
361
  .tabbed-content-indicator-active,
@@ -363,8 +363,8 @@ watch(
363
363
  --_start: var(--_active-start, 0px);
364
364
  --_end: var(--_active-end, 100%);
365
365
  --_duration: var(--_active-duration, 0ms);
366
- --_start-delay: var(--_active-start-delay, 0ms);
367
- --_end-delay: var(--_active-end-delay, 0ms);
366
+ --_start-delay: calc(var(--_active-start-delay, 0ms) * var(--tabs-indicator-stretch, 1));
367
+ --_end-delay: calc(var(--_active-end-delay, 0ms) * var(--tabs-indicator-stretch, 1));
368
368
  }
369
369
 
370
370
  .tabbed-content-indicator-hover,