srcdev-nuxt-components 9.1.37 → 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.
- package/.claude/skills/components/data-grid.md +167 -0
- package/.claude/skills/index.md +2 -1
- package/app/components/01.atoms/grids/data-grid/DataGrid.vue +39 -0
- package/app/components/01.atoms/grids/data-grid/stories/DataGrid.stories.ts +234 -0
- package/app/components/01.atoms/grids/data-grid/tests/DataGrid.spec.ts +140 -0
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +11 -0
- package/app/components/02.molecules/qr-code/CaptureQrCode.vue +49 -49
- package/app/pages/ui/simple-grid.vue +2 -2
- package/package.json +1 -1
- package/app/components/display-grid/DisplayGridCore.vue +0 -22
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# DataGrid Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`DataGrid` is a responsive auto-fit CSS grid wrapper. It renders whatever named slots the consumer provides, auto-fitting columns to a minimum of `250px` each. Column count and gap are controlled via CSS custom properties, making layout adjustments a single-line style override rather than a prop change.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Slot pattern
|
|
10
|
+
|
|
11
|
+
Pass any number of named slots — the component renders each one in document order inside the grid.
|
|
12
|
+
|
|
13
|
+
```vue
|
|
14
|
+
<DataGrid>
|
|
15
|
+
<template #item-1><StatCard label="Revenue" value="£24,500" /></template>
|
|
16
|
+
<template #item-2><StatCard label="Clients" value="142" /></template>
|
|
17
|
+
<template #item-3><StatCard label="Bookings" value="38" /></template>
|
|
18
|
+
</DataGrid>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When filling from a data array, use a dynamic slot name in a `v-for`:
|
|
22
|
+
|
|
23
|
+
```vue
|
|
24
|
+
<DataGrid>
|
|
25
|
+
<template v-for="(item, i) in stats" #[`item-${i}`] :key="i">
|
|
26
|
+
<StatCard :label="item.label" :value="item.value" />
|
|
27
|
+
</template>
|
|
28
|
+
</DataGrid>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Props reference
|
|
34
|
+
|
|
35
|
+
> **Hyphenation rule**: Vue's ESLint config enforces `vue/attribute-hyphenation`. Always write camelCase prop names hyphenated in templates: `:style-class-passthrough`.
|
|
36
|
+
|
|
37
|
+
| Prop (template form) | Type | Default | Notes |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | Use a semantic tag for page landmark regions. |
|
|
40
|
+
| `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes on the root element. |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## CSS custom properties
|
|
45
|
+
|
|
46
|
+
Override these via `style` attribute or a `styleClassPassthrough` class in a consuming `<style>` block.
|
|
47
|
+
|
|
48
|
+
| Property | Default | Notes |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `--data-grid-columns` | `repeat(auto-fit, minmax(250px, 1fr))` | Full `grid-template-columns` value. Override to fix column count or change min width. |
|
|
51
|
+
| `--data-grid-gap` | `1rem` | Grid gap between items. |
|
|
52
|
+
|
|
53
|
+
### Fixed column count
|
|
54
|
+
|
|
55
|
+
```vue
|
|
56
|
+
<DataGrid style="--data-grid-columns: repeat(3, 1fr); --data-grid-gap: 2.4rem;">
|
|
57
|
+
...
|
|
58
|
+
</DataGrid>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Narrower minimum item width
|
|
62
|
+
|
|
63
|
+
```vue
|
|
64
|
+
<DataGrid style="--data-grid-columns: repeat(auto-fit, minmax(180px, 1fr));">
|
|
65
|
+
...
|
|
66
|
+
</DataGrid>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Usage examples
|
|
72
|
+
|
|
73
|
+
### Stat cards (default auto-fit)
|
|
74
|
+
|
|
75
|
+
```vue
|
|
76
|
+
<DataGrid>
|
|
77
|
+
<template #revenue>
|
|
78
|
+
<div class="stat-card">
|
|
79
|
+
<span class="stat-card-label">Revenue</span>
|
|
80
|
+
<span class="stat-card-value">£24,500</span>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
<template #clients>
|
|
84
|
+
<div class="stat-card">
|
|
85
|
+
<span class="stat-card-label">Clients</span>
|
|
86
|
+
<span class="stat-card-value">142</span>
|
|
87
|
+
</div>
|
|
88
|
+
</template>
|
|
89
|
+
</DataGrid>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Semantic section with auto aria-labelledby
|
|
93
|
+
|
|
94
|
+
```vue
|
|
95
|
+
<DataGrid tag="section">
|
|
96
|
+
<!-- aria-labelledby is wired automatically via useAriaLabelledById -->
|
|
97
|
+
<template #item-1><div>Item 1</div></template>
|
|
98
|
+
<template #item-2><div>Item 2</div></template>
|
|
99
|
+
</DataGrid>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Data-driven grid
|
|
103
|
+
|
|
104
|
+
```vue
|
|
105
|
+
<script setup lang="ts">
|
|
106
|
+
const stats = [
|
|
107
|
+
{ id: "revenue", label: "Revenue", value: "£24,500" },
|
|
108
|
+
{ id: "clients", label: "Clients", value: "142" },
|
|
109
|
+
{ id: "bookings", label: "Bookings", value: "38" },
|
|
110
|
+
];
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<template>
|
|
114
|
+
<DataGrid>
|
|
115
|
+
<template v-for="stat in stats" #[stat.id] :key="stat.id">
|
|
116
|
+
<div class="stat-card">
|
|
117
|
+
<span class="stat-card-label">{{ stat.label }}</span>
|
|
118
|
+
<span class="stat-card-value">{{ stat.value }}</span>
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|
|
121
|
+
</DataGrid>
|
|
122
|
+
</template>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Accessibility
|
|
128
|
+
|
|
129
|
+
- When `tag` is `section`, `article`, or `main`, `aria-labelledby` is automatically set via `useAriaLabelledById`, pointing to a generated heading ID.
|
|
130
|
+
- When `tag="div"`, no ARIA attributes are added.
|
|
131
|
+
- Ensure a heading element with the matching ID is present inside the grid when using semantic tags.
|
|
132
|
+
|
|
133
|
+
See [component-aria-landmark.md](../component-aria-landmark.md) for the full landmark pattern.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Local style override scaffold
|
|
138
|
+
|
|
139
|
+
```vue
|
|
140
|
+
<DataGrid :style-class-passthrough="['my-data-grid']">
|
|
141
|
+
...
|
|
142
|
+
</DataGrid>
|
|
143
|
+
|
|
144
|
+
<style>
|
|
145
|
+
/* ─── DataGrid local overrides ──────────────────────────────────────
|
|
146
|
+
Use CSS custom properties for layout, not utility classes.
|
|
147
|
+
Delete this block if no overrides are needed.
|
|
148
|
+
─────────────────────────────────────────────────────────────────── */
|
|
149
|
+
.data-grid {
|
|
150
|
+
&.my-data-grid {
|
|
151
|
+
--data-grid-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
152
|
+
--data-grid-gap: 2rem;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
See [component-local-style-override.md](../component-local-style-override.md) for the full pattern.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Notes
|
|
163
|
+
|
|
164
|
+
- Auto-imported in Nuxt — no manual import needed.
|
|
165
|
+
- Slot names can be anything — semantic (`#revenue`) or indexed (`#item-0`). Document order determines render order.
|
|
166
|
+
- `--data-grid-columns` accepts any valid `grid-template-columns` value, including named tracks and `subgrid`.
|
|
167
|
+
- The component does not impose a column count — `auto-fit` with `minmax` means the browser decides. Use `repeat(N, 1fr)` in `--data-grid-columns` to fix the count.
|
package/.claude/skills/index.md
CHANGED
|
@@ -74,7 +74,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
74
74
|
├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names, CSS tokens
|
|
75
75
|
├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
|
|
76
76
|
├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
|
|
77
|
-
|
|
77
|
+
├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
|
|
78
|
+
└── data-grid.md — DataGrid: auto-fit responsive grid, $slots iteration, --data-grid-columns/gap tokens, semantic tag + aria
|
|
78
79
|
```
|
|
79
80
|
|
|
80
81
|
## Skill file template
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="data-grid" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
|
|
3
|
+
<slot v-for="(_, name) in $slots" :key="name" :name="name"></slot>
|
|
4
|
+
</component>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
interface Props {
|
|
9
|
+
tag?: "div" | "section" | "article" | "main";
|
|
10
|
+
styleClassPassthrough?: string | string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
14
|
+
tag: "div",
|
|
15
|
+
styleClassPassthrough: () => [],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
19
|
+
const { ariaLabelledby } = useAriaLabelledById(props.tag);
|
|
20
|
+
|
|
21
|
+
watch(
|
|
22
|
+
() => props.styleClassPassthrough,
|
|
23
|
+
() => resetElementClasses(props.styleClassPassthrough),
|
|
24
|
+
);
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<style lang="css">
|
|
28
|
+
@layer components {
|
|
29
|
+
.data-grid {
|
|
30
|
+
/* CSS Tockens for @container grid-template-columns */
|
|
31
|
+
--data-grid-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
32
|
+
--data-grid-gap: 1rem;
|
|
33
|
+
|
|
34
|
+
display: grid;
|
|
35
|
+
grid-template-columns: var(--data-grid-columns);
|
|
36
|
+
gap: var(--data-grid-gap);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import DataGridComponent from "../DataGrid.vue";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof DataGridComponent> = {
|
|
5
|
+
title: "Atoms/Grids/DataGrid",
|
|
6
|
+
component: DataGridComponent,
|
|
7
|
+
argTypes: {
|
|
8
|
+
tag: {
|
|
9
|
+
control: { type: "select" },
|
|
10
|
+
options: ["div", "section", "article", "main"],
|
|
11
|
+
description: "HTML tag to render as",
|
|
12
|
+
table: { category: "Semantic" },
|
|
13
|
+
},
|
|
14
|
+
styleClassPassthrough: {
|
|
15
|
+
table: { disable: true },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
tag: "div",
|
|
20
|
+
styleClassPassthrough: [],
|
|
21
|
+
},
|
|
22
|
+
parameters: {
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"A responsive auto-fit grid container. Uses CSS custom properties `--data-grid-columns` and `--data-grid-gap` to control layout. Renders named slots provided by the consumer.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
|
|
34
|
+
const cardStyle =
|
|
35
|
+
"padding: 2.4rem; background: white; border-radius: 0.8rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); display: flex; flex-direction: column; gap: 0.8rem;";
|
|
36
|
+
const labelStyle = "font-size: 1.2rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280;";
|
|
37
|
+
const valueStyle = "font-size: 2.4rem; font-weight: 700; color: #111827;";
|
|
38
|
+
|
|
39
|
+
interface DataGridArgs {
|
|
40
|
+
tag: "div" | "section" | "article" | "main";
|
|
41
|
+
styleClassPassthrough: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Template: StoryFn<DataGridArgs> = (args) => ({
|
|
45
|
+
components: { DataGridComponent },
|
|
46
|
+
setup() {
|
|
47
|
+
return { args };
|
|
48
|
+
},
|
|
49
|
+
template: `
|
|
50
|
+
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
51
|
+
<DataGridComponent :tag="args.tag" :style-class-passthrough="args.styleClassPassthrough">
|
|
52
|
+
<template #item-1>
|
|
53
|
+
<div style="${cardStyle}">
|
|
54
|
+
<span style="${labelStyle}">Revenue</span>
|
|
55
|
+
<span style="${valueStyle}">£24,500</span>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
<template #item-2>
|
|
59
|
+
<div style="${cardStyle}">
|
|
60
|
+
<span style="${labelStyle}">Clients</span>
|
|
61
|
+
<span style="${valueStyle}">142</span>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
<template #item-3>
|
|
65
|
+
<div style="${cardStyle}">
|
|
66
|
+
<span style="${labelStyle}">Bookings</span>
|
|
67
|
+
<span style="${valueStyle}">38</span>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
<template #item-4>
|
|
71
|
+
<div style="${cardStyle}">
|
|
72
|
+
<span style="${labelStyle}">Avg. Rating</span>
|
|
73
|
+
<span style="${valueStyle}">4.9</span>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
</DataGridComponent>
|
|
77
|
+
</div>
|
|
78
|
+
`,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const Default = Template.bind({});
|
|
82
|
+
Default.parameters = {
|
|
83
|
+
docs: {
|
|
84
|
+
description: {
|
|
85
|
+
story: "Default grid with four stat cards. Columns auto-fit to available space at a minimum of 250px each.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const TwoItems = Template.bind({});
|
|
91
|
+
TwoItems.storyName = "Two Items";
|
|
92
|
+
TwoItems.decorators = [
|
|
93
|
+
() => ({
|
|
94
|
+
components: { DataGridComponent },
|
|
95
|
+
template: `
|
|
96
|
+
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
97
|
+
<DataGridComponent>
|
|
98
|
+
<template #item-1>
|
|
99
|
+
<div style="${cardStyle}">
|
|
100
|
+
<span style="${labelStyle}">Revenue</span>
|
|
101
|
+
<span style="${valueStyle}">£24,500</span>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
<template #item-2>
|
|
105
|
+
<div style="${cardStyle}">
|
|
106
|
+
<span style="${labelStyle}">Clients</span>
|
|
107
|
+
<span style="${valueStyle}">142</span>
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
110
|
+
</DataGridComponent>
|
|
111
|
+
</div>
|
|
112
|
+
`,
|
|
113
|
+
}),
|
|
114
|
+
];
|
|
115
|
+
TwoItems.parameters = {
|
|
116
|
+
docs: { description: { story: "Grid with two items — auto-fit spreads them to fill available columns." } },
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const NarrowContainer: StoryFn<DataGridArgs> = (args) => ({
|
|
120
|
+
components: { DataGridComponent },
|
|
121
|
+
setup() {
|
|
122
|
+
return { args };
|
|
123
|
+
},
|
|
124
|
+
template: `
|
|
125
|
+
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
126
|
+
<div style="max-width: 400px;">
|
|
127
|
+
<DataGridComponent :tag="args.tag">
|
|
128
|
+
<template #item-1>
|
|
129
|
+
<div style="${cardStyle}">
|
|
130
|
+
<span style="${labelStyle}">Revenue</span>
|
|
131
|
+
<span style="${valueStyle}">£24,500</span>
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
<template #item-2>
|
|
135
|
+
<div style="${cardStyle}">
|
|
136
|
+
<span style="${labelStyle}">Clients</span>
|
|
137
|
+
<span style="${valueStyle}">142</span>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
140
|
+
<template #item-3>
|
|
141
|
+
<div style="${cardStyle}">
|
|
142
|
+
<span style="${labelStyle}">Bookings</span>
|
|
143
|
+
<span style="${valueStyle}">38</span>
|
|
144
|
+
</div>
|
|
145
|
+
</template>
|
|
146
|
+
</DataGridComponent>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
`,
|
|
150
|
+
});
|
|
151
|
+
NarrowContainer.parameters = {
|
|
152
|
+
docs: {
|
|
153
|
+
description: {
|
|
154
|
+
story: "In a 400px container the auto-fit columns stack to a single column once items fall below 250px.",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const CustomColumns: StoryFn<DataGridArgs> = (args) => ({
|
|
160
|
+
components: { DataGridComponent },
|
|
161
|
+
setup() {
|
|
162
|
+
return { args };
|
|
163
|
+
},
|
|
164
|
+
template: `
|
|
165
|
+
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
166
|
+
<DataGridComponent style="--data-grid-columns: repeat(3, 1fr); --data-grid-gap: 2.4rem;" :tag="args.tag">
|
|
167
|
+
<template #item-1>
|
|
168
|
+
<div style="${cardStyle}">
|
|
169
|
+
<span style="${labelStyle}">Revenue</span>
|
|
170
|
+
<span style="${valueStyle}">£24,500</span>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
<template #item-2>
|
|
174
|
+
<div style="${cardStyle}">
|
|
175
|
+
<span style="${labelStyle}">Clients</span>
|
|
176
|
+
<span style="${valueStyle}">142</span>
|
|
177
|
+
</div>
|
|
178
|
+
</template>
|
|
179
|
+
<template #item-3>
|
|
180
|
+
<div style="${cardStyle}">
|
|
181
|
+
<span style="${labelStyle}">Bookings</span>
|
|
182
|
+
<span style="${valueStyle}">38</span>
|
|
183
|
+
</div>
|
|
184
|
+
</template>
|
|
185
|
+
</DataGridComponent>
|
|
186
|
+
</div>
|
|
187
|
+
`,
|
|
188
|
+
});
|
|
189
|
+
CustomColumns.parameters = {
|
|
190
|
+
docs: {
|
|
191
|
+
description: {
|
|
192
|
+
story:
|
|
193
|
+
"Override `--data-grid-columns` and `--data-grid-gap` via inline style to force a fixed 3-column layout with wider gaps.",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const SemanticSection: StoryFn<DataGridArgs> = (args) => ({
|
|
199
|
+
components: { DataGridComponent },
|
|
200
|
+
setup() {
|
|
201
|
+
return { args };
|
|
202
|
+
},
|
|
203
|
+
template: `
|
|
204
|
+
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
205
|
+
<DataGridComponent tag="section">
|
|
206
|
+
<template #item-1>
|
|
207
|
+
<div style="${cardStyle}">
|
|
208
|
+
<span style="${labelStyle}">Revenue</span>
|
|
209
|
+
<span style="${valueStyle}">£24,500</span>
|
|
210
|
+
</div>
|
|
211
|
+
</template>
|
|
212
|
+
<template #item-2>
|
|
213
|
+
<div style="${cardStyle}">
|
|
214
|
+
<span style="${labelStyle}">Clients</span>
|
|
215
|
+
<span style="${valueStyle}">142</span>
|
|
216
|
+
</div>
|
|
217
|
+
</template>
|
|
218
|
+
<template #item-3>
|
|
219
|
+
<div style="${cardStyle}">
|
|
220
|
+
<span style="${labelStyle}">Bookings</span>
|
|
221
|
+
<span style="${valueStyle}">38</span>
|
|
222
|
+
</div>
|
|
223
|
+
</template>
|
|
224
|
+
</DataGridComponent>
|
|
225
|
+
</div>
|
|
226
|
+
`,
|
|
227
|
+
});
|
|
228
|
+
SemanticSection.parameters = {
|
|
229
|
+
docs: {
|
|
230
|
+
description: {
|
|
231
|
+
story: "Rendered as a `<section>` — `aria-labelledby` is automatically applied via `useAriaLabelledById`.",
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import DataGrid from "../DataGrid.vue";
|
|
4
|
+
|
|
5
|
+
describe("DataGrid", () => {
|
|
6
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
it("mounts without error", async () => {
|
|
9
|
+
const wrapper = await mountSuspended(DataGrid);
|
|
10
|
+
expect(wrapper.vm).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
it("renders correct HTML structure (default props)", async () => {
|
|
16
|
+
const wrapper = await mountSuspended(DataGrid);
|
|
17
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders correct HTML structure (all props and slots set)", async () => {
|
|
21
|
+
const wrapper = await mountSuspended(DataGrid, {
|
|
22
|
+
props: {
|
|
23
|
+
tag: "section",
|
|
24
|
+
styleClassPassthrough: ["custom-class"],
|
|
25
|
+
},
|
|
26
|
+
slots: {
|
|
27
|
+
"item-1": "<div>Item 1</div>",
|
|
28
|
+
"item-2": "<div>Item 2</div>",
|
|
29
|
+
"item-3": "<div>Item 3</div>",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── Root element ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
it("renders a <div> as the root element by default", async () => {
|
|
38
|
+
const wrapper = await mountSuspended(DataGrid);
|
|
39
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("always has the data-grid class", async () => {
|
|
43
|
+
const wrapper = await mountSuspended(DataGrid);
|
|
44
|
+
expect(wrapper.classes()).toContain("data-grid");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Tag prop ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
it("renders a <section> when tag is section", async () => {
|
|
50
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "section" } });
|
|
51
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders an <article> when tag is article", async () => {
|
|
55
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "article" } });
|
|
56
|
+
expect(wrapper.element.tagName).toBe("ARTICLE");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders a <main> when tag is main", async () => {
|
|
60
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "main" } });
|
|
61
|
+
expect(wrapper.element.tagName).toBe("MAIN");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── Aria ─────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
it("does not set aria-labelledby when tag is div", async () => {
|
|
67
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "div" } });
|
|
68
|
+
expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("sets aria-labelledby when tag is section", async () => {
|
|
72
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "section" } });
|
|
73
|
+
expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("sets aria-labelledby when tag is article", async () => {
|
|
77
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "article" } });
|
|
78
|
+
expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("sets aria-labelledby when tag is main", async () => {
|
|
82
|
+
const wrapper = await mountSuspended(DataGrid, { props: { tag: "main" } });
|
|
83
|
+
expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── Slots ───────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
it("renders a named slot", async () => {
|
|
89
|
+
const wrapper = await mountSuspended(DataGrid, {
|
|
90
|
+
slots: { "item-1": "<div class='card'>Card 1</div>" },
|
|
91
|
+
});
|
|
92
|
+
expect(wrapper.find(".card").exists()).toBe(true);
|
|
93
|
+
expect(wrapper.find(".card").text()).toBe("Card 1");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders multiple named slots", async () => {
|
|
97
|
+
const wrapper = await mountSuspended(DataGrid, {
|
|
98
|
+
slots: {
|
|
99
|
+
"item-1": "<div class='card-1'>Card 1</div>",
|
|
100
|
+
"item-2": "<div class='card-2'>Card 2</div>",
|
|
101
|
+
"item-3": "<div class='card-3'>Card 3</div>",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(wrapper.find(".card-1").exists()).toBe(true);
|
|
105
|
+
expect(wrapper.find(".card-2").exists()).toBe(true);
|
|
106
|
+
expect(wrapper.find(".card-3").exists()).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders no slot content when no slots are provided", async () => {
|
|
110
|
+
const wrapper = await mountSuspended(DataGrid);
|
|
111
|
+
expect(wrapper.text()).toBe("");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
it("applies a single styleClassPassthrough string to the root", async () => {
|
|
117
|
+
const wrapper = await mountSuspended(DataGrid, {
|
|
118
|
+
props: { styleClassPassthrough: "my-grid" },
|
|
119
|
+
});
|
|
120
|
+
expect(wrapper.classes()).toContain("my-grid");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
124
|
+
const wrapper = await mountSuspended(DataGrid, {
|
|
125
|
+
props: { styleClassPassthrough: ["my-grid", "mbe-32"] },
|
|
126
|
+
});
|
|
127
|
+
expect(wrapper.classes()).toContain("my-grid");
|
|
128
|
+
expect(wrapper.classes()).toContain("mbe-32");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
132
|
+
const wrapper = await mountSuspended(DataGrid, {
|
|
133
|
+
props: { styleClassPassthrough: ["original"] },
|
|
134
|
+
});
|
|
135
|
+
expect(wrapper.classes()).toContain("original");
|
|
136
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
137
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
138
|
+
expect(wrapper.classes()).toContain("updated");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`DataGrid > renders correct HTML structure (all props and slots set) 1`] = `
|
|
4
|
+
"<section class="data-grid custom-class" aria-labelledby="v-0-0">
|
|
5
|
+
<div>Item 1</div>
|
|
6
|
+
<div>Item 2</div>
|
|
7
|
+
<div>Item 3</div>
|
|
8
|
+
</section>"
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
exports[`DataGrid > renders correct HTML structure (default props) 1`] = `"<div class="data-grid"></div>"`;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
</template>
|
|
22
22
|
|
|
23
23
|
<script setup lang="ts">
|
|
24
|
-
import type { DetectedBarcode } from "nuxt-qrcode"
|
|
24
|
+
import type { DetectedBarcode } from "nuxt-qrcode";
|
|
25
25
|
|
|
26
26
|
interface Props {
|
|
27
27
|
styleClassPassthrough?: string | string[];
|
|
@@ -31,72 +31,72 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
31
31
|
styleClassPassthrough: () => [],
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
const qrcodeStreamRef = ref()
|
|
35
|
-
const result = ref<string[]>()
|
|
34
|
+
const qrcodeStreamRef = ref();
|
|
35
|
+
const result = ref<string[]>();
|
|
36
36
|
const state = reactive({
|
|
37
37
|
errorMsg: "",
|
|
38
38
|
error: false,
|
|
39
39
|
cameraOn: true,
|
|
40
|
-
})
|
|
40
|
+
});
|
|
41
41
|
|
|
42
42
|
onMounted(() => {
|
|
43
|
-
state.cameraOn = true
|
|
44
|
-
state.error = false
|
|
45
|
-
state.errorMsg = ""
|
|
46
|
-
result.value = []
|
|
43
|
+
state.cameraOn = true;
|
|
44
|
+
state.error = false;
|
|
45
|
+
state.errorMsg = "";
|
|
46
|
+
result.value = [];
|
|
47
47
|
|
|
48
48
|
const handleVisibilityChange = () => {
|
|
49
49
|
if (document.hidden) {
|
|
50
|
-
state.cameraOn = false
|
|
51
|
-
stopAllMediaStreams()
|
|
50
|
+
state.cameraOn = false;
|
|
51
|
+
stopAllMediaStreams();
|
|
52
52
|
}
|
|
53
|
-
}
|
|
53
|
+
};
|
|
54
54
|
|
|
55
|
-
document.addEventListener("visibilitychange", handleVisibilityChange)
|
|
55
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
56
56
|
|
|
57
57
|
onBeforeUnmount(() => {
|
|
58
|
-
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
|
59
|
-
})
|
|
60
|
-
})
|
|
58
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
61
|
|
|
62
62
|
function onDetect(detectedCodes: DetectedBarcode[]) {
|
|
63
|
-
result.value = detectedCodes.map((code) => code.rawValue)
|
|
63
|
+
result.value = detectedCodes.map((code) => code.rawValue);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function onError(err: Error) {
|
|
67
|
-
state.error = true
|
|
68
|
-
state.errorMsg = `[${err.name}]: ${err.message}
|
|
67
|
+
state.error = true;
|
|
68
|
+
state.errorMsg = `[${err.name}]: ${err.message}`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function resetCamera() {
|
|
72
|
-
state.error = false
|
|
73
|
-
state.cameraOn = true
|
|
72
|
+
state.error = false;
|
|
73
|
+
state.cameraOn = true;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function stopAllMediaStreams() {
|
|
77
77
|
if (qrcodeStreamRef.value) {
|
|
78
78
|
try {
|
|
79
|
-
const videoElement = qrcodeStreamRef.value.$el?.querySelector("video")
|
|
79
|
+
const videoElement = qrcodeStreamRef.value.$el?.querySelector("video");
|
|
80
80
|
if (videoElement && videoElement.srcObject) {
|
|
81
|
-
const stream = videoElement.srcObject as MediaStream
|
|
82
|
-
stream.getTracks().forEach((track) => track.stop())
|
|
83
|
-
videoElement.srcObject = null
|
|
81
|
+
const stream = videoElement.srcObject as MediaStream;
|
|
82
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
83
|
+
videoElement.srcObject = null;
|
|
84
84
|
}
|
|
85
85
|
} catch (error) {
|
|
86
|
-
console.warn("Error stopping camera stream:", error)
|
|
86
|
+
console.warn("Error stopping camera stream:", error);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
try {
|
|
91
91
|
document.querySelectorAll("video").forEach((video) => {
|
|
92
92
|
if (video.srcObject) {
|
|
93
|
-
const stream = video.srcObject as MediaStream
|
|
94
|
-
stream.getTracks().forEach((track) => track.stop())
|
|
95
|
-
video.srcObject = null
|
|
93
|
+
const stream = video.srcObject as MediaStream;
|
|
94
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
95
|
+
video.srcObject = null;
|
|
96
96
|
}
|
|
97
|
-
})
|
|
97
|
+
});
|
|
98
98
|
} catch (error) {
|
|
99
|
-
console.warn("Error in global video cleanup:", error)
|
|
99
|
+
console.warn("Error in global video cleanup:", error);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -104,39 +104,39 @@ watch(
|
|
|
104
104
|
() => state.cameraOn,
|
|
105
105
|
(newValue) => {
|
|
106
106
|
if (!newValue) {
|
|
107
|
-
nextTick(() => stopAllMediaStreams())
|
|
107
|
+
nextTick(() => stopAllMediaStreams());
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
-
)
|
|
110
|
+
);
|
|
111
111
|
|
|
112
112
|
onBeforeUnmount(() => {
|
|
113
|
-
state.cameraOn = false
|
|
114
|
-
stopAllMediaStreams()
|
|
115
|
-
})
|
|
113
|
+
state.cameraOn = false;
|
|
114
|
+
stopAllMediaStreams();
|
|
115
|
+
});
|
|
116
116
|
|
|
117
117
|
onDeactivated(() => {
|
|
118
|
-
state.cameraOn = false
|
|
119
|
-
stopAllMediaStreams()
|
|
120
|
-
})
|
|
118
|
+
state.cameraOn = false;
|
|
119
|
+
stopAllMediaStreams();
|
|
120
|
+
});
|
|
121
121
|
|
|
122
122
|
onActivated(() => {
|
|
123
|
-
state.cameraOn = true
|
|
124
|
-
state.error = false
|
|
125
|
-
state.errorMsg = ""
|
|
126
|
-
})
|
|
123
|
+
state.cameraOn = true;
|
|
124
|
+
state.error = false;
|
|
125
|
+
state.errorMsg = "";
|
|
126
|
+
});
|
|
127
127
|
|
|
128
128
|
onBeforeRouteLeave(() => {
|
|
129
|
-
state.cameraOn = false
|
|
130
|
-
stopAllMediaStreams()
|
|
131
|
-
})
|
|
129
|
+
state.cameraOn = false;
|
|
130
|
+
stopAllMediaStreams();
|
|
131
|
+
});
|
|
132
132
|
|
|
133
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
133
|
+
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
134
134
|
</script>
|
|
135
135
|
|
|
136
136
|
<style lang="css">
|
|
137
137
|
@layer components {
|
|
138
|
-
.capture-qr-stream {
|
|
139
|
-
|
|
140
|
-
}
|
|
138
|
+
.capture-qr-stream {
|
|
139
|
+
aspect-ratio: 1 / 1;
|
|
140
|
+
}
|
|
141
141
|
}
|
|
142
142
|
</style>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<h1 class="page-heading-3">Simple Grid</h1>
|
|
7
7
|
<p class="page-body-normal">Simple grid displaying dummy posts data</p>
|
|
8
8
|
|
|
9
|
-
<
|
|
9
|
+
<GridCore
|
|
10
10
|
v-if="status === 'success'"
|
|
11
11
|
:grid-data="postsData?.posts.slice(0, displayCount) ?? ({} as Posts)"
|
|
12
12
|
:style-class-passthrough="['display-posts']"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<div>{{ item.body }}</div>
|
|
19
19
|
</div>
|
|
20
20
|
</template>
|
|
21
|
-
</
|
|
21
|
+
</GridCore>
|
|
22
22
|
|
|
23
23
|
<p v-else class="page-body-normal">…Loading</p>
|
|
24
24
|
</LayoutRow>
|
package/package.json
CHANGED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div :class="[elementClasses]">
|
|
3
|
-
<slot v-for="item in gridData" :key="item.id" :name="item.id"></slot>
|
|
4
|
-
</div>
|
|
5
|
-
</template>
|
|
6
|
-
|
|
7
|
-
<script setup lang="ts">
|
|
8
|
-
const props = defineProps({
|
|
9
|
-
gridData: {
|
|
10
|
-
type: Object,
|
|
11
|
-
default: () => ({}),
|
|
12
|
-
},
|
|
13
|
-
styleClassPassthrough: {
|
|
14
|
-
type: [String, Array] as PropType<string | string[]>,
|
|
15
|
-
default: () => [],
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
20
|
-
|
|
21
|
-
const gridData = toRef(() => props.gridData);
|
|
22
|
-
</script>
|