srcdev-nuxt-components 9.6.18 → 9.6.20

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 (187) hide show
  1. package/.claude/rules/consumer/css-relative-colour-calc.md +15 -0
  2. package/.claude/rules/consumer/ids-from-useid.md +3 -2
  3. package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
  4. package/.claude/skills/component-compliance-checklist.md +11 -1
  5. package/.claude/skills/component-dynamic-slots.md +2 -2
  6. package/.claude/skills/component-naming.md +1 -1
  7. package/.claude/skills/components/accordion-chat.md +99 -0
  8. package/.claude/skills/components/accordion-group.md +98 -0
  9. package/.claude/skills/components/action-menu.md +11 -5
  10. package/.claude/skills/components/auto-grid.md +3 -1
  11. package/.claude/skills/components/capture-qr-code.md +9 -5
  12. package/.claude/skills/components/carousel-flip.md +41 -30
  13. package/.claude/skills/components/column-flow-grid.md +2 -0
  14. package/.claude/skills/components/dashboard-quad-grid.md +7 -2
  15. package/.claude/skills/components/dashboard-stats-grid.md +4 -0
  16. package/.claude/skills/components/decode-qr-code.md +7 -2
  17. package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
  18. package/.claude/skills/components/deep-expanding-menu.md +22 -21
  19. package/.claude/skills/components/expanding-panel-classic.md +9 -7
  20. package/.claude/skills/components/expanding-panel.md +9 -8
  21. package/.claude/skills/components/grid-stack.md +2 -0
  22. package/.claude/skills/components/layout-grid-by-cols.md +2 -0
  23. package/.claude/skills/components/masonry-grid.md +9 -1
  24. package/.claude/skills/components/page-row.md +3 -0
  25. package/.claude/skills/components/samaritan-prompt.md +24 -8
  26. package/.claude/skills/components/site-header.md +16 -2
  27. package/.claude/skills/components/sparkle-effect.md +79 -0
  28. package/.claude/skills/index.md +16 -14
  29. package/.claude/skills/storybook-add-story.md +33 -0
  30. package/.claude/skills/testing-add-unit-test.md +26 -0
  31. package/.claude/skills/using-component-skills.md +1 -1
  32. package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
  33. package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
  34. package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
  35. package/.vscode/srcdev-component-capture-decode-qr-code.code-snippets +11 -2
  36. package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
  37. package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
  38. package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
  39. package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
  40. package/.vscode/srcdev-component-site-header.code-snippets +13 -1
  41. package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
  42. package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
  43. package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
  44. package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
  45. package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
  46. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
  47. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
  48. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
  49. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
  50. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
  51. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
  52. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
  53. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
  54. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
  55. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
  56. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
  57. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
  58. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
  59. package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
  60. package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
  61. package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
  62. package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
  63. package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  64. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
  65. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
  66. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
  67. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
  68. package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
  69. package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
  70. package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
  71. package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
  72. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
  73. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
  74. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
  75. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
  76. package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
  77. package/app/components/01.atoms/page-row/PageRow.vue +16 -15
  78. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
  79. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
  80. package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
  81. package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
  82. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
  83. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
  84. package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
  85. package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
  86. package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
  87. package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
  88. package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
  89. package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue → accordion-group/AccordionGroup.vue} +32 -20
  90. package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
  91. package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
  92. package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
  93. package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
  94. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
  95. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
  96. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
  97. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
  98. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
  99. package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
  100. package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
  101. package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
  102. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
  103. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
  104. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
  105. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
  106. package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
  107. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
  108. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
  109. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
  110. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
  111. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
  112. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
  113. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
  114. package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
  115. package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
  116. package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
  117. package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
  118. package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
  119. package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
  120. package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
  121. package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
  122. package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
  123. package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
  124. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
  125. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
  126. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
  127. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
  128. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
  129. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
  130. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
  131. package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
  132. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
  133. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
  134. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
  135. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
  136. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
  137. package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
  138. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
  139. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
  140. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
  141. package/app/types/components/qr-code.d.ts +12 -0
  142. package/app/utils/graphemes.ts +8 -0
  143. package/package.json +1 -1
  144. package/.claude/skills/components/accordian-core.md +0 -180
  145. package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
  146. package/app/components/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
  147. package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
  148. package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
  149. package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
  150. package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
  151. package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
  152. package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
  153. package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
  154. package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
  155. package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
  156. package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
  157. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
  158. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
  159. package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
  160. package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
  161. package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
  162. package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
  163. package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
  164. package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
  165. package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  166. package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
  167. package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
  168. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
  169. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -95
  170. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
  171. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
  172. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
  173. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
  174. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
  175. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
  176. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
  177. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
  178. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
  179. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
  180. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
  181. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
  182. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
  183. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
  184. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
  185. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
  186. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  187. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, h, nextTick } from "vue";
3
4
  import DeepExpandingMenuClassic from "../DeepExpandingMenuClassic.vue";
4
5
  import type { ResponsiveHeaderNavItem } from "~/types/components";
5
6
 
@@ -16,67 +17,184 @@ const defaultNavLinks: ResponsiveHeaderNavItem[] = [
16
17
  { name: "Contact", path: "/contact" },
17
18
  ];
18
19
 
20
+ const twoGroups: ResponsiveHeaderNavItem[] = [
21
+ { name: "Services", childLinksTitle: "Our services", childLinks: [{ name: "Haircuts", path: "/haircuts" }] },
22
+ { name: "About", childLinksTitle: "About us", childLinks: [{ name: "Team", path: "/team" }] },
23
+ ];
24
+
25
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
26
+
27
+ const mountMenu = async (options: { props?: Record<string, unknown>; attachTo?: HTMLElement } = {}) => {
28
+ const wrapper = await mountSuspended(DeepExpandingMenuClassic, options);
29
+ mounted.push(wrapper);
30
+ return wrapper;
31
+ };
32
+
33
+ afterEach(() => {
34
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ const groups = (wrapper: Awaited<ReturnType<typeof mountMenu>>) =>
39
+ wrapper.findAll("details").map((d) => d.element as HTMLDetailsElement);
40
+
19
41
  describe("DeepExpandingMenuClassic", () => {
20
42
  it("mounts without error", async () => {
21
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
43
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
22
44
  expect(wrapper.vm).toBeTruthy();
23
45
  });
24
46
 
25
47
  it("renders correct HTML structure", async () => {
26
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
48
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
27
49
  expect(wrapper.html()).toMatchSnapshot();
28
50
  });
29
51
 
30
52
  it("renders a <nav> by default", async () => {
31
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
53
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
32
54
  expect(wrapper.element.tagName.toLowerCase()).toBe("nav");
33
55
  });
34
56
 
35
57
  it("renders the given tag", async () => {
36
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, {
37
- props: { navLinks: defaultNavLinks, tag: "div" },
38
- });
58
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks, tag: "div" } });
39
59
  expect(wrapper.element.tagName.toLowerCase()).toBe("div");
40
60
  });
41
61
 
62
+ it("labels the root when ariaLabel is set, and leaves it unlabelled otherwise", async () => {
63
+ const unlabelled = await mountMenu({ props: { navLinks: defaultNavLinks } });
64
+ expect(unlabelled.attributes("aria-label")).toBeUndefined();
65
+
66
+ const labelled = await mountMenu({ props: { navLinks: defaultNavLinks, ariaLabel: "Main" } });
67
+ expect(labelled.attributes("aria-label")).toBe("Main");
68
+ });
69
+
42
70
  it("renders a direct link for items with a path", async () => {
43
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
44
- const links = wrapper.findAll(".navigation-link");
71
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
72
+ const links = wrapper.findAll(".deep-expanding-menu-classic-link");
45
73
  expect(links.map((l) => l.text())).toEqual(["Home", "Contact"]);
46
74
  });
47
75
 
48
76
  it("renders a <details> group for items with childLinks", async () => {
49
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
50
- expect(wrapper.find("details.navigation-group").exists()).toBe(true);
51
- expect(wrapper.find(".navigation-group-toggle").text()).toContain("Services");
77
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
78
+ expect(wrapper.find("details.deep-expanding-menu-classic-group").exists()).toBe(true);
79
+ expect(wrapper.find(".deep-expanding-menu-classic-group-toggle").text()).toContain("Services");
52
80
  });
53
81
 
54
82
  it("renders each child link inside the group panel", async () => {
55
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
56
- const childLinks = wrapper.findAll(".navigation-group-link");
83
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
84
+ const childLinks = wrapper.findAll(".deep-expanding-menu-classic-group-link");
57
85
  expect(childLinks.map((l) => l.text())).toEqual(["Haircuts", "Colouring"]);
58
86
  });
59
87
 
60
88
  it("renders the childLinksTitle heading", async () => {
61
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
62
- expect(wrapper.find(".navigation-group-panel h4").text()).toBe("Our services");
89
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
90
+ expect(wrapper.find(".deep-expanding-menu-classic-group-heading").text()).toBe("Our services");
63
91
  });
64
92
 
65
- it("applies styleClassPassthrough classes", async () => {
66
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, {
67
- props: { navLinks: defaultNavLinks, styleClassPassthrough: ["custom-class"] },
93
+ it("omits the heading when childLinksTitle is missing or empty", async () => {
94
+ const navLinks: ResponsiveHeaderNavItem[] = [
95
+ { name: "A", childLinks: [{ name: "A1", path: "/a1" }] },
96
+ { name: "B", childLinksTitle: "", childLinks: [{ name: "B1", path: "/b1" }] },
97
+ ];
98
+ const wrapper = await mountMenu({ props: { navLinks } });
99
+ expect(wrapper.find(".deep-expanding-menu-classic-group-heading").exists()).toBe(false);
100
+ });
101
+
102
+ it("omits the list when a group has no child links", async () => {
103
+ const navLinks: ResponsiveHeaderNavItem[] = [{ name: "Empty", childLinksTitle: "Empty", childLinks: [] }];
104
+ const wrapper = await mountMenu({ props: { navLinks } });
105
+ expect(wrapper.find(".deep-expanding-menu-classic-group-list").exists()).toBe(false);
106
+ });
107
+
108
+ it("gives the child link list a list role and hides the caret from assistive technology", async () => {
109
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
110
+ expect(wrapper.find(".deep-expanding-menu-classic-group-list").attributes("role")).toBe("list");
111
+ expect(wrapper.find(".deep-expanding-menu-classic-group-icon").attributes("aria-hidden")).toBe("true");
112
+ });
113
+
114
+ it("keeps child links with duplicate names in step when the list is reordered", async () => {
115
+ const childLinks = [
116
+ { name: "Same", path: "/one" },
117
+ { name: "Same", path: "/two" },
118
+ { name: "Other", path: "/three" },
119
+ ];
120
+ const wrapper = await mountMenu({ props: { navLinks: [{ name: "Group", childLinks }] } });
121
+ await wrapper.setProps({ navLinks: [{ name: "Group", childLinks: [childLinks[2]!, childLinks[0]!] }] });
122
+ const links = wrapper.findAll(".deep-expanding-menu-classic-group-link");
123
+ expect(links.map((l) => l.attributes("href"))).toEqual(["/three", "/one"]);
124
+ });
125
+
126
+ it("shares a details name within one menu but not across menus", async () => {
127
+ const Host = defineComponent({
128
+ setup: () => () => [
129
+ h(DeepExpandingMenuClassic, { navLinks: twoGroups }),
130
+ h(DeepExpandingMenuClassic, { navLinks: twoGroups }),
131
+ ],
68
132
  });
133
+ const wrapper = await mountSuspended(Host);
134
+ mounted.push(wrapper);
135
+ const names = wrapper.findAll("details").map((d) => d.attributes("name"));
136
+ expect(names[0]).toBe(names[1]);
137
+ expect(names[2]).toBe(names[3]);
138
+ expect(names[0]).not.toBe(names[2]);
139
+ });
140
+
141
+ it("applies styleClassPassthrough classes", async () => {
142
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks, styleClassPassthrough: ["custom-class"] } });
69
143
  expect(wrapper.classes()).toContain("custom-class");
70
144
  });
71
145
 
72
146
  it("resets classes when styleClassPassthrough prop changes", async () => {
73
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, {
74
- props: { navLinks: defaultNavLinks, styleClassPassthrough: ["initial-class"] },
75
- });
147
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks, styleClassPassthrough: ["initial-class"] } });
76
148
  expect(wrapper.classes()).toContain("initial-class");
77
149
 
78
150
  await wrapper.setProps({ styleClassPassthrough: ["updated-class"] });
79
151
  expect(wrapper.classes()).not.toContain("initial-class");
80
152
  expect(wrapper.classes()).toContain("updated-class");
81
153
  });
154
+
155
+ describe("closing", () => {
156
+ it("closes an open group when something else in the menu is clicked", async () => {
157
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups } });
158
+ const [first] = groups(wrapper);
159
+ first!.open = true;
160
+
161
+ await wrapper.findAll("summary")[1]!.trigger("click");
162
+ expect(first!.open).toBe(false);
163
+ });
164
+
165
+ it("keeps a group open when its own panel is clicked, and closes it when a child link is followed", async () => {
166
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups } });
167
+ const [first] = groups(wrapper);
168
+ first!.open = true;
169
+
170
+ await wrapper.find(".deep-expanding-menu-classic-group-heading").trigger("click");
171
+ expect(first!.open).toBe(true);
172
+
173
+ await wrapper.find(".deep-expanding-menu-classic-group-link").trigger("click");
174
+ expect(first!.open).toBe(false);
175
+ });
176
+
177
+ it("closes the group on Escape and returns focus to its toggle", async () => {
178
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups }, attachTo: document.body });
179
+ const [first] = groups(wrapper);
180
+ first!.open = true;
181
+
182
+ const link = wrapper.find(".deep-expanding-menu-classic-group-link");
183
+ await link.trigger("keydown", { key: "Escape" });
184
+ expect(first!.open).toBe(false);
185
+ expect(document.activeElement).toBe(wrapper.findAll("summary")[0]!.element);
186
+ });
187
+
188
+ it("closes open groups on a click outside the menu", async () => {
189
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups }, attachTo: document.body });
190
+ const [first] = groups(wrapper);
191
+ first!.open = true;
192
+ vi.advanceTimersByTime(1);
193
+
194
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
195
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
196
+ await nextTick();
197
+ expect(first!.open).toBe(false);
198
+ });
199
+ });
82
200
  });
@@ -2,17 +2,17 @@
2
2
 
3
3
  exports[`DeepExpandingMenuClassic > renders correct HTML structure 1`] = `
4
4
  "<nav class="deep-expanding-menu-classic">
5
- <div class="inner"><a href="/" class="navigation-link">Home</a>
6
- <details class="navigation-group" name="navigation-group">
7
- <summary class="navigation-group-toggle"><span>Services</span><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></summary>
8
- <div class="navigation-group-panel">
9
- <h4 class="page-heading-4 mb-6">Our services</h4>
10
- <ul class="navigation-group-list">
11
- <li class="navigation-group-item"><a href="/services/haircuts" class="navigation-group-link">Haircuts</a></li>
12
- <li class="navigation-group-item"><a href="/services/colouring" class="navigation-group-link">Colouring</a></li>
5
+ <div class="deep-expanding-menu-classic-inner"><a href="/" class="deep-expanding-menu-classic-link">Home</a>
6
+ <details class="deep-expanding-menu-classic-group" name="deep-expanding-menu-classic-v-0-0">
7
+ <summary class="deep-expanding-menu-classic-group-toggle"><span class="deep-expanding-menu-classic-group-label">Services</span><span class="iconify i-bi:caret-down-fill deep-expanding-menu-classic-group-icon" aria-hidden="true"></span></summary>
8
+ <div class="deep-expanding-menu-classic-group-panel">
9
+ <h4 class="deep-expanding-menu-classic-group-heading page-heading-4 mb-6">Our services</h4>
10
+ <ul class="deep-expanding-menu-classic-group-list" role="list">
11
+ <li class="deep-expanding-menu-classic-group-item"><a href="/services/haircuts" class="deep-expanding-menu-classic-group-link">Haircuts</a></li>
12
+ <li class="deep-expanding-menu-classic-group-item"><a href="/services/colouring" class="deep-expanding-menu-classic-group-link">Colouring</a></li>
13
13
  </ul>
14
14
  </div>
15
- </details><a href="/contact" class="navigation-link">Contact</a>
15
+ </details><a href="/contact" class="deep-expanding-menu-classic-link">Contact</a>
16
16
  </div>
17
17
  </nav>"
18
18
  `;
@@ -8,6 +8,7 @@
8
8
  |---|---|---|
9
9
  | `--capture-qr-code-aspect-ratio` | `1 / 1` | Root aspect ratio (the camera frame) |
10
10
  | `--capture-qr-code-error-gap` | `1.2rem` | Gap between the error message and the reset button |
11
+ | `--capture-qr-code-results-indent` | `2rem` | Inline-start padding of the results list (bullet indent) |
11
12
 
12
13
  The reset button is `InputButton` (`variant="secondary"`), styled by its own tokens.
13
14
 
@@ -20,13 +21,19 @@ The reset button is `InputButton` (`variant="secondary"`), styled by its own tok
20
21
  | `--decode-qr-code-dropzone-min-height` | `3rem` | Drop zone minimum height |
21
22
  | `--decode-qr-code-dropzone-padding` | `1.2rem` | Drop zone padding |
22
23
  | `--decode-qr-code-dropzone-border-radius` | `0.5rem` | Drop zone corner rounding |
23
- | `--decode-qr-code-dropzone-border-width` | `2px` | Drop zone dashed border width |
24
- | `--decode-qr-code-dropzone-border-colour` | `gray` | Drop zone border colour |
24
+ | `--decode-qr-code-dropzone-border-width` | `0.2rem` | Drop zone dashed border width |
25
+ | `--decode-qr-code-dropzone-border-colour` | `var(--theme-border)` | Drop zone border colour |
25
26
  | `--decode-qr-code-dropzone-border-colour-active` | the border colour | Border colour while an image is dragged over |
26
- | `--decode-qr-code-dropzone-background-active` | `transparent` | Background while an image is dragged over |
27
+ | `--decode-qr-code-dropzone-background-active` | `var(--theme-surface-subtle)` | Background while an image is dragged over |
28
+ | `--decode-qr-code-results-indent` | `2rem` | Inline-start padding of the results list (bullet indent) |
27
29
 
28
- Private (not public API): `--_dropzone-border-colour`.
30
+ Private (not public API): `--_decode-qr-code-dropzone-border-colour`.
29
31
 
32
+ > Changed 2026-10-21: the drop zone border defaults to `var(--theme-border)` (was `gray`), its
33
+ > width to `0.2rem` (was `2px`), and the active background to `var(--theme-surface-subtle)` (was
34
+ > `transparent`, so dragging gave no feedback beyond the border). The private token was renamed
35
+ > from `--_dropzone-border-colour`.
36
+ >
30
37
  > Changed 2026-09-28: all of these were hardcoded or missing. DecodeQrCode's root is no longer
31
38
  > forced to `aspect-ratio: 1 / 1`.
32
39
 
@@ -38,14 +45,22 @@ Private (not public API): `--_dropzone-border-colour`.
38
45
  |---|---|---|
39
46
  | `.capture-qr-code-stopped` | Capture | Camera stopped (tab hidden, route leave), no error |
40
47
  | `.capture-qr-code-error` (`role="alert"`) | Capture | Camera error; replaceable via the `#error` slot |
41
- | `.decode-qr-code-dropzone.is-dropping` | Decode | An image is dragged over the drop zone |
48
+ | `.decode-qr-code-dropzone[data-dropping]` | Decode | An image is dragged over the drop zone |
49
+ | `.decode-qr-code-status` | Decode | No QR code found (`noResultLabel`), or with `[data-error]` the file couldn't be read (`readErrorLabel`) |
42
50
 
43
- Inner classes: Capture `.capture-qr-code-camera`, `.capture-qr-code-results`; Decode
51
+ Inner classes: Capture `.capture-qr-code-camera`, `.capture-qr-code-results`,
52
+ `.capture-qr-code-results-list`, `.capture-qr-code-result`, `.capture-qr-code-error-message`; Decode
44
53
  `.decode-qr-code-upload` (label), `.decode-qr-code-upload-label`, `.decode-qr-code-capture` (file
45
- input), `.decode-qr-code-dropzone`, `.decode-qr-code-dropzone-label`, `.decode-qr-code-results`.
54
+ input), `.decode-qr-code-dropzone`, `.decode-qr-code-dropzone-label`, `.decode-qr-code-results`,
55
+ `.decode-qr-code-results-list`, `.decode-qr-code-result`. Results, error messages and labels wrap
56
+ (`overflow-wrap: anywhere`), so a long decoded URL stays inside the component.
46
57
  Both results wrappers are polite live regions and always rendered; the `<ul>` inside appears once a
47
58
  code is decoded.
48
59
 
60
+ > Changed 2026-10-21: `.decode-qr-code-dropzone.is-dropping` is now `[data-dropping]` (a bare state
61
+ > class could collide with a consumer's own `.is-dropping`), and each result `<li>` lost its inner
62
+ > `<span>` and gained a class.
63
+ >
49
64
  > Changed 2026-09-28: renamed from `.capture-qr-stream` (root), `.camera-stopped`, `.camera-error`,
50
65
  > `.scanned-results`, `.qr-code-capture` and `.qr-code-dropzone`.
51
66
 
@@ -1,21 +1,19 @@
1
1
  <template>
2
- <div class="capture-qr-code" :class="[elementClasses]">
2
+ <div ref="rootRef" class="capture-qr-code" :class="[elementClasses]">
3
3
  <div v-if="!state.error" class="capture-qr-code-camera">
4
- <QrcodeStream v-if="state.cameraOn" ref="qrcodeStreamRef" @error="onError" @detect="onDetect" />
4
+ <QrcodeStream v-if="state.cameraOn" @error="onError" @detect="onDetect" />
5
5
  <div v-else class="capture-qr-code-stopped">
6
6
  <p>{{ cameraStoppedLabel }}</p>
7
7
  </div>
8
8
  <div class="capture-qr-code-results" aria-live="polite">
9
- <ul v-if="result?.length">
10
- <li v-for="(r, i) in result" :key="i">
11
- <span>{{ r }}</span>
12
- </li>
9
+ <ul v-if="result?.length" class="capture-qr-code-results-list">
10
+ <li v-for="(r, i) in result" :key="i" class="capture-qr-code-result">{{ r }}</li>
13
11
  </ul>
14
12
  </div>
15
13
  </div>
16
14
  <div v-else class="capture-qr-code-error" role="alert">
17
15
  <slot name="error" :error="state.errorName" :message="state.errorMsg" :reset="resetCamera">
18
- <p>{{ state.errorMsg }}</p>
16
+ <p class="capture-qr-code-error-message">{{ state.errorMsg }}</p>
19
17
  <InputButton variant="secondary" :button-text="resetCameraLabel" @click="resetCamera" />
20
18
  </slot>
21
19
  </div>
@@ -23,21 +21,37 @@
23
21
  </template>
24
22
 
25
23
  <script setup lang="ts">
24
+ import { useEventListener } from "@vueuse/core";
26
25
  import type { DetectedBarcode } from "nuxt-qrcode";
26
+ import type { CaptureQrCodeErrorMessages, CaptureQrCodeErrorName } from "~/types/components";
27
27
 
28
28
  interface Props {
29
29
  cameraStoppedLabel?: string;
30
30
  resetCameraLabel?: string;
31
+ errorMessages?: CaptureQrCodeErrorMessages;
31
32
  styleClassPassthrough?: string | string[];
32
33
  }
33
34
 
34
35
  const props = withDefaults(defineProps<Props>(), {
35
36
  cameraStoppedLabel: "Camera stopped",
36
37
  resetCameraLabel: "Reset camera",
38
+ errorMessages: () => ({}),
37
39
  styleClassPassthrough: () => [],
38
40
  });
39
41
 
40
- const qrcodeStreamRef = ref();
42
+ const defaultErrorMessages: Required<CaptureQrCodeErrorMessages> = {
43
+ NotAllowedError: "Camera access was blocked. Allow it in your browser settings, then try again.",
44
+ NotFoundError: "No camera was found on this device.",
45
+ NotSupportedError: "This page needs a secure (HTTPS) connection to use the camera.",
46
+ NotReadableError: "The camera is already in use by another app or tab.",
47
+ OverconstrainedError: "No suitable camera was found on this device.",
48
+ StreamApiNotSupportedError: "This browser can't use the camera for scanning.",
49
+ InsecureContextError: "This page needs a secure (HTTPS) connection to use the camera.",
50
+ StreamLoadTimeoutError: "The camera took too long to start. Try again.",
51
+ default: "The camera couldn't be started. Try again.",
52
+ };
53
+
54
+ const rootRef = ref<HTMLElement | null>(null);
41
55
  const result = ref<string[]>();
42
56
  const state = reactive({
43
57
  errorMsg: "",
@@ -46,34 +60,19 @@ const state = reactive({
46
60
  cameraOn: true,
47
61
  });
48
62
 
49
- onMounted(() => {
50
- state.cameraOn = true;
51
- state.error = false;
52
- state.errorMsg = "";
53
- result.value = [];
54
-
55
- const handleVisibilityChange = () => {
56
- if (document.hidden) {
57
- state.cameraOn = false;
58
- stopAllMediaStreams();
59
- }
60
- };
61
-
62
- document.addEventListener("visibilitychange", handleVisibilityChange);
63
-
64
- onBeforeUnmount(() => {
65
- document.removeEventListener("visibilitychange", handleVisibilityChange);
66
- });
67
- });
68
-
69
63
  function onDetect(detectedCodes: DetectedBarcode[]) {
70
64
  result.value = detectedCodes.map((code) => code.rawValue);
71
65
  }
72
66
 
73
67
  function onError(err: Error) {
68
+ const name = err.name as CaptureQrCodeErrorName;
74
69
  state.error = true;
75
70
  state.errorName = err.name;
76
- state.errorMsg = `[${err.name}]: ${err.message}`;
71
+ state.errorMsg =
72
+ props.errorMessages[name] ??
73
+ defaultErrorMessages[name] ??
74
+ props.errorMessages.default ??
75
+ defaultErrorMessages.default;
77
76
  }
78
77
 
79
78
  function resetCamera() {
@@ -81,51 +80,38 @@ function resetCamera() {
81
80
  state.cameraOn = true;
82
81
  }
83
82
 
84
- function stopAllMediaStreams() {
85
- if (qrcodeStreamRef.value) {
86
- try {
87
- const videoElement = qrcodeStreamRef.value.$el?.querySelector("video");
88
- if (videoElement && videoElement.srcObject) {
89
- const stream = videoElement.srcObject as MediaStream;
90
- stream.getTracks().forEach((track) => track.stop());
91
- videoElement.srcObject = null;
92
- }
93
- } catch (error) {
94
- console.warn("Error stopping camera stream:", error);
95
- }
96
- }
83
+ function stopMediaStreams() {
84
+ rootRef.value?.querySelectorAll("video").forEach((video) => {
85
+ if (!video.srcObject) return;
86
+ (video.srcObject as MediaStream).getTracks().forEach((track) => track.stop());
87
+ video.srcObject = null;
88
+ });
89
+ }
97
90
 
98
- try {
99
- document.querySelectorAll("video").forEach((video) => {
100
- if (video.srcObject) {
101
- const stream = video.srcObject as MediaStream;
102
- stream.getTracks().forEach((track) => track.stop());
103
- video.srcObject = null;
104
- }
105
- });
106
- } catch (error) {
107
- console.warn("Error in global video cleanup:", error);
108
- }
91
+ function stopCamera() {
92
+ state.cameraOn = false;
93
+ stopMediaStreams();
109
94
  }
110
95
 
96
+ useEventListener(
97
+ import.meta.client ? document : null,
98
+ "visibilitychange",
99
+ () => {
100
+ if (document.hidden) stopCamera();
101
+ else if (!state.error) state.cameraOn = true;
102
+ }
103
+ );
104
+
111
105
  watch(
112
106
  () => state.cameraOn,
113
- (newValue) => {
114
- if (!newValue) {
115
- nextTick(() => stopAllMediaStreams());
116
- }
107
+ (cameraOn) => {
108
+ if (!cameraOn) nextTick(stopMediaStreams);
117
109
  }
118
110
  );
119
111
 
120
- onBeforeUnmount(() => {
121
- state.cameraOn = false;
122
- stopAllMediaStreams();
123
- });
124
-
125
- onDeactivated(() => {
126
- state.cameraOn = false;
127
- stopAllMediaStreams();
128
- });
112
+ onBeforeUnmount(stopCamera);
113
+ onDeactivated(stopCamera);
114
+ onBeforeRouteLeave(stopCamera);
129
115
 
130
116
  onActivated(() => {
131
117
  state.cameraOn = true;
@@ -133,11 +119,6 @@ onActivated(() => {
133
119
  state.errorMsg = "";
134
120
  });
135
121
 
136
- onBeforeRouteLeave(() => {
137
- state.cameraOn = false;
138
- stopAllMediaStreams();
139
- });
140
-
141
122
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
142
123
 
143
124
  watch(
@@ -150,6 +131,17 @@ watch(
150
131
  @layer components {
151
132
  .capture-qr-code {
152
133
  aspect-ratio: var(--capture-qr-code-aspect-ratio, 1 / 1);
134
+ min-inline-size: 0;
135
+
136
+ .capture-qr-code-results-list {
137
+ margin: 0;
138
+ padding-inline-start: var(--capture-qr-code-results-indent, 2rem);
139
+ }
140
+
141
+ .capture-qr-code-result,
142
+ .capture-qr-code-error-message {
143
+ overflow-wrap: anywhere;
144
+ }
153
145
 
154
146
  .capture-qr-code-error {
155
147
  display: grid;
@@ -6,18 +6,19 @@
6
6
  </label>
7
7
  <QrcodeDropZone
8
8
  class="decode-qr-code-dropzone"
9
- :class="{ 'is-dropping': isDropping }"
9
+ :data-dropping="isDropping ? '' : undefined"
10
10
  @detect="onDetect"
11
11
  @dragover="onDropping"
12
+ @error="onDropError"
12
13
  >
13
14
  <p class="decode-qr-code-dropzone-label">{{ dropLabel }}</p>
14
15
  </QrcodeDropZone>
15
16
  <div class="decode-qr-code-results" aria-live="polite">
16
- <ul v-if="result?.length">
17
- <li v-for="(r, i) in result" :key="i">
18
- <span>{{ r }}</span>
19
- </li>
17
+ <ul v-if="result?.length" class="decode-qr-code-results-list">
18
+ <li v-for="(r, i) in result" :key="i" class="decode-qr-code-result">{{ r }}</li>
20
19
  </ul>
20
+ <p v-else-if="status === 'empty' && noResultLabel" class="decode-qr-code-status">{{ noResultLabel }}</p>
21
+ <p v-else-if="status === 'error' && readErrorLabel" class="decode-qr-code-status" data-error>{{ readErrorLabel }}</p>
21
22
  </div>
22
23
  </div>
23
24
  </template>
@@ -28,16 +29,21 @@ import type { DetectedBarcode } from "nuxt-qrcode";
28
29
  interface Props {
29
30
  uploadLabel?: string;
30
31
  dropLabel?: string;
32
+ noResultLabel?: string;
33
+ readErrorLabel?: string;
31
34
  styleClassPassthrough?: string | string[];
32
35
  }
33
36
 
34
37
  const props = withDefaults(defineProps<Props>(), {
35
38
  uploadLabel: "Upload a QR code image",
36
39
  dropLabel: "Or drop a QR code image here",
40
+ noResultLabel: "No QR code was found in that image.",
41
+ readErrorLabel: "That file couldn't be read as an image.",
37
42
  styleClassPassthrough: () => [],
38
43
  });
39
44
 
40
45
  const result = ref<string[]>();
46
+ const status = ref<"idle" | "found" | "empty" | "error">("idle");
41
47
  const isDropping = ref(false);
42
48
 
43
49
  function onDropping(dropping: boolean) {
@@ -46,6 +52,12 @@ function onDropping(dropping: boolean) {
46
52
 
47
53
  function onDetect(detectedCodes: DetectedBarcode[]) {
48
54
  result.value = detectedCodes.map((code) => code.rawValue);
55
+ status.value = result.value.length ? "found" : "empty";
56
+ }
57
+
58
+ function onDropError() {
59
+ result.value = [];
60
+ status.value = "error";
49
61
  }
50
62
 
51
63
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -61,31 +73,56 @@ watch(
61
73
  .decode-qr-code {
62
74
  display: grid;
63
75
  gap: var(--decode-qr-code-gap, 1.2rem);
76
+ min-inline-size: 0;
64
77
 
65
78
  .decode-qr-code-upload {
66
79
  display: grid;
67
80
  gap: var(--decode-qr-code-upload-gap, 0.6rem);
81
+ overflow-wrap: anywhere;
82
+
83
+ .decode-qr-code-capture {
84
+ max-inline-size: 100%;
85
+ }
68
86
  }
69
87
 
70
88
  .decode-qr-code-dropzone {
71
- --_dropzone-border-colour: var(--decode-qr-code-dropzone-border-colour, gray);
89
+ --_decode-qr-code-dropzone-border-colour: var(--decode-qr-code-dropzone-border-colour, var(--theme-border));
72
90
 
73
91
  display: grid;
74
92
  place-items: center;
75
93
  min-height: var(--decode-qr-code-dropzone-min-height, 3rem);
76
94
  padding: var(--decode-qr-code-dropzone-padding, 1.2rem);
77
95
  border-radius: var(--decode-qr-code-dropzone-border-radius, 0.5rem);
78
- border: var(--decode-qr-code-dropzone-border-width, 2px) dashed var(--_dropzone-border-colour);
96
+ border: var(--decode-qr-code-dropzone-border-width, 0.2rem) dashed var(--_decode-qr-code-dropzone-border-colour);
79
97
 
80
- &.is-dropping {
81
- border-color: var(--decode-qr-code-dropzone-border-colour-active, var(--_dropzone-border-colour));
82
- background-color: var(--decode-qr-code-dropzone-background-active, transparent);
98
+ &[data-dropping] {
99
+ border-color: var(
100
+ --decode-qr-code-dropzone-border-colour-active,
101
+ var(--_decode-qr-code-dropzone-border-colour)
102
+ );
103
+ background-color: var(--decode-qr-code-dropzone-background-active, var(--theme-surface-subtle));
83
104
  }
84
105
 
85
106
  .decode-qr-code-dropzone-label {
86
107
  margin: 0;
108
+ overflow-wrap: anywhere;
109
+ text-align: center;
87
110
  }
88
111
  }
112
+
113
+ .decode-qr-code-results-list {
114
+ margin: 0;
115
+ padding-inline-start: var(--decode-qr-code-results-indent, 2rem);
116
+ }
117
+
118
+ .decode-qr-code-result,
119
+ .decode-qr-code-status {
120
+ overflow-wrap: anywhere;
121
+ }
122
+
123
+ .decode-qr-code-status {
124
+ margin: 0;
125
+ }
89
126
  }
90
127
  }
91
128
  </style>