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.
- package/.claude/rules/consumer/story-register-components.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +5 -0
- package/.claude/skills/components/display-qr-code.md +22 -4
- package/.claude/skills/components/eyebrow-text.md +9 -5
- package/.claude/skills/components/header-block.md +13 -5
- package/.claude/skills/components/hero-text.md +13 -9
- package/.claude/skills/components/link-text.md +8 -5
- package/.claude/skills/components/pop-over.md +17 -3
- package/.claude/skills/components/sliding-toggle.md +108 -0
- package/.claude/skills/components/tabbed-content.md +8 -3
- package/.claude/skills/composable-sliding-indicator.md +60 -0
- package/.claude/skills/index.md +2 -0
- package/.claude/skills/storybook-add-story.md +22 -2
- package/.claude/skills/testing-add-playwright.md +12 -7
- package/.claude/skills/testing-add-unit-test.md +25 -0
- package/.claude/skills/theming-component-token-pattern.md +18 -0
- package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
- package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
- package/.vscode/srcdev-component-link-text.code-snippets +3 -3
- package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
- package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
- package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
- package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
- package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
- package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
- package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
- package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
- package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
- package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
- package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
- package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
- package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
- package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
- package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
- package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
- package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
- package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
- package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
- package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
- package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
- package/app/composables/useSlidingIndicator.ts +56 -0
- package/app/composables/useTabs.ts +4 -31
- package/app/types/components/index.ts +1 -0
- package/app/types/components/sliding-toggle.d.ts +5 -0
- 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"`).
|
|
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
|
|
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
|
|
2
|
-
import
|
|
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
|
-
|
|
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:
|
|
31
|
+
component: DisplayQrCode,
|
|
8
32
|
argTypes: {
|
|
9
33
|
qrValue: {
|
|
10
34
|
control: { type: "text" },
|
|
11
|
-
description: "The value
|
|
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: "
|
|
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:
|
|
21
|
-
description: "
|
|
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: "
|
|
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: "
|
|
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
|
|
60
|
+
description: "Accessible name for the QR image (override with a more specific or translated label)",
|
|
37
61
|
table: { category: "Accessibility" },
|
|
38
62
|
},
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
variantInner: variantArgType("inner"),
|
|
64
|
+
variantMarker: variantArgType("marker"),
|
|
65
|
+
variantPixel: variantArgType("pixel"),
|
|
41
66
|
},
|
|
42
67
|
args: {
|
|
43
|
-
qrValue: "https://
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
77
|
-
<
|
|
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
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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 =
|
|
102
|
-
|
|
103
|
-
|
|
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 =
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
SmallSize
|
|
132
|
-
|
|
133
|
-
export const LargeSize =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
358
|
-
--_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-
|
|
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,
|