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.
- package/.claude/rules/consumer/css-relative-colour-calc.md +15 -0
- package/.claude/rules/consumer/ids-from-useid.md +3 -2
- package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
- package/.claude/skills/component-compliance-checklist.md +11 -1
- package/.claude/skills/component-dynamic-slots.md +2 -2
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/accordion-chat.md +99 -0
- package/.claude/skills/components/accordion-group.md +98 -0
- package/.claude/skills/components/action-menu.md +11 -5
- package/.claude/skills/components/auto-grid.md +3 -1
- package/.claude/skills/components/capture-qr-code.md +9 -5
- package/.claude/skills/components/carousel-flip.md +41 -30
- package/.claude/skills/components/column-flow-grid.md +2 -0
- package/.claude/skills/components/dashboard-quad-grid.md +7 -2
- package/.claude/skills/components/dashboard-stats-grid.md +4 -0
- package/.claude/skills/components/decode-qr-code.md +7 -2
- package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
- package/.claude/skills/components/deep-expanding-menu.md +22 -21
- package/.claude/skills/components/expanding-panel-classic.md +9 -7
- package/.claude/skills/components/expanding-panel.md +9 -8
- package/.claude/skills/components/grid-stack.md +2 -0
- package/.claude/skills/components/layout-grid-by-cols.md +2 -0
- package/.claude/skills/components/masonry-grid.md +9 -1
- package/.claude/skills/components/page-row.md +3 -0
- package/.claude/skills/components/samaritan-prompt.md +24 -8
- package/.claude/skills/components/site-header.md +16 -2
- package/.claude/skills/components/sparkle-effect.md +79 -0
- package/.claude/skills/index.md +16 -14
- package/.claude/skills/storybook-add-story.md +33 -0
- package/.claude/skills/testing-add-unit-test.md +26 -0
- package/.claude/skills/using-component-skills.md +1 -1
- package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
- package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
- package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
- package/.vscode/srcdev-component-capture-decode-qr-code.code-snippets +11 -2
- package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
- package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
- package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
- package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
- package/.vscode/srcdev-component-site-header.code-snippets +13 -1
- package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
- package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
- package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
- package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
- package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
- package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
- package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
- package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
- package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
- package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
- package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
- package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
- package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
- package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
- package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
- package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
- package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
- package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
- package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
- package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
- package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
- package/app/components/01.atoms/page-row/PageRow.vue +16 -15
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
- package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
- package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
- package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
- package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
- package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
- package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
- package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue → accordion-group/AccordionGroup.vue} +32 -20
- package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
- package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
- package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
- package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
- package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
- package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
- package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
- package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
- package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
- package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
- package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
- package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
- package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
- package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
- package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
- package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
- package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
- package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
- package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
- package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
- package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
- package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
- package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
- package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
- package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
- package/app/types/components/qr-code.d.ts +12 -0
- package/app/utils/graphemes.ts +8 -0
- package/package.json +1 -1
- package/.claude/skills/components/accordian-core.md +0 -180
- package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
- package/app/components/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
- package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
- package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
- package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
- package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
- package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
- package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
- package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
- package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
- package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
- package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
- package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
- package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
- package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
- package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
- package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
- package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
- package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
- package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
- package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
- package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -95
- package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
- package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
- package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
- package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
44
|
-
const links = wrapper.findAll(".
|
|
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
|
|
50
|
-
expect(wrapper.find("details.
|
|
51
|
-
expect(wrapper.find(".
|
|
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
|
|
56
|
-
const childLinks = wrapper.findAll(".
|
|
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
|
|
62
|
-
expect(wrapper.find(".
|
|
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("
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
|
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="
|
|
6
|
-
<details class="
|
|
7
|
-
<summary class="
|
|
8
|
-
<div class="
|
|
9
|
-
<h4 class="page-heading-4 mb-6">Our services</h4>
|
|
10
|
-
<ul class="
|
|
11
|
-
<li class="
|
|
12
|
-
<li class="
|
|
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="
|
|
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` | `
|
|
24
|
-
| `--decode-qr-code-dropzone-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` | `
|
|
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): `--
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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 =
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
(
|
|
114
|
-
if (!
|
|
115
|
-
nextTick(() => stopAllMediaStreams());
|
|
116
|
-
}
|
|
107
|
+
(cameraOn) => {
|
|
108
|
+
if (!cameraOn) nextTick(stopMediaStreams);
|
|
117
109
|
}
|
|
118
110
|
);
|
|
119
111
|
|
|
120
|
-
onBeforeUnmount(
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
:
|
|
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
|
-
--
|
|
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,
|
|
96
|
+
border: var(--decode-qr-code-dropzone-border-width, 0.2rem) dashed var(--_decode-qr-code-dropzone-border-colour);
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
border-color: var(
|
|
82
|
-
|
|
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>
|