srcdev-nuxt-components 9.1.36 → 9.1.38

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 (32) hide show
  1. package/.claude/settings.json +2 -1
  2. package/.claude/settings.local.json +2 -2
  3. package/.claude/skills/components/capture-qr-code.md +84 -0
  4. package/.claude/skills/components/data-grid.md +167 -0
  5. package/.claude/skills/components/decode-qr-code.md +84 -0
  6. package/.claude/skills/components/display-qr-code.md +64 -0
  7. package/.claude/skills/index.md +5 -2
  8. package/app/components/01.atoms/grids/data-grid/DataGrid.vue +39 -0
  9. package/app/components/01.atoms/grids/data-grid/stories/DataGrid.stories.ts +234 -0
  10. package/app/components/01.atoms/grids/data-grid/tests/DataGrid.spec.ts +140 -0
  11. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +11 -0
  12. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +50 -0
  13. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +206 -0
  14. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +139 -0
  15. package/app/components/02.molecules/qr-code/CaptureQrCode.vue +142 -0
  16. package/app/components/{qr-code → 02.molecules/qr-code}/DecodeQrCode.vue +11 -35
  17. package/app/components/02.molecules/qr-code/stories/QrCode.stories.ts +101 -0
  18. package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +212 -0
  19. package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +145 -0
  20. package/app/layouts/default.vue +0 -1
  21. package/app/pages/ui/qr-code/[componentName].vue +3 -3
  22. package/app/pages/ui/simple-grid.vue +2 -2
  23. package/package.json +1 -1
  24. package/.claude/skills/components/scroll-parallax-section.md +0 -148
  25. package/app/components/01.atoms/scroll-parallax-section/ScrollParallaxSection.vue +0 -108
  26. package/app/components/01.atoms/scroll-parallax-section/stories/ScrollParallaxSection.stories.ts +0 -151
  27. package/app/components/01.atoms/scroll-parallax-section/tests/ScrollParallaxSection.spec.ts +0 -91
  28. package/app/components/display-grid/DisplayGridCore.vue +0 -22
  29. package/app/components/qr-code/CaptureQrCode.vue +0 -183
  30. package/app/components/qr-code/DisplayQrCode.vue +0 -53
  31. package/app/components/qr-code/stories/QrCode.stories.ts +0 -933
  32. package/app/pages/ui/scroll-parallax-section.vue +0 -65
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import DisplayQrCode from "../DisplayQrCode.vue";
4
+ import type { QrCodeVariant } from "~/types/components";
5
+
6
+ const defaultProps = {
7
+ qrValue: "https://example.com",
8
+ };
9
+
10
+ describe("DisplayQrCode", () => {
11
+ // ─── Mount ───────────────────────────────────────────────────────────────
12
+
13
+ it("mounts without error", async () => {
14
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
15
+ expect(wrapper.vm).toBeTruthy();
16
+ });
17
+
18
+ // ─── Root element ─────────────────────────────────────────────────────────
19
+
20
+ it("renders a single root element", async () => {
21
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
22
+ expect(wrapper.element).toBeTruthy();
23
+ });
24
+
25
+ it("always has the display-qr-code class", async () => {
26
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
27
+ expect(wrapper.classes()).toContain("display-qr-code");
28
+ });
29
+
30
+ // ─── Default props ────────────────────────────────────────────────────────
31
+
32
+ it("defaults radius to 0", async () => {
33
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
34
+ interface VM { radius: number }
35
+ expect((wrapper.vm as unknown as VM).radius).toBe(0);
36
+ });
37
+
38
+ it("defaults blackColor to currentColor", async () => {
39
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
40
+ interface VM { blackColor: string }
41
+ expect((wrapper.vm as unknown as VM).blackColor).toBe("currentColor");
42
+ });
43
+
44
+ it("defaults whiteColor to transparent", async () => {
45
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
46
+ interface VM { whiteColor: string }
47
+ expect((wrapper.vm as unknown as VM).whiteColor).toBe("transparent");
48
+ });
49
+
50
+ it("defaults size to 256px", async () => {
51
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
52
+ interface VM { size: string }
53
+ expect((wrapper.vm as unknown as VM).size).toBe("256px");
54
+ });
55
+
56
+ it("defaults variant to all-default", async () => {
57
+ const wrapper = await mountSuspended(DisplayQrCode, { props: defaultProps });
58
+ interface VM { variant: QrCodeVariant }
59
+ expect((wrapper.vm as unknown as VM).variant).toEqual({ inner: "default", marker: "default", pixel: "default" });
60
+ });
61
+
62
+ // ─── Custom props ─────────────────────────────────────────────────────────
63
+
64
+ it("reflects a custom radius prop", async () => {
65
+ const wrapper = await mountSuspended(DisplayQrCode, {
66
+ props: { ...defaultProps, radius: 12 },
67
+ });
68
+ interface VM { radius: number }
69
+ expect((wrapper.vm as unknown as VM).radius).toBe(12);
70
+ });
71
+
72
+ it("reflects a custom blackColor prop", async () => {
73
+ const wrapper = await mountSuspended(DisplayQrCode, {
74
+ props: { ...defaultProps, blackColor: "#ff0000" },
75
+ });
76
+ interface VM { blackColor: string }
77
+ expect((wrapper.vm as unknown as VM).blackColor).toBe("#ff0000");
78
+ });
79
+
80
+ it("reflects a custom whiteColor prop", async () => {
81
+ const wrapper = await mountSuspended(DisplayQrCode, {
82
+ props: { ...defaultProps, whiteColor: "#ffffff" },
83
+ });
84
+ interface VM { whiteColor: string }
85
+ expect((wrapper.vm as unknown as VM).whiteColor).toBe("#ffffff");
86
+ });
87
+
88
+ it("reflects a custom size prop", async () => {
89
+ const wrapper = await mountSuspended(DisplayQrCode, {
90
+ props: { ...defaultProps, size: "128px" },
91
+ });
92
+ interface VM { size: string }
93
+ expect((wrapper.vm as unknown as VM).size).toBe("128px");
94
+ });
95
+
96
+ it("reflects a custom variant prop", async () => {
97
+ const variant: QrCodeVariant = { inner: "circle", marker: "rounded", pixel: "dots" };
98
+ const wrapper = await mountSuspended(DisplayQrCode, {
99
+ props: { ...defaultProps, variant },
100
+ });
101
+ interface VM { variant: QrCodeVariant }
102
+ expect((wrapper.vm as unknown as VM).variant).toEqual(variant);
103
+ });
104
+
105
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
106
+
107
+ it("applies a single styleClassPassthrough string", async () => {
108
+ const wrapper = await mountSuspended(DisplayQrCode, {
109
+ props: { ...defaultProps, styleClassPassthrough: "custom-class" },
110
+ });
111
+ expect(wrapper.classes()).toContain("custom-class");
112
+ });
113
+
114
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
115
+ const wrapper = await mountSuspended(DisplayQrCode, {
116
+ props: { ...defaultProps, styleClassPassthrough: ["class-a", "class-b"] },
117
+ });
118
+ expect(wrapper.classes()).toContain("class-a");
119
+ expect(wrapper.classes()).toContain("class-b");
120
+ });
121
+
122
+ it("retains display-qr-code class alongside styleClassPassthrough", async () => {
123
+ const wrapper = await mountSuspended(DisplayQrCode, {
124
+ props: { ...defaultProps, styleClassPassthrough: "extra" },
125
+ });
126
+ expect(wrapper.classes()).toContain("display-qr-code");
127
+ expect(wrapper.classes()).toContain("extra");
128
+ });
129
+
130
+ it("updates classes when styleClassPassthrough prop changes", async () => {
131
+ const wrapper = await mountSuspended(DisplayQrCode, {
132
+ props: { ...defaultProps, styleClassPassthrough: ["original"] },
133
+ });
134
+ expect(wrapper.classes()).toContain("original");
135
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
136
+ expect(wrapper.classes()).not.toContain("original");
137
+ expect(wrapper.classes()).toContain("updated");
138
+ });
139
+ });
@@ -0,0 +1,142 @@
1
+ <template>
2
+ <div class="capture-qr-stream" :class="[elementClasses]">
3
+ <div v-if="!state.error">
4
+ <QrcodeStream v-if="state.cameraOn" ref="qrcodeStreamRef" @error="onError" @detect="onDetect" />
5
+ <div v-else class="camera-stopped">
6
+ <p>Camera stopped</p>
7
+ </div>
8
+ <div v-if="result?.length" class="scanned-results">
9
+ <ul>
10
+ <li v-for="(r, i) in result" :key="i">
11
+ <span>{{ r }}</span>
12
+ </li>
13
+ </ul>
14
+ </div>
15
+ </div>
16
+ <div v-else class="camera-error">
17
+ <p>{{ state.errorMsg }}</p>
18
+ <button @click="resetCamera">Reset camera</button>
19
+ </div>
20
+ </div>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import type { DetectedBarcode } from "nuxt-qrcode";
25
+
26
+ interface Props {
27
+ styleClassPassthrough?: string | string[];
28
+ }
29
+
30
+ const props = withDefaults(defineProps<Props>(), {
31
+ styleClassPassthrough: () => [],
32
+ });
33
+
34
+ const qrcodeStreamRef = ref();
35
+ const result = ref<string[]>();
36
+ const state = reactive({
37
+ errorMsg: "",
38
+ error: false,
39
+ cameraOn: true,
40
+ });
41
+
42
+ onMounted(() => {
43
+ state.cameraOn = true;
44
+ state.error = false;
45
+ state.errorMsg = "";
46
+ result.value = [];
47
+
48
+ const handleVisibilityChange = () => {
49
+ if (document.hidden) {
50
+ state.cameraOn = false;
51
+ stopAllMediaStreams();
52
+ }
53
+ };
54
+
55
+ document.addEventListener("visibilitychange", handleVisibilityChange);
56
+
57
+ onBeforeUnmount(() => {
58
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
59
+ });
60
+ });
61
+
62
+ function onDetect(detectedCodes: DetectedBarcode[]) {
63
+ result.value = detectedCodes.map((code) => code.rawValue);
64
+ }
65
+
66
+ function onError(err: Error) {
67
+ state.error = true;
68
+ state.errorMsg = `[${err.name}]: ${err.message}`;
69
+ }
70
+
71
+ function resetCamera() {
72
+ state.error = false;
73
+ state.cameraOn = true;
74
+ }
75
+
76
+ function stopAllMediaStreams() {
77
+ if (qrcodeStreamRef.value) {
78
+ try {
79
+ const videoElement = qrcodeStreamRef.value.$el?.querySelector("video");
80
+ if (videoElement && videoElement.srcObject) {
81
+ const stream = videoElement.srcObject as MediaStream;
82
+ stream.getTracks().forEach((track) => track.stop());
83
+ videoElement.srcObject = null;
84
+ }
85
+ } catch (error) {
86
+ console.warn("Error stopping camera stream:", error);
87
+ }
88
+ }
89
+
90
+ try {
91
+ document.querySelectorAll("video").forEach((video) => {
92
+ if (video.srcObject) {
93
+ const stream = video.srcObject as MediaStream;
94
+ stream.getTracks().forEach((track) => track.stop());
95
+ video.srcObject = null;
96
+ }
97
+ });
98
+ } catch (error) {
99
+ console.warn("Error in global video cleanup:", error);
100
+ }
101
+ }
102
+
103
+ watch(
104
+ () => state.cameraOn,
105
+ (newValue) => {
106
+ if (!newValue) {
107
+ nextTick(() => stopAllMediaStreams());
108
+ }
109
+ }
110
+ );
111
+
112
+ onBeforeUnmount(() => {
113
+ state.cameraOn = false;
114
+ stopAllMediaStreams();
115
+ });
116
+
117
+ onDeactivated(() => {
118
+ state.cameraOn = false;
119
+ stopAllMediaStreams();
120
+ });
121
+
122
+ onActivated(() => {
123
+ state.cameraOn = true;
124
+ state.error = false;
125
+ state.errorMsg = "";
126
+ });
127
+
128
+ onBeforeRouteLeave(() => {
129
+ state.cameraOn = false;
130
+ stopAllMediaStreams();
131
+ });
132
+
133
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
134
+ </script>
135
+
136
+ <style lang="css">
137
+ @layer components {
138
+ .capture-qr-stream {
139
+ aspect-ratio: 1 / 1;
140
+ }
141
+ }
142
+ </style>
@@ -1,22 +1,11 @@
1
1
  <template>
2
2
  <div class="decode-qr-code" :class="[elementClasses]">
3
- <h2>Upload QR Code</h2>
4
3
  <QrcodeCapture class="qr-code-capture" @detect="onDetect" />
5
-
6
- <h2>Drop QR Code</h2>
7
4
  <QrcodeDropZone class="qr-code-dropzone" @detect="onDetect" @dragover="onDropping" />
8
-
9
- <div v-if="isDropping">
10
- <h5>Scanned QRCodes (Dropped): {{ isDropping ? "Dropping..." : "" }}</h5>
11
- </div>
12
-
13
- <div class="pt-4">
14
- <h5>Scanned QRCodes:</h5>
15
- <ul v-if="result" class="list-disc pl-4">
5
+ <div v-if="result?.length" class="scanned-results">
6
+ <ul>
16
7
  <li v-for="(r, i) in result" :key="i">
17
- <span class="text-wrap wrap-anywhere">
18
- {{ r }}
19
- </span>
8
+ <span>{{ r }}</span>
20
9
  </li>
21
10
  </ul>
22
11
  </div>
@@ -26,12 +15,13 @@
26
15
  <script setup lang="ts">
27
16
  import type { DetectedBarcode } from "nuxt-qrcode"
28
17
 
29
- const props = defineProps({
30
- styleClassPassthrough: {
31
- type: [String, Array] as PropType<string | string[]>,
32
- default: () => [],
33
- },
34
- })
18
+ interface Props {
19
+ styleClassPassthrough?: string | string[];
20
+ }
21
+
22
+ const props = withDefaults(defineProps<Props>(), {
23
+ styleClassPassthrough: () => [],
24
+ });
35
25
 
36
26
  const result = ref<string[]>()
37
27
  const isDropping = ref(false)
@@ -41,21 +31,7 @@ function onDropping(dropping: boolean) {
41
31
  }
42
32
 
43
33
  function onDetect(detectedCodes: DetectedBarcode[]) {
44
- result.value = detectedCodes.map((code) => {
45
- // toast.add({
46
- // title: 'Detected',
47
- // description: `Value: ${code.rawValue}`,
48
- // actions: [
49
- // {
50
- // label: 'Copy',
51
- // onClick: () => {
52
- // navigator.clipboard.writeText(code.rawValue)
53
- // },
54
- // },
55
- // ],
56
- // })
57
- return code.rawValue
58
- })
34
+ result.value = detectedCodes.map((code) => code.rawValue)
59
35
  }
60
36
 
61
37
  const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
@@ -0,0 +1,101 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import CaptureQrCodeComponent from "../CaptureQrCode.vue";
3
+ import DecodeQrCodeComponent from "../DecodeQrCode.vue";
4
+
5
+ // ===== CAPTURE QR CODE =====
6
+
7
+ const CaptureQrCodeMeta: Meta<typeof CaptureQrCodeComponent> = {
8
+ title: "Molecules/QR Code/CaptureQrCode",
9
+ component: CaptureQrCodeComponent,
10
+ argTypes: {
11
+ styleClassPassthrough: { table: { disable: true } },
12
+ },
13
+ args: {
14
+ styleClassPassthrough: [],
15
+ },
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "Interactive QR code scanner using device camera. Requires camera permissions to function properly. Features automatic detection, error handling, and camera management.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ const CaptureTemplate: StoryFn<typeof CaptureQrCodeComponent> = (args) => ({
27
+ components: { CaptureQrCodeComponent },
28
+ setup() {
29
+ return { args };
30
+ },
31
+ template: `
32
+ <div style="padding: 40px; max-width: 600px; margin: 0 auto;">
33
+ <div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
34
+ <h3 style="margin: 0 0 10px 0; color: #374151;">Live Camera QR Scanner</h3>
35
+ <p style="margin: 0 0 10px 0; color: #6b7280; font-size: 14px;">
36
+ This component uses your device camera to scan QR codes in real-time.
37
+ Make sure to allow camera access when prompted.
38
+ </p>
39
+ <div style="background: #fff3cd; padding: 12px; border-radius: 4px; border: 1px solid #ffeaa7;">
40
+ <p style="margin: 0; color: #856404; font-size: 12px;">
41
+ <strong>Features:</strong> Automatic detection, multiple code scanning, camera management, error recovery
42
+ </p>
43
+ </div>
44
+ </div>
45
+ <CaptureQrCodeComponent :style-class-passthrough="args.styleClassPassthrough" />
46
+ </div>
47
+ `,
48
+ });
49
+
50
+ export const CaptureDefault = CaptureTemplate.bind({});
51
+ export const CaptureDefaultMeta = CaptureQrCodeMeta;
52
+
53
+ // ===== DECODE QR CODE =====
54
+
55
+ const DecodeQrCodeMeta: Meta<typeof DecodeQrCodeComponent> = {
56
+ title: "Molecules/QR Code/DecodeQrCode",
57
+ component: DecodeQrCodeComponent,
58
+ argTypes: {
59
+ styleClassPassthrough: { table: { disable: true } },
60
+ },
61
+ args: {
62
+ styleClassPassthrough: [],
63
+ },
64
+ parameters: {
65
+ docs: {
66
+ description: {
67
+ component:
68
+ "Upload or drag-and-drop QR code images to decode their content. Supports various image formats including PNG, JPEG, WEBP, and more.",
69
+ },
70
+ },
71
+ },
72
+ };
73
+
74
+ const DecodeTemplate: StoryFn<typeof DecodeQrCodeComponent> = (args) => ({
75
+ components: { DecodeQrCodeComponent },
76
+ setup() {
77
+ return { args };
78
+ },
79
+ template: `
80
+ <div style="padding: 40px; max-width: 600px; margin: 0 auto;">
81
+ <div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin-bottom: 20px;">
82
+ <h3 style="margin: 0 0 10px 0; color: #374151;">QR Image Decoder</h3>
83
+ <p style="margin: 0 0 10px 0; color: #6b7280; font-size: 14px;">
84
+ Upload or drag QR code images to extract their content.
85
+ Supports PNG, JPEG, WEBP, and other common image formats.
86
+ </p>
87
+ <div style="background: #d1ecf1; padding: 12px; border-radius: 4px; border: 1px solid #bee5eb;">
88
+ <p style="margin: 0; color: #0c5460; font-size: 12px;">
89
+ <strong>Supported formats:</strong> PNG, JPEG, WEBP, BMP, GIF • <strong>Methods:</strong> File upload, drag & drop
90
+ </p>
91
+ </div>
92
+ </div>
93
+ <DecodeQrCodeComponent :style-class-passthrough="args.styleClassPassthrough" />
94
+ </div>
95
+ `,
96
+ });
97
+
98
+ export const DecodeDefault = DecodeTemplate.bind({});
99
+ export const DecodeDefaultMeta = DecodeQrCodeMeta;
100
+
101
+ export default CaptureQrCodeMeta;
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import CaptureQrCode from "../CaptureQrCode.vue";
5
+
6
+ interface CaptureVM {
7
+ state: { error: boolean; errorMsg: string; cameraOn: boolean };
8
+ result: string[] | undefined;
9
+ onDetect: (codes: { rawValue: string }[]) => void;
10
+ onError: (err: Error) => void;
11
+ resetCamera: () => void;
12
+ }
13
+
14
+ describe("CaptureQrCode", () => {
15
+ // QrcodeStream (from nuxt-qrcode) runs canvas-based detection internally.
16
+ // jsdom defines HTMLCanvasElement but returns null for getContext, which
17
+ // causes the library to throw. We provide a minimal non-null mock.
18
+ beforeEach(() => {
19
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as any);
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ // ─── Mount ───────────────────────────────────────────────────────────────
27
+
28
+ it("mounts without error", async () => {
29
+ const wrapper = await mountSuspended(CaptureQrCode);
30
+ expect(wrapper.vm).toBeTruthy();
31
+ });
32
+
33
+ // ─── Root element ─────────────────────────────────────────────────────────
34
+
35
+ it("renders as a div", async () => {
36
+ const wrapper = await mountSuspended(CaptureQrCode);
37
+ expect(wrapper.element.tagName).toBe("DIV");
38
+ });
39
+
40
+ it("always has the capture-qr-stream class", async () => {
41
+ const wrapper = await mountSuspended(CaptureQrCode);
42
+ expect(wrapper.classes()).toContain("capture-qr-stream");
43
+ });
44
+
45
+ // ─── Initial state ────────────────────────────────────────────────────────
46
+
47
+ it("has camera on by default", async () => {
48
+ const wrapper = await mountSuspended(CaptureQrCode);
49
+ const vm = wrapper.vm as unknown as CaptureVM;
50
+ expect(vm.state.cameraOn).toBe(true);
51
+ });
52
+
53
+ it("does not show camera-stopped on mount", async () => {
54
+ const wrapper = await mountSuspended(CaptureQrCode);
55
+ expect(wrapper.find(".camera-stopped").exists()).toBe(false);
56
+ });
57
+
58
+ it("does not show scanned results initially", async () => {
59
+ const wrapper = await mountSuspended(CaptureQrCode);
60
+ expect(wrapper.find(".scanned-results").exists()).toBe(false);
61
+ });
62
+
63
+ it("does not show the error state initially", async () => {
64
+ const wrapper = await mountSuspended(CaptureQrCode);
65
+ expect(wrapper.find(".camera-error").exists()).toBe(false);
66
+ });
67
+
68
+ // ─── Camera off ───────────────────────────────────────────────────────────
69
+
70
+ it("hides the stream and shows camera-stopped when cameraOn is false", async () => {
71
+ const wrapper = await mountSuspended(CaptureQrCode);
72
+ const vm = wrapper.vm as unknown as CaptureVM;
73
+ vm.state.cameraOn = false;
74
+ await nextTick();
75
+ expect(vm.state.cameraOn).toBe(false);
76
+ expect(wrapper.find(".camera-stopped").exists()).toBe(true);
77
+ });
78
+
79
+ // ─── Error state ──────────────────────────────────────────────────────────
80
+
81
+ it("shows camera-error when onError is called", async () => {
82
+ const wrapper = await mountSuspended(CaptureQrCode);
83
+ const vm = wrapper.vm as unknown as CaptureVM;
84
+ vm.onError(new Error("Permission denied"));
85
+ await nextTick();
86
+ expect(wrapper.find(".camera-error").exists()).toBe(true);
87
+ });
88
+
89
+ it("shows the error message in the error state", async () => {
90
+ const wrapper = await mountSuspended(CaptureQrCode);
91
+ const vm = wrapper.vm as unknown as CaptureVM;
92
+ vm.onError(new Error("Permission denied"));
93
+ await nextTick();
94
+ expect(wrapper.find(".camera-error p").text()).toContain("Permission denied");
95
+ });
96
+
97
+ it("sets state.error when onError is called, hiding the stream", async () => {
98
+ const wrapper = await mountSuspended(CaptureQrCode);
99
+ const vm = wrapper.vm as unknown as CaptureVM;
100
+ vm.onError(new Error("test"));
101
+ await nextTick();
102
+ expect(vm.state.error).toBe(true);
103
+ expect(wrapper.find(".camera-error").exists()).toBe(true);
104
+ });
105
+
106
+ it("shows a reset button in the error state", async () => {
107
+ const wrapper = await mountSuspended(CaptureQrCode);
108
+ const vm = wrapper.vm as unknown as CaptureVM;
109
+ vm.onError(new Error("test"));
110
+ await nextTick();
111
+ expect(wrapper.find(".camera-error button").exists()).toBe(true);
112
+ });
113
+
114
+ // ─── Reset camera ─────────────────────────────────────────────────────────
115
+
116
+ it("clears the error state when resetCamera is called", async () => {
117
+ const wrapper = await mountSuspended(CaptureQrCode);
118
+ const vm = wrapper.vm as unknown as CaptureVM;
119
+ vm.onError(new Error("test"));
120
+ await nextTick();
121
+ vm.resetCamera();
122
+ await nextTick();
123
+ expect(wrapper.find(".camera-error").exists()).toBe(false);
124
+ expect(vm.state.cameraOn).toBe(true);
125
+ });
126
+
127
+ it("clears the error state when the reset button is clicked", async () => {
128
+ const wrapper = await mountSuspended(CaptureQrCode);
129
+ const vm = wrapper.vm as unknown as CaptureVM;
130
+ vm.onError(new Error("test"));
131
+ await nextTick();
132
+ await wrapper.find(".camera-error button").trigger("click");
133
+ expect(wrapper.find(".camera-error").exists()).toBe(false);
134
+ });
135
+
136
+ // ─── Detect ───────────────────────────────────────────────────────────────
137
+
138
+ it("shows scanned results after onDetect is called", async () => {
139
+ const wrapper = await mountSuspended(CaptureQrCode);
140
+ const vm = wrapper.vm as unknown as CaptureVM;
141
+ vm.onDetect([{ rawValue: "https://example.com" }]);
142
+ await nextTick();
143
+ expect(wrapper.find(".scanned-results").exists()).toBe(true);
144
+ });
145
+
146
+ it("shows the detected value in the results list", async () => {
147
+ const wrapper = await mountSuspended(CaptureQrCode);
148
+ const vm = wrapper.vm as unknown as CaptureVM;
149
+ vm.onDetect([{ rawValue: "https://example.com" }]);
150
+ await nextTick();
151
+ expect(wrapper.find(".scanned-results").text()).toContain("https://example.com");
152
+ });
153
+
154
+ it("shows all detected values when multiple codes are scanned", async () => {
155
+ const wrapper = await mountSuspended(CaptureQrCode);
156
+ const vm = wrapper.vm as unknown as CaptureVM;
157
+ vm.onDetect([{ rawValue: "https://one.com" }, { rawValue: "https://two.com" }]);
158
+ await nextTick();
159
+ const items = wrapper.findAll(".scanned-results li");
160
+ expect(items).toHaveLength(2);
161
+ expect(items[0]!.text()).toBe("https://one.com");
162
+ expect(items[1]!.text()).toBe("https://two.com");
163
+ });
164
+
165
+ it("replaces results on subsequent scans", async () => {
166
+ const wrapper = await mountSuspended(CaptureQrCode);
167
+ const vm = wrapper.vm as unknown as CaptureVM;
168
+ vm.onDetect([{ rawValue: "first" }]);
169
+ await nextTick();
170
+ vm.onDetect([{ rawValue: "second" }]);
171
+ await nextTick();
172
+ const items = wrapper.findAll(".scanned-results li");
173
+ expect(items).toHaveLength(1);
174
+ expect(items[0]!.text()).toBe("second");
175
+ });
176
+
177
+ // ─── Visibility change ────────────────────────────────────────────────────
178
+
179
+ it("stops the camera when the page becomes hidden", async () => {
180
+ const wrapper = await mountSuspended(CaptureQrCode);
181
+ Object.defineProperty(document, "hidden", { get: () => true, configurable: true });
182
+ document.dispatchEvent(new Event("visibilitychange"));
183
+ await nextTick();
184
+ expect(wrapper.find("qrcodestream").exists()).toBe(false);
185
+ Object.defineProperty(document, "hidden", { get: () => false, configurable: true });
186
+ });
187
+
188
+ // ─── styleClassPassthrough ────────────────────────────────────────────────
189
+
190
+ it("applies a single styleClassPassthrough string", async () => {
191
+ const wrapper = await mountSuspended(CaptureQrCode, {
192
+ props: { styleClassPassthrough: "custom-class" },
193
+ });
194
+ expect(wrapper.classes()).toContain("custom-class");
195
+ });
196
+
197
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
198
+ const wrapper = await mountSuspended(CaptureQrCode, {
199
+ props: { styleClassPassthrough: ["class-a", "class-b"] },
200
+ });
201
+ expect(wrapper.classes()).toContain("class-a");
202
+ expect(wrapper.classes()).toContain("class-b");
203
+ });
204
+
205
+ it("retains capture-qr-stream class alongside styleClassPassthrough", async () => {
206
+ const wrapper = await mountSuspended(CaptureQrCode, {
207
+ props: { styleClassPassthrough: "extra" },
208
+ });
209
+ expect(wrapper.classes()).toContain("capture-qr-stream");
210
+ expect(wrapper.classes()).toContain("extra");
211
+ });
212
+ });