srcdev-nuxt-components 9.6.13 → 9.6.14

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 (53) 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/storybook-add-story.md +5 -2
  10. package/.claude/skills/testing-add-playwright.md +12 -7
  11. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  12. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  13. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  14. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  15. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  16. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  17. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  18. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  19. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  20. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  21. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  22. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  23. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  24. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  25. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  26. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  27. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  28. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  29. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  30. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  31. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  32. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  33. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  34. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  35. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  36. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  37. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  39. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  40. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  41. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  42. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  43. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  44. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  45. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  46. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  47. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  48. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  49. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  50. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  51. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  52. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  53. package/package.json +1 -1
@@ -28,21 +28,25 @@
28
28
  :class="{ 'pop-over-popover-open': usesFallbackPopover && isOpen }"
29
29
  :style="positionStyle"
30
30
  :data-placement="popoverPlacement"
31
+ :role="popoverAriaLabel ? 'dialog' : undefined"
31
32
  :aria-label="popoverAriaLabel || undefined"
32
33
  @beforetoggle="handleBeforeToggle"
33
34
  @toggle="handleToggle"
34
35
  >
35
- <button
36
- ref="closeButtonRef"
36
+ <InputButton
37
+ type="button"
38
+ variant="inline"
37
39
  :popovertarget="popoverId"
38
40
  popovertargetaction="hide"
39
- type="button"
40
- class="pop-over-close-button"
41
+ :button-text="closeButtonAriaLabel"
41
42
  :aria-label="closeButtonAriaLabel"
43
+ style-class-passthrough="pop-over-close-button"
42
44
  @click="hide"
43
45
  >
44
- <Icon name="lucide:x" class="pop-over-close-button-icon" aria-hidden="true" />
45
- </button>
46
+ <template #iconOnly>
47
+ <Icon :name="closeIcon" class="pop-over-close-button-icon" aria-hidden="true" />
48
+ </template>
49
+ </InputButton>
46
50
  <div class="pop-over-content">
47
51
  <slot name="content"></slot>
48
52
  </div>
@@ -56,10 +60,12 @@ interface Props {
56
60
  placement?: "top" | "right" | "bottom" | "left";
57
61
  /** aria-label on the trigger button — set this when the trigger slot is icon-only. */
58
62
  triggerAriaLabel?: string;
59
- /** aria-label on the popover content region — set this when the content slot has no visible heading. */
63
+ /** Names the panel and gives it role="dialog"; set this when the content slot has no visible heading. */
60
64
  popoverAriaLabel?: string;
61
65
  /** aria-label on the close button — override for localisation. */
62
66
  closeButtonAriaLabel?: string;
67
+ /** Iconify name for the close button icon. */
68
+ closeIcon?: string;
63
69
  styleClassPassthrough?: string | string[];
64
70
  }
65
71
 
@@ -68,6 +74,7 @@ const props = withDefaults(defineProps<Props>(), {
68
74
  triggerAriaLabel: "",
69
75
  popoverAriaLabel: "",
70
76
  closeButtonAriaLabel: "Close",
77
+ closeIcon: "lucide:x",
71
78
  styleClassPassthrough: () => [],
72
79
  });
73
80
 
@@ -78,7 +85,6 @@ const anchorName = `--pop-over-anchor-${id}`;
78
85
  const rootRef = ref<HTMLDivElement | null>(null);
79
86
  const triggerRef = ref<HTMLButtonElement | null>(null);
80
87
  const popoverRef = ref<HTMLDivElement | null>(null);
81
- const closeButtonRef = ref<HTMLButtonElement | null>(null);
82
88
 
83
89
  // Focus moves to the close button on open; closing returns it to the trigger in every mode.
84
90
  const {
@@ -95,7 +101,7 @@ const {
95
101
  triggerRef,
96
102
  popoverRef,
97
103
  side: () => props.placement,
98
- onOpen: () => closeButtonRef.value?.focus(),
104
+ onOpen: () => popoverRef.value?.querySelector<HTMLElement>(".pop-over-close-button")?.focus(),
99
105
  });
100
106
 
101
107
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -113,11 +119,14 @@ watch(
113
119
  .pop-over {
114
120
  display: inline-block;
115
121
  position: relative;
122
+ max-inline-size: 100%;
116
123
 
117
124
  .pop-over-trigger {
118
125
  all: unset;
119
126
  cursor: pointer;
120
127
  anchor-name: var(--_anchor-name);
128
+ max-inline-size: 100%;
129
+ overflow-wrap: anywhere;
121
130
  outline: var(--pop-over-trigger-outline-width, 0.1rem) solid transparent;
122
131
 
123
132
  &:focus-visible {
@@ -141,7 +150,10 @@ watch(
141
150
  --pop-over-shadow,
142
151
  0 0.4rem 1.6rem rgba(0, 0, 0, 0.12)
143
152
  );
144
- width: var(--pop-over-width, 30rem);
153
+ width: min(var(--pop-over-width, 30rem), calc(100vw - 3.2rem));
154
+ max-block-size: var(--pop-over-max-block-size, calc(100dvh - 3.2rem));
155
+ overflow-y: auto;
156
+ overflow-wrap: anywhere;
145
157
 
146
158
  opacity: 0;
147
159
  transition:
@@ -169,8 +181,8 @@ watch(
169
181
  all: unset;
170
182
  cursor: pointer;
171
183
  position: absolute;
172
- top: var(--pop-over-close-button-offset, 0.8rem);
173
- right: var(--pop-over-close-button-offset, 0.8rem);
184
+ inset-block-start: var(--pop-over-close-button-offset, 0.8rem);
185
+ inset-inline-end: var(--pop-over-close-button-offset, 0.8rem);
174
186
  display: grid;
175
187
  place-items: center;
176
188
  width: var(--pop-over-close-button-size, 2.4rem);
@@ -186,6 +198,10 @@ watch(
186
198
  outline-offset: 0.1rem;
187
199
  }
188
200
 
201
+ .input-button-icon {
202
+ margin: 0;
203
+ }
204
+
189
205
  .pop-over-close-button-icon {
190
206
  width: var(--pop-over-close-button-icon-size, 1.4rem);
191
207
  height: var(--pop-over-close-button-icon-size, 1.4rem);
@@ -18,7 +18,7 @@ const meta: Meta<typeof PopOver> = {
18
18
  },
19
19
  popoverAriaLabel: {
20
20
  control: "text",
21
- description: "aria-label on the popover content region — set this when the content slot has no visible heading.",
21
+ description: "Names the popover panel and gives it role=\"dialog\"; set this when the content slot has no visible heading.",
22
22
  table: { category: "Accessibility" },
23
23
  },
24
24
  closeButtonAriaLabel: {
@@ -26,17 +26,23 @@ const meta: Meta<typeof PopOver> = {
26
26
  description: "aria-label on the close button — override for localisation.",
27
27
  table: { category: "Accessibility" },
28
28
  },
29
+ closeIcon: {
30
+ control: "text",
31
+ description: "Iconify name for the close button icon.",
32
+ table: { category: "Basic" },
33
+ },
29
34
  styleClassPassthrough: { table: { disable: true } },
30
35
  },
31
36
  args: {
32
37
  placement: "right",
33
38
  closeButtonAriaLabel: "Close",
39
+ closeIcon: "lucide:x",
34
40
  },
35
41
  parameters: {
36
42
  docs: {
37
43
  description: {
38
44
  component:
39
- "A generic anchor-positioned disclosure panel: fully consumer-supplied trigger and content slots, built on the native Popover API and CSS anchor-positioning. See DisplayTooltip for a fixed icon-trigger variant, or ActionMenu for one with menu semantics.",
45
+ "A generic anchor-positioned disclosure panel built on the native Popover API and CSS anchor positioning, with fallbacks for Safari 16-18. The `trigger` and `content` slots are entirely consumer-supplied; `placement` picks the side the panel opens on (it flips when there is no room), and the close button is labelled by `closeButtonAriaLabel` with its icon set by `closeIcon`. Set `triggerAriaLabel` for an icon-only trigger and `popoverAriaLabel` when the content has no visible heading. See DisplayTooltip for a fixed icon-trigger variant, or ActionMenu for one with menu semantics.",
40
46
  },
41
47
  },
42
48
  },
@@ -46,6 +52,14 @@ export default meta;
46
52
  type Story = StoryObj<typeof PopOver>;
47
53
 
48
54
  export const Default: Story = {
55
+ parameters: {
56
+ docs: {
57
+ description: {
58
+ story:
59
+ "Plain text trigger and a single paragraph of content. Use the Controls panel to change the placement and labels.",
60
+ },
61
+ },
62
+ },
49
63
  render: (args) => ({
50
64
  components: { PopOver },
51
65
  setup() {
@@ -67,6 +81,14 @@ export const Default: Story = {
67
81
  };
68
82
 
69
83
  export const Placements: Story = {
84
+ parameters: {
85
+ docs: {
86
+ description: {
87
+ story:
88
+ "One instance per `placement` value. Each panel flips to the opposite side when it would overflow the viewport.",
89
+ },
90
+ },
91
+ },
70
92
  render: (args) => ({
71
93
  components: { PopOver },
72
94
  setup() {
@@ -96,6 +118,14 @@ export const Placements: Story = {
96
118
  };
97
119
 
98
120
  export const IconTrigger: Story = {
121
+ parameters: {
122
+ docs: {
123
+ description: {
124
+ story:
125
+ "An icon-only trigger: `triggerAriaLabel` gives the button its accessible name, and `popoverAriaLabel` names the panel because the content has no visible heading.",
126
+ },
127
+ },
128
+ },
99
129
  render: (args) => ({
100
130
  components: { PopOver },
101
131
  setup() {
@@ -117,6 +147,14 @@ export const IconTrigger: Story = {
117
147
  };
118
148
 
119
149
  export const CustomColours: Story = {
150
+ parameters: {
151
+ docs: {
152
+ description: {
153
+ story:
154
+ "Overriding the public colour tokens with inline custom properties on the component.",
155
+ },
156
+ },
157
+ },
120
158
  render: (args) => ({
121
159
  components: { PopOver },
122
160
  setup() {
@@ -139,3 +177,77 @@ export const CustomColours: Story = {
139
177
  `,
140
178
  }),
141
179
  };
180
+
181
+ export const StressTest: Story = {
182
+ name: "Stress Test (Worst-Case Data)",
183
+ args: {
184
+ triggerAriaLabel: "Weitereinformationenzurlieferunganzeigen 😀",
185
+ popoverAriaLabel: "Lieferbedingungenundrückgaberichtlinien",
186
+ closeButtonAriaLabel: "Schließenundzurückzurbestellübersicht",
187
+ },
188
+ render: (args) => ({
189
+ components: { PopOver },
190
+ setup() {
191
+ return { args };
192
+ },
193
+ template: `
194
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.4rem;">
195
+ <p style="margin: 0;">Long unbroken trigger text and worst-case content inside an 18rem column.</p>
196
+ <div style="inline-size: 18rem; outline: 0.1rem dashed currentColor;">
197
+ <PopOver v-bind="args">
198
+ <template #trigger>
199
+ <span>😀 Lieferkostenundversandbedingungenfürallebestellungen</span>
200
+ </template>
201
+ <template #content>
202
+ <h3 style="margin-block-end: 0.8rem;">Kostenloselieferungfürallebestellungenübereinemmindestbestellwert</h3>
203
+ <p>https://example.invalid/a/very/long/unbroken/url/that/never/offers/a/break/opportunity/anywhere/at/all</p>
204
+ <p>&lt;b&gt;not bold&lt;/b&gt; Bestellungen über fünfzig Pfund qualifizieren sich für die kostenlose Standardlieferung innerhalb des Vereinigten Königreichs, ausgenommen sind Inseln und abgelegene Regionen.</p>
205
+ </template>
206
+ </PopOver>
207
+ </div>
208
+
209
+ <p style="margin: 0;">An oversized <code>--pop-over-width</code> (80rem): the panel stays inside the viewport.</p>
210
+ <PopOver placement="bottom" style="--pop-over-width: 80rem;">
211
+ <template #trigger><span>Very wide panel</span></template>
212
+ <template #content><p>The panel is capped at the viewport width minus 3.2rem.</p></template>
213
+ </PopOver>
214
+
215
+ <p style="margin: 0;">Very tall content: the panel caps at the viewport height and scrolls.</p>
216
+ <PopOver placement="bottom">
217
+ <template #trigger><span>Very tall panel</span></template>
218
+ <template #content>
219
+ <p v-for="n in 30" :key="n">Paragraph {{ n }} of thirty.</p>
220
+ </template>
221
+ </PopOver>
222
+
223
+ <p style="margin: 0;">Empty content slot, and single characters in the trigger and content.</p>
224
+ <div style="display: flex; gap: 2.4rem;">
225
+ <PopOver placement="bottom">
226
+ <template #trigger><span>Empty content</span></template>
227
+ </PopOver>
228
+ <PopOver placement="bottom" trigger-aria-label="X">
229
+ <template #trigger><span>X</span></template>
230
+ <template #content><p>Y</p></template>
231
+ </PopOver>
232
+ </div>
233
+
234
+ <p style="margin: 0;">Right-to-left content at the right edge: the panel flips left and the close button sits at the inline end.</p>
235
+ <div dir="rtl" style="display: flex; justify-content: flex-start;">
236
+ <PopOver placement="right" close-button-aria-label="إغلاق">
237
+ <template #trigger><span>التوصيل المجاني</span></template>
238
+ <template #content><p>الطلبات التي تزيد عن خمسين جنيهًا مؤهلة للتوصيل المجاني.</p></template>
239
+ </PopOver>
240
+ </div>
241
+ </div>
242
+ `,
243
+ }),
244
+ parameters: {
245
+ docs: {
246
+ story: { inline: false, height: "900px" },
247
+ description: {
248
+ story:
249
+ "Worst-case content. Open each popover and check: unbroken trigger text wraps inside the dashed column; unbroken heading, URL and German copy wrap inside the panel; HTML-like text renders as text; an oversized `--pop-over-width` never makes the panel wider than the viewport; very tall content scrolls inside a panel no taller than the viewport; an empty content slot leaves just the close button; right-to-left content puts the close button at the inline end and flips the panel when there is no room.",
250
+ },
251
+ },
252
+ },
253
+ };
@@ -104,6 +104,16 @@ describe("PopOver", () => {
104
104
  expect(wrapper.find(".pop-over-popover").attributes("aria-label")).toBe("Filter options");
105
105
  });
106
106
 
107
+ it("has no role without popoverAriaLabel", async () => {
108
+ wrapper = await createWrapper();
109
+ expect(wrapper.find(".pop-over-popover").attributes("role")).toBeUndefined();
110
+ });
111
+
112
+ it("has role='dialog' when popoverAriaLabel is given", async () => {
113
+ wrapper = await createWrapper({ popoverAriaLabel: "Filter options" });
114
+ expect(wrapper.find(".pop-over-popover").attributes("role")).toBe("dialog");
115
+ });
116
+
107
117
  it("sets data-placement on the root element", async () => {
108
118
  wrapper = await createWrapper({ placement: "top" });
109
119
  expect(wrapper.find(".pop-over").attributes("data-placement")).toBe("top");
@@ -133,6 +143,31 @@ describe("PopOver", () => {
133
143
  expect(closeButton.attributes("popovertargetaction")).toBe("hide");
134
144
  expect(closeButton.attributes("popovertarget")).toBe(vm.popoverId);
135
145
  });
146
+
147
+ it("is an inline InputButton with type='button'", async () => {
148
+ wrapper = await createWrapper();
149
+ const closeButton = wrapper.find(".pop-over-close-button");
150
+ expect(closeButton.classes()).toContain("input-button");
151
+ expect(closeButton.attributes("data-variant")).toBe("inline");
152
+ expect(closeButton.attributes("type")).toBe("button");
153
+ });
154
+
155
+ it("renders the close label as visually hidden button text", async () => {
156
+ wrapper = await createWrapper({ closeButtonAriaLabel: "Dismiss" });
157
+ const text = wrapper.find(".pop-over-close-button .input-button-text");
158
+ expect(text.text()).toBe("Dismiss");
159
+ expect(text.classes()).toContain("sr-only");
160
+ });
161
+
162
+ it("uses lucide:x as the default close icon", async () => {
163
+ wrapper = await createWrapper();
164
+ expect(wrapper.find(".pop-over-close-button-icon").classes().join(" ")).toContain("lucide:x");
165
+ });
166
+
167
+ it("uses a custom closeIcon", async () => {
168
+ wrapper = await createWrapper({ closeIcon: "mdi:close" });
169
+ expect(wrapper.find(".pop-over-close-button-icon").classes().join(" ")).toContain("mdi:close");
170
+ });
136
171
  });
137
172
 
138
173
  describe("handleToggle focus management", () => {
@@ -2,7 +2,11 @@
2
2
 
3
3
  exports[`PopOver > renders correct HTML structure 1`] = `
4
4
  "<div class="pop-over" style="--_anchor-name: --pop-over-anchor-v-0-0;" data-placement="right"><button popovertarget="pop-over-v-0-0" popovertargetaction="toggle" type="button" class="pop-over-trigger" aria-expanded="false"><span>Open</span></button>
5
- <div id="pop-over-v-0-0" popover="" class="pop-over-popover"><button popovertarget="pop-over-v-0-0" popovertargetaction="hide" type="button" class="pop-over-close-button" aria-label="Close"><span class="iconify i-lucide:x pop-over-close-button-icon" aria-hidden="true"></span></button>
5
+ <div id="pop-over-v-0-0" popover="" class="pop-over-popover"><button type="button" data-testid="input-button" data-theme="default" data-variant="inline" data-icon-only="" class="input-button pop-over-close-button" popovertarget="pop-over-v-0-0" popovertargetaction="hide" aria-label="Close">
6
+ <!--v-if-->
7
+ <!--v-if--><span class="input-button-text sr-only">Close</span>
8
+ <!--v-if--><span class="input-button-icon" data-slot="icon-only"><span class="iconify i-lucide:x pop-over-close-button-icon" aria-hidden="true"></span></span>
9
+ </button>
6
10
  <div class="pop-over-content">
7
11
  <p>Details</p>
8
12
  </div>
@@ -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>