srcdev-nuxt-components 9.1.42 → 9.1.43
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/settings.json +3 -1
- package/.claude/skills/components/{data-grid.md → auto-grid.md} +36 -34
- package/.claude/skills/css-animation-utilities.md +87 -0
- package/.claude/skills/index.md +2 -1
- package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +21 -0
- package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/index.css +5 -4
- package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +57 -0
- package/app/components/01.atoms/grids/data-grid/stories/{DataGrid.stories.ts → AutoGrid.stories.ts} +95 -39
- package/app/components/01.atoms/grids/data-grid/tests/{DataGrid.spec.ts → AutoGrid.spec.ts} +42 -22
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +11 -0
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +3 -3
- package/app/pages/auto-grid.vue +311 -0
- package/app/pages/index.vue +5 -0
- package/package.json +1 -1
- package/app/components/01.atoms/grids/data-grid/DataGrid.vue +0 -39
package/.claude/settings.json
CHANGED
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"Bash(node -e ':*)",
|
|
27
27
|
"Bash(git ls-tree *)",
|
|
28
28
|
"Bash(node -p \"require\\('./package.json'\\).version\")",
|
|
29
|
-
"Bash(git status *)"
|
|
29
|
+
"Bash(git status *)",
|
|
30
|
+
"Bash(cp /Users/simoncornforth/websites/nuxt-components/.claude/skills/components/data-grid.md /Users/simoncornforth/websites/nuxt-components/.claude/skills/components/auto-grid.md)",
|
|
31
|
+
"Bash(rm /Users/simoncornforth/websites/nuxt-components/.claude/skills/components/data-grid.md)"
|
|
30
32
|
],
|
|
31
33
|
"additionalDirectories": []
|
|
32
34
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AutoGrid Component
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
`
|
|
5
|
+
`AutoGrid` 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
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -11,21 +11,21 @@
|
|
|
11
11
|
Pass any number of named slots — the component renders each one in document order inside the grid.
|
|
12
12
|
|
|
13
13
|
```vue
|
|
14
|
-
<
|
|
14
|
+
<AutoGrid>
|
|
15
15
|
<template #item-1><StatCard label="Revenue" value="£24,500" /></template>
|
|
16
16
|
<template #item-2><StatCard label="Clients" value="142" /></template>
|
|
17
17
|
<template #item-3><StatCard label="Bookings" value="38" /></template>
|
|
18
|
-
</
|
|
18
|
+
</AutoGrid>
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
When filling from a data array, use a dynamic slot name in a `v-for`:
|
|
22
22
|
|
|
23
23
|
```vue
|
|
24
|
-
<
|
|
24
|
+
<AutoGrid>
|
|
25
25
|
<template v-for="(item, i) in stats" #[`item-${i}`] :key="i">
|
|
26
26
|
<StatCard :label="item.label" :value="item.value" />
|
|
27
27
|
</template>
|
|
28
|
-
</
|
|
28
|
+
</AutoGrid>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
---
|
|
@@ -34,10 +34,10 @@ When filling from a data array, use a dynamic slot name in a `v-for`:
|
|
|
34
34
|
|
|
35
35
|
> **Hyphenation rule**: Vue's ESLint config enforces `vue/attribute-hyphenation`. Always write camelCase prop names hyphenated in templates: `:style-class-passthrough`.
|
|
36
36
|
|
|
37
|
-
| Prop (template form)
|
|
38
|
-
|
|
39
|
-
| `tag`
|
|
40
|
-
| `:style-class-passthrough` | `string \| string[]`
|
|
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
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
@@ -45,25 +45,27 @@ When filling from a data array, use a dynamic slot name in a `v-for`:
|
|
|
45
45
|
|
|
46
46
|
Override these via `style` attribute or a `styleClassPassthrough` class in a consuming `<style>` block.
|
|
47
47
|
|
|
48
|
-
| Property
|
|
49
|
-
|
|
50
|
-
| `--
|
|
51
|
-
| `--
|
|
48
|
+
| Property | Default | Notes |
|
|
49
|
+
| ------------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
|
50
|
+
| `--auto-grid-min-col-size` | `250px` | Minimum column width; browser auto-fits as many columns as will fit. |
|
|
51
|
+
| `--auto-grid-gap` | `1rem` | Grid gap between items. |
|
|
52
52
|
|
|
53
53
|
### Fixed column count
|
|
54
54
|
|
|
55
|
+
Override `grid-template-columns` directly — there is no single token for this:
|
|
56
|
+
|
|
55
57
|
```vue
|
|
56
|
-
<
|
|
58
|
+
<AutoGrid style="grid-template-columns: repeat(3, 1fr); --auto-grid-gap: 2.4rem;">
|
|
57
59
|
...
|
|
58
|
-
</
|
|
60
|
+
</AutoGrid>
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
### Narrower minimum item width
|
|
62
64
|
|
|
63
65
|
```vue
|
|
64
|
-
<
|
|
66
|
+
<AutoGrid style="--auto-grid-min-col-size: 180px;">
|
|
65
67
|
...
|
|
66
|
-
</
|
|
68
|
+
</AutoGrid>
|
|
67
69
|
```
|
|
68
70
|
|
|
69
71
|
---
|
|
@@ -73,7 +75,7 @@ Override these via `style` attribute or a `styleClassPassthrough` class in a con
|
|
|
73
75
|
### Stat cards (default auto-fit)
|
|
74
76
|
|
|
75
77
|
```vue
|
|
76
|
-
<
|
|
78
|
+
<AutoGrid>
|
|
77
79
|
<template #revenue>
|
|
78
80
|
<div class="stat-card">
|
|
79
81
|
<span class="stat-card-label">Revenue</span>
|
|
@@ -86,17 +88,17 @@ Override these via `style` attribute or a `styleClassPassthrough` class in a con
|
|
|
86
88
|
<span class="stat-card-value">142</span>
|
|
87
89
|
</div>
|
|
88
90
|
</template>
|
|
89
|
-
</
|
|
91
|
+
</AutoGrid>
|
|
90
92
|
```
|
|
91
93
|
|
|
92
94
|
### Semantic section with auto aria-labelledby
|
|
93
95
|
|
|
94
96
|
```vue
|
|
95
|
-
<
|
|
97
|
+
<AutoGrid tag="section">
|
|
96
98
|
<!-- aria-labelledby is wired automatically via useAriaLabelledById -->
|
|
97
99
|
<template #item-1><div>Item 1</div></template>
|
|
98
100
|
<template #item-2><div>Item 2</div></template>
|
|
99
|
-
</
|
|
101
|
+
</AutoGrid>
|
|
100
102
|
```
|
|
101
103
|
|
|
102
104
|
### Data-driven grid
|
|
@@ -105,20 +107,20 @@ Override these via `style` attribute or a `styleClassPassthrough` class in a con
|
|
|
105
107
|
<script setup lang="ts">
|
|
106
108
|
const stats = [
|
|
107
109
|
{ id: "revenue", label: "Revenue", value: "£24,500" },
|
|
108
|
-
{ id: "clients", label: "Clients",
|
|
110
|
+
{ id: "clients", label: "Clients", value: "142" },
|
|
109
111
|
{ id: "bookings", label: "Bookings", value: "38" },
|
|
110
112
|
];
|
|
111
113
|
</script>
|
|
112
114
|
|
|
113
115
|
<template>
|
|
114
|
-
<
|
|
116
|
+
<AutoGrid>
|
|
115
117
|
<template v-for="stat in stats" #[stat.id] :key="stat.id">
|
|
116
118
|
<div class="stat-card">
|
|
117
119
|
<span class="stat-card-label">{{ stat.label }}</span>
|
|
118
120
|
<span class="stat-card-value">{{ stat.value }}</span>
|
|
119
121
|
</div>
|
|
120
122
|
</template>
|
|
121
|
-
</
|
|
123
|
+
</AutoGrid>
|
|
122
124
|
</template>
|
|
123
125
|
```
|
|
124
126
|
|
|
@@ -137,19 +139,19 @@ See [component-aria-landmark.md](../component-aria-landmark.md) for the full lan
|
|
|
137
139
|
## Local style override scaffold
|
|
138
140
|
|
|
139
141
|
```vue
|
|
140
|
-
<
|
|
142
|
+
<AutoGrid :style-class-passthrough="['my-auto-grid']">
|
|
141
143
|
...
|
|
142
|
-
</
|
|
144
|
+
</AutoGrid>
|
|
143
145
|
|
|
144
146
|
<style>
|
|
145
|
-
/* ───
|
|
147
|
+
/* ─── AutoGrid local overrides ──────────────────────────────────────
|
|
146
148
|
Use CSS custom properties for layout, not utility classes.
|
|
147
149
|
Delete this block if no overrides are needed.
|
|
148
150
|
─────────────────────────────────────────────────────────────────── */
|
|
149
|
-
.
|
|
150
|
-
&.my-
|
|
151
|
-
--
|
|
152
|
-
--
|
|
151
|
+
.auto-grid {
|
|
152
|
+
&.my-auto-grid {
|
|
153
|
+
--auto-grid-min-col-size: 200px;
|
|
154
|
+
--auto-grid-gap: 2rem;
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
</style>
|
|
@@ -163,5 +165,5 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
163
165
|
|
|
164
166
|
- Auto-imported in Nuxt — no manual import needed.
|
|
165
167
|
- Slot names can be anything — semantic (`#revenue`) or indexed (`#item-0`). Document order determines render order.
|
|
166
|
-
- `--
|
|
167
|
-
-
|
|
168
|
+
- `--auto-grid-min-col-size` controls the minimum column width; `auto-fit` fills as many columns as will fit.
|
|
169
|
+
- To fix the column count, override `grid-template-columns` directly (e.g. `style="grid-template-columns: repeat(3, 1fr)"`) — there is no single token for this.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# CSS Animation Utilities
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Scroll-driven animation utility classes bundled with the layer. Apply a class to any element to get a CSS-only, scroll-linked animation — no JavaScript required. All utilities use the [CSS Scroll-Driven Animations](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline) spec (`animation-timeline: view()`).
|
|
6
|
+
|
|
7
|
+
**Browser support**: Chrome 115+, Firefox 110+, Safari 18+. No polyfill exists — use `@supports` for graceful degradation where needed.
|
|
8
|
+
|
|
9
|
+
All utilities are wrapped in `@media (prefers-reduced-motion: no-preference)` — animations are automatically disabled for users who prefer reduced motion.
|
|
10
|
+
|
|
11
|
+
## Available classes
|
|
12
|
+
|
|
13
|
+
### `.animation-scroller-x`
|
|
14
|
+
|
|
15
|
+
Scales and fades items relative to their position in a **horizontal scroll container** (carousel, horizontal list). Items at the edges are small and faint; items at the centre are full size and opaque.
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
animation-timeline: view(x);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Position | `opacity` | `scale` |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Edges (0 %, 100 %) | 0.25 | 0.5 |
|
|
24
|
+
| Centre (35 %–65 %) | 1 | 1 |
|
|
25
|
+
|
|
26
|
+
#### Usage
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<!-- Scrollable container — overflow-x must be auto or scroll -->
|
|
30
|
+
<div class="carousel-track" style="overflow-x: auto; display: flex;">
|
|
31
|
+
<div class="animation-scroller-x">Item 1</div>
|
|
32
|
+
<div class="animation-scroller-x">Item 2</div>
|
|
33
|
+
<div class="animation-scroller-x">Item 3</div>
|
|
34
|
+
</div>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The animation is driven by the element's position inside its nearest scrollport on the x-axis. Add `.animation-scroller-x` to each **child** — not the container.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
### `.entry-zoom-reveal`
|
|
42
|
+
|
|
43
|
+
Fades and zooms an element in as it scrolls into the vertical viewport (bottom 30 % inset to top 5 %).
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<div class="entry-zoom-reveal">Content revealed on scroll</div>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `fill: both` — element stays hidden before entry, visible after exit
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### `.entry-slide-in`
|
|
54
|
+
|
|
55
|
+
Slides an element up from 200 px below as it scrolls into view.
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<div class="entry-slide-in">Slides up on scroll</div>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### `.entry-exit-blur`
|
|
64
|
+
|
|
65
|
+
Blurs an element as it enters and exits the vertical viewport; sharp in the centre (45 %–55 %).
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<div class="entry-exit-blur">Sharp in view, blurred at edges</div>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### `.auto-rotate`
|
|
74
|
+
|
|
75
|
+
Rotates an element 0 → 360 ° as it scrolls through the vertical viewport.
|
|
76
|
+
|
|
77
|
+
```html
|
|
78
|
+
<div class="auto-rotate">
|
|
79
|
+
<img src="/logo.svg" alt="" />
|
|
80
|
+
</div>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- All classes use `animation-timeline: view()` (vertical) **except** `.animation-scroller-x` which uses `view(x)`.
|
|
86
|
+
- The `scroller` keyframe name used by `.animation-scroller-x` is global — avoid re-declaring `@keyframes scroller` in your own CSS.
|
|
87
|
+
- Utility classes are included automatically when you extend the layer — no explicit import needed in your app.
|
package/.claude/skills/index.md
CHANGED
|
@@ -34,6 +34,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
34
34
|
├── component-local-style-override.md — styleClassPassthrough + scoped style block for per-usage visual customisation
|
|
35
35
|
├── component-prop-driven-container-layout.md — vary CSS grid layout inside @container queries using data-* attribute selectors
|
|
36
36
|
├── css-grid-max-width-gutters.md — cap a centre grid column width by growing gutters, with start/center alignment variants
|
|
37
|
+
├── css-animation-utilities.md — scroll-driven animation utility classes: scroller-x (carousel), entry-zoom-reveal, entry-slide-in, entry-exit-blur, auto-rotate
|
|
37
38
|
├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/main/article/aside tags
|
|
38
39
|
├── component-export-types.md — move inline component types to app/types/components/ barrel for consumer imports
|
|
39
40
|
├── component-inline-action-button.md — InputButtonCore variant="inline" pattern for buttons embedded in custom input wrappers
|
|
@@ -76,7 +77,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
76
77
|
├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
|
|
77
78
|
├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
|
|
78
79
|
├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
|
|
79
|
-
├──
|
|
80
|
+
├── auto-grid.md — AutoGrid: auto-fit responsive grid, $slots iteration, --auto-grid-min-col-size/gap tokens, semantic tag + aria
|
|
80
81
|
├── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens
|
|
81
82
|
└── samaritan-prompt-mixed.md — SamaritanPromptMixed: animated text prompt, typewriter/word-pulse effects, MessageConfig API, aria-live accessibility, CSS tokens
|
|
82
83
|
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
@keyframes scroller {
|
|
2
|
+
0%,
|
|
3
|
+
100% {
|
|
4
|
+
opacity: 0.25;
|
|
5
|
+
scale: 0.5;
|
|
6
|
+
}
|
|
7
|
+
35%,
|
|
8
|
+
65% {
|
|
9
|
+
opacity: 1;
|
|
10
|
+
scale: 1;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Used to scroll scale elements when scrolling x-axis like a carousel. */
|
|
15
|
+
|
|
16
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
17
|
+
.animation-scroller-x {
|
|
18
|
+
animation: scroller linear both;
|
|
19
|
+
animation-timeline: view(x);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
2
|
+
.entry-exit-blur {
|
|
3
|
+
animation: entryExitBlurAnimation linear both;
|
|
4
|
+
animation-timeline: view();
|
|
5
|
+
}
|
|
4
6
|
}
|
|
5
7
|
@keyframes entryExitBlurAnimation {
|
|
6
8
|
0% {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
13
|
+
.entry-zoom-reveal {
|
|
14
|
+
animation: entryZoomRevealAnimation both;
|
|
15
|
+
animation-timeline: view(70% 5%);
|
|
16
|
+
}
|
|
15
17
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
@import
|
|
2
|
-
@import
|
|
3
|
-
@import
|
|
4
|
-
@import
|
|
1
|
+
@import "./_entry-zoom-reveal";
|
|
2
|
+
@import "./_entry-slide-in";
|
|
3
|
+
@import "./_entry-exit-blur";
|
|
4
|
+
@import "./_auto-rotate";
|
|
5
|
+
@import "./_animation-scroller-x.css";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="auto-grid" :class="[elementClasses, { 'is-responsive': isResponsive }]" :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
|
+
isResponsive?: boolean;
|
|
11
|
+
styleClassPassthrough?: string | string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
tag: "div",
|
|
16
|
+
isResponsive: false,
|
|
17
|
+
styleClassPassthrough: () => [],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
21
|
+
const { ariaLabelledby } = useAriaLabelledById(props.tag);
|
|
22
|
+
|
|
23
|
+
watch(
|
|
24
|
+
() => props.styleClassPassthrough,
|
|
25
|
+
() => resetElementClasses(props.styleClassPassthrough)
|
|
26
|
+
);
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<style lang="css">
|
|
30
|
+
@layer components {
|
|
31
|
+
.auto-grid {
|
|
32
|
+
--auto-grid-min-col-size-small: 250px;
|
|
33
|
+
--auto-grid-min-col-size-default: 300px;
|
|
34
|
+
--auto-grid-min-col-size-large: 350px;
|
|
35
|
+
--auto-grid-gap: 1rem;
|
|
36
|
+
|
|
37
|
+
display: grid;
|
|
38
|
+
gap: var(--auto-grid-gap);
|
|
39
|
+
|
|
40
|
+
&:not(.is-responsive) {
|
|
41
|
+
grid-template-columns: repeat(auto-fit, minmax(min(var(--auto-grid-min-col-size-default), 100%), 1fr));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
&.is-responsive {
|
|
45
|
+
grid-template-columns: repeat(auto-fit, minmax(min(var(--auto-grid-min-col-size-small), 100%), 1fr));
|
|
46
|
+
|
|
47
|
+
@container (width >= 768px) {
|
|
48
|
+
grid-template-columns: repeat(auto-fit, minmax(min(var(--auto-grid-min-col-size-default), 100%), 1fr));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@container (width >= 1024px) {
|
|
52
|
+
grid-template-columns: repeat(auto-fit, minmax(min(var(--auto-grid-min-col-size-large), 100%), 1fr));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
</style>
|
package/app/components/01.atoms/grids/data-grid/stories/{DataGrid.stories.ts → AutoGrid.stories.ts}
RENAMED
|
@@ -1,15 +1,53 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
1
2
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
-
import
|
|
3
|
+
import AutoGridComponent from "../AutoGrid.vue";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
interface AutoGridArgs {
|
|
6
|
+
tag: "div" | "section" | "article" | "main";
|
|
7
|
+
isResponsive: boolean;
|
|
8
|
+
styleClassPassthrough: string[];
|
|
9
|
+
minColSizeSmall: string;
|
|
10
|
+
minColSizeDefault: string;
|
|
11
|
+
minColSizeLarge: string;
|
|
12
|
+
gap: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const meta: Meta<AutoGridArgs> = {
|
|
16
|
+
title: "Atoms/Grids/AutoGrid",
|
|
17
|
+
component: AutoGridComponent,
|
|
7
18
|
argTypes: {
|
|
8
19
|
tag: {
|
|
9
20
|
control: { type: "select" },
|
|
10
21
|
options: ["div", "section", "article", "main"],
|
|
11
22
|
description: "HTML tag to render as",
|
|
12
|
-
table: { category: "
|
|
23
|
+
table: { category: "Props" },
|
|
24
|
+
},
|
|
25
|
+
isResponsive: {
|
|
26
|
+
control: { type: "boolean" },
|
|
27
|
+
description:
|
|
28
|
+
"Enables container-query breakpoints: `--auto-grid-min-col-size-small` below 768px, default 768px+, large 1024px+. Requires a `container-type: inline-size` ancestor.",
|
|
29
|
+
table: { category: "Props" },
|
|
30
|
+
},
|
|
31
|
+
minColSizeSmall: {
|
|
32
|
+
control: { type: "text" },
|
|
33
|
+
description: "`--auto-grid-min-col-size-small` — minimum column width in responsive mode below 768px",
|
|
34
|
+
table: { category: "CSS Tokens" },
|
|
35
|
+
},
|
|
36
|
+
minColSizeDefault: {
|
|
37
|
+
control: { type: "text" },
|
|
38
|
+
description:
|
|
39
|
+
"`--auto-grid-min-col-size-default` — controls column width when `isResponsive` is **off**. Also used as the mid-size step in responsive mode (768px+).",
|
|
40
|
+
table: { category: "CSS Tokens" },
|
|
41
|
+
},
|
|
42
|
+
minColSizeLarge: {
|
|
43
|
+
control: { type: "text" },
|
|
44
|
+
description: "`--auto-grid-min-col-size-large` — minimum column width in responsive mode 1024px+",
|
|
45
|
+
table: { category: "CSS Tokens" },
|
|
46
|
+
},
|
|
47
|
+
gap: {
|
|
48
|
+
control: { type: "text" },
|
|
49
|
+
description: "`--auto-grid-gap` — gap between grid items",
|
|
50
|
+
table: { category: "CSS Tokens" },
|
|
13
51
|
},
|
|
14
52
|
styleClassPassthrough: {
|
|
15
53
|
table: { disable: true },
|
|
@@ -17,13 +55,18 @@ const meta: Meta<typeof DataGridComponent> = {
|
|
|
17
55
|
},
|
|
18
56
|
args: {
|
|
19
57
|
tag: "div",
|
|
58
|
+
isResponsive: false,
|
|
59
|
+
minColSizeSmall: "250px",
|
|
60
|
+
minColSizeDefault: "300px",
|
|
61
|
+
minColSizeLarge: "350px",
|
|
62
|
+
gap: "1rem",
|
|
20
63
|
styleClassPassthrough: [],
|
|
21
64
|
},
|
|
22
65
|
parameters: {
|
|
23
66
|
docs: {
|
|
24
67
|
description: {
|
|
25
68
|
component:
|
|
26
|
-
"A responsive auto-fit grid container.
|
|
69
|
+
"A responsive auto-fit grid container. Override `--auto-grid-min-col-size-default` and `--auto-grid-gap` to control layout. Enable `isResponsive` for container-query-based column steps using `--auto-grid-min-col-size-small` and `--auto-grid-min-col-size-large`.",
|
|
27
70
|
},
|
|
28
71
|
},
|
|
29
72
|
},
|
|
@@ -33,22 +76,24 @@ export default meta;
|
|
|
33
76
|
|
|
34
77
|
const cardStyle =
|
|
35
78
|
"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 =
|
|
79
|
+
const labelStyle =
|
|
80
|
+
"font-size: 1.2rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280;";
|
|
37
81
|
const valueStyle = "font-size: 2.4rem; font-weight: 700; color: #111827;";
|
|
38
82
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
styleClassPassthrough: string[];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const Template: StoryFn<DataGridArgs> = (args) => ({
|
|
45
|
-
components: { DataGridComponent },
|
|
83
|
+
const Template: StoryFn<AutoGridArgs> = (args) => ({
|
|
84
|
+
components: { AutoGridComponent },
|
|
46
85
|
setup() {
|
|
47
|
-
|
|
86
|
+
const cssTokenStyle = computed(() => ({
|
|
87
|
+
"--auto-grid-min-col-size-small": args.minColSizeSmall,
|
|
88
|
+
"--auto-grid-min-col-size-default": args.minColSizeDefault,
|
|
89
|
+
"--auto-grid-min-col-size-large": args.minColSizeLarge,
|
|
90
|
+
"--auto-grid-gap": args.gap,
|
|
91
|
+
}));
|
|
92
|
+
return { args, cssTokenStyle };
|
|
48
93
|
},
|
|
49
94
|
template: `
|
|
50
|
-
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
51
|
-
<
|
|
95
|
+
<div style="padding: 3.2rem; background: #f9fafb; container-type: inline-size;">
|
|
96
|
+
<AutoGridComponent :tag="args.tag" :is-responsive="args.isResponsive" :style="cssTokenStyle" :style-class-passthrough="args.styleClassPassthrough">
|
|
52
97
|
<template #item-1>
|
|
53
98
|
<div style="${cardStyle}">
|
|
54
99
|
<span style="${labelStyle}">Revenue</span>
|
|
@@ -73,7 +118,7 @@ const Template: StoryFn<DataGridArgs> = (args) => ({
|
|
|
73
118
|
<span style="${valueStyle}">4.9</span>
|
|
74
119
|
</div>
|
|
75
120
|
</template>
|
|
76
|
-
</
|
|
121
|
+
</AutoGridComponent>
|
|
77
122
|
</div>
|
|
78
123
|
`,
|
|
79
124
|
});
|
|
@@ -82,7 +127,18 @@ export const Default = Template.bind({});
|
|
|
82
127
|
Default.parameters = {
|
|
83
128
|
docs: {
|
|
84
129
|
description: {
|
|
85
|
-
story: "Default grid with four stat cards. Columns auto-fit to available space at a minimum of
|
|
130
|
+
story: "Default grid with four stat cards. Columns auto-fit to available space at a minimum of 300px each.",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const Responsive = Template.bind({});
|
|
136
|
+
Responsive.args = { isResponsive: true };
|
|
137
|
+
Responsive.parameters = {
|
|
138
|
+
docs: {
|
|
139
|
+
description: {
|
|
140
|
+
story:
|
|
141
|
+
"With `isResponsive` enabled, column width steps through three sizes based on container width. Resize the story viewport to see the breakpoints in action.",
|
|
86
142
|
},
|
|
87
143
|
},
|
|
88
144
|
};
|
|
@@ -91,10 +147,10 @@ export const TwoItems = Template.bind({});
|
|
|
91
147
|
TwoItems.storyName = "Two Items";
|
|
92
148
|
TwoItems.decorators = [
|
|
93
149
|
() => ({
|
|
94
|
-
components: {
|
|
150
|
+
components: { AutoGridComponent },
|
|
95
151
|
template: `
|
|
96
|
-
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
97
|
-
<
|
|
152
|
+
<div style="padding: 3.2rem; background: #f9fafb; container-type: inline-size;">
|
|
153
|
+
<AutoGridComponent>
|
|
98
154
|
<template #item-1>
|
|
99
155
|
<div style="${cardStyle}">
|
|
100
156
|
<span style="${labelStyle}">Revenue</span>
|
|
@@ -107,7 +163,7 @@ TwoItems.decorators = [
|
|
|
107
163
|
<span style="${valueStyle}">142</span>
|
|
108
164
|
</div>
|
|
109
165
|
</template>
|
|
110
|
-
</
|
|
166
|
+
</AutoGridComponent>
|
|
111
167
|
</div>
|
|
112
168
|
`,
|
|
113
169
|
}),
|
|
@@ -116,15 +172,15 @@ TwoItems.parameters = {
|
|
|
116
172
|
docs: { description: { story: "Grid with two items — auto-fit spreads them to fill available columns." } },
|
|
117
173
|
};
|
|
118
174
|
|
|
119
|
-
export const NarrowContainer: StoryFn<
|
|
120
|
-
components: {
|
|
175
|
+
export const NarrowContainer: StoryFn<AutoGridArgs> = (args) => ({
|
|
176
|
+
components: { AutoGridComponent },
|
|
121
177
|
setup() {
|
|
122
178
|
return { args };
|
|
123
179
|
},
|
|
124
180
|
template: `
|
|
125
181
|
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
126
|
-
<div style="max-width: 400px;">
|
|
127
|
-
<
|
|
182
|
+
<div style="max-width: 400px; container-type: inline-size;">
|
|
183
|
+
<AutoGridComponent :tag="args.tag">
|
|
128
184
|
<template #item-1>
|
|
129
185
|
<div style="${cardStyle}">
|
|
130
186
|
<span style="${labelStyle}">Revenue</span>
|
|
@@ -143,7 +199,7 @@ export const NarrowContainer: StoryFn<DataGridArgs> = (args) => ({
|
|
|
143
199
|
<span style="${valueStyle}">38</span>
|
|
144
200
|
</div>
|
|
145
201
|
</template>
|
|
146
|
-
</
|
|
202
|
+
</AutoGridComponent>
|
|
147
203
|
</div>
|
|
148
204
|
</div>
|
|
149
205
|
`,
|
|
@@ -151,19 +207,19 @@ export const NarrowContainer: StoryFn<DataGridArgs> = (args) => ({
|
|
|
151
207
|
NarrowContainer.parameters = {
|
|
152
208
|
docs: {
|
|
153
209
|
description: {
|
|
154
|
-
story: "In a 400px container the auto-fit columns stack to a single column once items fall below
|
|
210
|
+
story: "In a 400px container the auto-fit columns stack to a single column once items fall below 300px.",
|
|
155
211
|
},
|
|
156
212
|
},
|
|
157
213
|
};
|
|
158
214
|
|
|
159
|
-
export const CustomColumns: StoryFn<
|
|
160
|
-
components: {
|
|
215
|
+
export const CustomColumns: StoryFn<AutoGridArgs> = (args) => ({
|
|
216
|
+
components: { AutoGridComponent },
|
|
161
217
|
setup() {
|
|
162
218
|
return { args };
|
|
163
219
|
},
|
|
164
220
|
template: `
|
|
165
221
|
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
166
|
-
<
|
|
222
|
+
<AutoGridComponent style="grid-template-columns: repeat(3, 1fr); --auto-grid-gap: 2.4rem;" :tag="args.tag">
|
|
167
223
|
<template #item-1>
|
|
168
224
|
<div style="${cardStyle}">
|
|
169
225
|
<span style="${labelStyle}">Revenue</span>
|
|
@@ -182,7 +238,7 @@ export const CustomColumns: StoryFn<DataGridArgs> = (args) => ({
|
|
|
182
238
|
<span style="${valueStyle}">38</span>
|
|
183
239
|
</div>
|
|
184
240
|
</template>
|
|
185
|
-
</
|
|
241
|
+
</AutoGridComponent>
|
|
186
242
|
</div>
|
|
187
243
|
`,
|
|
188
244
|
});
|
|
@@ -190,19 +246,19 @@ CustomColumns.parameters = {
|
|
|
190
246
|
docs: {
|
|
191
247
|
description: {
|
|
192
248
|
story:
|
|
193
|
-
"Override
|
|
249
|
+
"Override `grid-template-columns` and `--auto-grid-gap` via inline style to force a fixed 3-column layout with wider gaps.",
|
|
194
250
|
},
|
|
195
251
|
},
|
|
196
252
|
};
|
|
197
253
|
|
|
198
|
-
export const SemanticSection: StoryFn<
|
|
199
|
-
components: {
|
|
254
|
+
export const SemanticSection: StoryFn<AutoGridArgs> = (args) => ({
|
|
255
|
+
components: { AutoGridComponent },
|
|
200
256
|
setup() {
|
|
201
257
|
return { args };
|
|
202
258
|
},
|
|
203
259
|
template: `
|
|
204
|
-
<div style="padding: 3.2rem; background: #f9fafb;">
|
|
205
|
-
<
|
|
260
|
+
<div style="padding: 3.2rem; background: #f9fafb; container-type: inline-size;">
|
|
261
|
+
<AutoGridComponent tag="section">
|
|
206
262
|
<template #item-1>
|
|
207
263
|
<div style="${cardStyle}">
|
|
208
264
|
<span style="${labelStyle}">Revenue</span>
|
|
@@ -221,7 +277,7 @@ export const SemanticSection: StoryFn<DataGridArgs> = (args) => ({
|
|
|
221
277
|
<span style="${valueStyle}">38</span>
|
|
222
278
|
</div>
|
|
223
279
|
</template>
|
|
224
|
-
</
|
|
280
|
+
</AutoGridComponent>
|
|
225
281
|
</div>
|
|
226
282
|
`,
|
|
227
283
|
});
|
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
-
import
|
|
3
|
+
import AutoGrid from "../AutoGrid.vue";
|
|
4
4
|
|
|
5
|
-
describe("
|
|
5
|
+
describe("AutoGrid", () => {
|
|
6
6
|
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
8
|
it("mounts without error", async () => {
|
|
9
|
-
const wrapper = await mountSuspended(
|
|
9
|
+
const wrapper = await mountSuspended(AutoGrid);
|
|
10
10
|
expect(wrapper.vm).toBeTruthy();
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
it("renders correct HTML structure (default props)", async () => {
|
|
16
|
-
const wrapper = await mountSuspended(
|
|
16
|
+
const wrapper = await mountSuspended(AutoGrid);
|
|
17
17
|
expect(wrapper.html()).toMatchSnapshot();
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it("renders correct HTML structure (all props and slots set)", async () => {
|
|
21
|
-
const wrapper = await mountSuspended(
|
|
21
|
+
const wrapper = await mountSuspended(AutoGrid, {
|
|
22
22
|
props: {
|
|
23
23
|
tag: "section",
|
|
24
|
+
isResponsive: true,
|
|
24
25
|
styleClassPassthrough: ["custom-class"],
|
|
25
26
|
},
|
|
26
27
|
slots: {
|
|
@@ -35,58 +36,77 @@ describe("DataGrid", () => {
|
|
|
35
36
|
// ─── Root element ────────────────────────────────────────────────────────
|
|
36
37
|
|
|
37
38
|
it("renders a <div> as the root element by default", async () => {
|
|
38
|
-
const wrapper = await mountSuspended(
|
|
39
|
+
const wrapper = await mountSuspended(AutoGrid);
|
|
39
40
|
expect(wrapper.element.tagName).toBe("DIV");
|
|
40
41
|
});
|
|
41
42
|
|
|
42
|
-
it("always has the
|
|
43
|
-
const wrapper = await mountSuspended(
|
|
44
|
-
expect(wrapper.classes()).toContain("
|
|
43
|
+
it("always has the auto-grid class", async () => {
|
|
44
|
+
const wrapper = await mountSuspended(AutoGrid);
|
|
45
|
+
expect(wrapper.classes()).toContain("auto-grid");
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
// ─── Tag prop ────────────────────────────────────────────────────────────
|
|
48
49
|
|
|
49
50
|
it("renders a <section> when tag is section", async () => {
|
|
50
|
-
const wrapper = await mountSuspended(
|
|
51
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "section" } });
|
|
51
52
|
expect(wrapper.element.tagName).toBe("SECTION");
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
it("renders an <article> when tag is article", async () => {
|
|
55
|
-
const wrapper = await mountSuspended(
|
|
56
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "article" } });
|
|
56
57
|
expect(wrapper.element.tagName).toBe("ARTICLE");
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
it("renders a <main> when tag is main", async () => {
|
|
60
|
-
const wrapper = await mountSuspended(
|
|
61
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "main" } });
|
|
61
62
|
expect(wrapper.element.tagName).toBe("MAIN");
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
// ─── Aria ─────────────────────────────────────────────────────────────────
|
|
65
66
|
|
|
66
67
|
it("does not set aria-labelledby when tag is div", async () => {
|
|
67
|
-
const wrapper = await mountSuspended(
|
|
68
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "div" } });
|
|
68
69
|
expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
it("sets aria-labelledby when tag is section", async () => {
|
|
72
|
-
const wrapper = await mountSuspended(
|
|
73
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "section" } });
|
|
73
74
|
expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
it("sets aria-labelledby when tag is article", async () => {
|
|
77
|
-
const wrapper = await mountSuspended(
|
|
78
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "article" } });
|
|
78
79
|
expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
it("sets aria-labelledby when tag is main", async () => {
|
|
82
|
-
const wrapper = await mountSuspended(
|
|
83
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { tag: "main" } });
|
|
83
84
|
expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
// ─── isResponsive ────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
it("does not have is-responsive class by default", async () => {
|
|
90
|
+
const wrapper = await mountSuspended(AutoGrid);
|
|
91
|
+
expect(wrapper.classes()).not.toContain("is-responsive");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("adds is-responsive class when isResponsive is true", async () => {
|
|
95
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { isResponsive: true } });
|
|
96
|
+
expect(wrapper.classes()).toContain("is-responsive");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("removes is-responsive class when isResponsive changes to false", async () => {
|
|
100
|
+
const wrapper = await mountSuspended(AutoGrid, { props: { isResponsive: true } });
|
|
101
|
+
expect(wrapper.classes()).toContain("is-responsive");
|
|
102
|
+
await wrapper.setProps({ isResponsive: false });
|
|
103
|
+
expect(wrapper.classes()).not.toContain("is-responsive");
|
|
104
|
+
});
|
|
105
|
+
|
|
86
106
|
// ─── Slots ───────────────────────────────────────────────────────────────
|
|
87
107
|
|
|
88
108
|
it("renders a named slot", async () => {
|
|
89
|
-
const wrapper = await mountSuspended(
|
|
109
|
+
const wrapper = await mountSuspended(AutoGrid, {
|
|
90
110
|
slots: { "item-1": "<div class='card'>Card 1</div>" },
|
|
91
111
|
});
|
|
92
112
|
expect(wrapper.find(".card").exists()).toBe(true);
|
|
@@ -94,7 +114,7 @@ describe("DataGrid", () => {
|
|
|
94
114
|
});
|
|
95
115
|
|
|
96
116
|
it("renders multiple named slots", async () => {
|
|
97
|
-
const wrapper = await mountSuspended(
|
|
117
|
+
const wrapper = await mountSuspended(AutoGrid, {
|
|
98
118
|
slots: {
|
|
99
119
|
"item-1": "<div class='card-1'>Card 1</div>",
|
|
100
120
|
"item-2": "<div class='card-2'>Card 2</div>",
|
|
@@ -107,21 +127,21 @@ describe("DataGrid", () => {
|
|
|
107
127
|
});
|
|
108
128
|
|
|
109
129
|
it("renders no slot content when no slots are provided", async () => {
|
|
110
|
-
const wrapper = await mountSuspended(
|
|
130
|
+
const wrapper = await mountSuspended(AutoGrid);
|
|
111
131
|
expect(wrapper.text()).toBe("");
|
|
112
132
|
});
|
|
113
133
|
|
|
114
134
|
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
115
135
|
|
|
116
136
|
it("applies a single styleClassPassthrough string to the root", async () => {
|
|
117
|
-
const wrapper = await mountSuspended(
|
|
137
|
+
const wrapper = await mountSuspended(AutoGrid, {
|
|
118
138
|
props: { styleClassPassthrough: "my-grid" },
|
|
119
139
|
});
|
|
120
140
|
expect(wrapper.classes()).toContain("my-grid");
|
|
121
141
|
});
|
|
122
142
|
|
|
123
143
|
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
124
|
-
const wrapper = await mountSuspended(
|
|
144
|
+
const wrapper = await mountSuspended(AutoGrid, {
|
|
125
145
|
props: { styleClassPassthrough: ["my-grid", "mbe-32"] },
|
|
126
146
|
});
|
|
127
147
|
expect(wrapper.classes()).toContain("my-grid");
|
|
@@ -129,7 +149,7 @@ describe("DataGrid", () => {
|
|
|
129
149
|
});
|
|
130
150
|
|
|
131
151
|
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
132
|
-
const wrapper = await mountSuspended(
|
|
152
|
+
const wrapper = await mountSuspended(AutoGrid, {
|
|
133
153
|
props: { styleClassPassthrough: ["original"] },
|
|
134
154
|
});
|
|
135
155
|
expect(wrapper.classes()).toContain("original");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`AutoGrid > renders correct HTML structure (all props and slots set) 1`] = `
|
|
4
|
+
"<section class="auto-grid custom-class is-responsive" 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[`AutoGrid > renders correct HTML structure (default props) 1`] = `"<div class="auto-grid"></div>"`;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
|
-
exports[`
|
|
4
|
-
"<section class="
|
|
3
|
+
exports[`AutoGrid > renders correct HTML structure (all props and slots set) 1`] = `
|
|
4
|
+
"<section class="auto-grid custom-class" aria-labelledby="v-0-0">
|
|
5
5
|
<div>Item 1</div>
|
|
6
6
|
<div>Item 2</div>
|
|
7
7
|
<div>Item 3</div>
|
|
8
8
|
</section>"
|
|
9
9
|
`;
|
|
10
10
|
|
|
11
|
-
exports[`
|
|
11
|
+
exports[`AutoGrid > renders correct HTML structure (default props) 1`] = `"<div class="auto-grid"></div>"`;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<NuxtLayout name="default">
|
|
4
|
+
<template #layout-content>
|
|
5
|
+
|
|
6
|
+
<LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-32']">
|
|
7
|
+
<h1 class="page-heading-1">AutoGrid</h1>
|
|
8
|
+
<p class="page-body-normal">
|
|
9
|
+
Responsive auto-fit CSS grid wrapper. Columns auto-fit to a minimum width; when
|
|
10
|
+
<code>is-responsive</code> is enabled, the minimum shifts at container breakpoints via
|
|
11
|
+
<code>@container</code> queries.
|
|
12
|
+
</p>
|
|
13
|
+
</LayoutRow>
|
|
14
|
+
|
|
15
|
+
<div v-if="isDev" class="qa-panel">
|
|
16
|
+
<details class="qa-panel__details">
|
|
17
|
+
<summary class="qa-panel__summary">
|
|
18
|
+
<span class="qa-panel__title">QA — AutoGrid</span>
|
|
19
|
+
<code class="qa-panel__status">
|
|
20
|
+
tag:{{ qaTag }} · responsive:{{ qaIsResponsive ? "on" : "off" }} · small:{{ qaMinColSizeSmall }} · default:{{ qaMinColSizeDefault }} · large:{{ qaMinColSizeLarge }} · gap:{{ qaGap }}
|
|
21
|
+
</code>
|
|
22
|
+
</summary>
|
|
23
|
+
<div class="qa-panel__body">
|
|
24
|
+
|
|
25
|
+
<div class="qa-panel__group">
|
|
26
|
+
<span class="qa-panel__label">tag</span>
|
|
27
|
+
<div class="qa-panel__chips">
|
|
28
|
+
<button
|
|
29
|
+
v-for="opt in tagOptions"
|
|
30
|
+
:key="opt"
|
|
31
|
+
class="qa-panel__chip"
|
|
32
|
+
:class="{ 'is-active': qaTag === opt }"
|
|
33
|
+
@click="qaTag = opt"
|
|
34
|
+
>{{ opt }}</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="qa-panel__group">
|
|
39
|
+
<span class="qa-panel__label">is-responsive</span>
|
|
40
|
+
<div class="qa-panel__chips">
|
|
41
|
+
<button
|
|
42
|
+
v-for="opt in [true, false]"
|
|
43
|
+
:key="String(opt)"
|
|
44
|
+
class="qa-panel__chip"
|
|
45
|
+
:class="{ 'is-active': qaIsResponsive === opt }"
|
|
46
|
+
@click="qaIsResponsive = opt"
|
|
47
|
+
>{{ opt ? "on" : "off" }}</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="qa-panel__group">
|
|
52
|
+
<span class="qa-panel__label">min-col-size-small</span>
|
|
53
|
+
<div class="qa-panel__chips">
|
|
54
|
+
<button
|
|
55
|
+
v-for="preset in sizePresets"
|
|
56
|
+
:key="preset"
|
|
57
|
+
class="qa-panel__chip"
|
|
58
|
+
:class="{ 'is-active': qaMinColSizeSmall === preset }"
|
|
59
|
+
@click="qaMinColSizeSmall = preset"
|
|
60
|
+
>{{ preset }}</button>
|
|
61
|
+
</div>
|
|
62
|
+
<input v-model="qaMinColSizeSmall" placeholder="e.g. 200px" class="qa-panel__input" />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="qa-panel__group">
|
|
66
|
+
<span class="qa-panel__label">min-col-size-default</span>
|
|
67
|
+
<div class="qa-panel__chips">
|
|
68
|
+
<button
|
|
69
|
+
v-for="preset in sizePresets"
|
|
70
|
+
:key="preset"
|
|
71
|
+
class="qa-panel__chip"
|
|
72
|
+
:class="{ 'is-active': qaMinColSizeDefault === preset }"
|
|
73
|
+
@click="qaMinColSizeDefault = preset"
|
|
74
|
+
>{{ preset }}</button>
|
|
75
|
+
</div>
|
|
76
|
+
<input v-model="qaMinColSizeDefault" placeholder="e.g. 300px" class="qa-panel__input" />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="qa-panel__group">
|
|
80
|
+
<span class="qa-panel__label">min-col-size-large</span>
|
|
81
|
+
<div class="qa-panel__chips">
|
|
82
|
+
<button
|
|
83
|
+
v-for="preset in sizePresets"
|
|
84
|
+
:key="preset"
|
|
85
|
+
class="qa-panel__chip"
|
|
86
|
+
:class="{ 'is-active': qaMinColSizeLarge === preset }"
|
|
87
|
+
@click="qaMinColSizeLarge = preset"
|
|
88
|
+
>{{ preset }}</button>
|
|
89
|
+
</div>
|
|
90
|
+
<input v-model="qaMinColSizeLarge" placeholder="e.g. 350px" class="qa-panel__input" />
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div class="qa-panel__group">
|
|
94
|
+
<span class="qa-panel__label">gap</span>
|
|
95
|
+
<div class="qa-panel__chips">
|
|
96
|
+
<button
|
|
97
|
+
v-for="preset in gapPresets"
|
|
98
|
+
:key="preset"
|
|
99
|
+
class="qa-panel__chip"
|
|
100
|
+
:class="{ 'is-active': qaGap === preset }"
|
|
101
|
+
@click="qaGap = preset"
|
|
102
|
+
>{{ preset }}</button>
|
|
103
|
+
</div>
|
|
104
|
+
<input v-model="qaGap" placeholder="e.g. 2rem" class="qa-panel__input" />
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
</div>
|
|
108
|
+
</details>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-32']">
|
|
112
|
+
<div class="auto-grid-demo-container">
|
|
113
|
+
<AutoGrid
|
|
114
|
+
:tag="qaTag"
|
|
115
|
+
:is-responsive="qaIsResponsive"
|
|
116
|
+
:min-col-size-small="qaMinColSizeSmall"
|
|
117
|
+
:min-col-size-default="qaMinColSizeDefault"
|
|
118
|
+
:min-col-size-large="qaMinColSizeLarge"
|
|
119
|
+
:style="qaStyle"
|
|
120
|
+
>
|
|
121
|
+
<template v-for="card in statCards" #[card.id] :key="card.id">
|
|
122
|
+
<div class="stat-card">
|
|
123
|
+
<span class="stat-card__label">{{ card.label }}</span>
|
|
124
|
+
<span class="stat-card__value">{{ card.value }}</span>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
</AutoGrid>
|
|
128
|
+
</div>
|
|
129
|
+
</LayoutRow>
|
|
130
|
+
|
|
131
|
+
</template>
|
|
132
|
+
</NuxtLayout>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
135
|
+
|
|
136
|
+
<script setup lang="ts">
|
|
137
|
+
definePageMeta({ layout: false });
|
|
138
|
+
|
|
139
|
+
useHead({
|
|
140
|
+
title: "AutoGrid",
|
|
141
|
+
meta: [{ name: "description", content: "AutoGrid component demo" }],
|
|
142
|
+
bodyAttrs: { class: "auto-grid-page" },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const isDev = import.meta.dev;
|
|
146
|
+
|
|
147
|
+
const tagOptions = ["div", "section", "article", "main"] as const;
|
|
148
|
+
const qaTag = ref<"div" | "section" | "article" | "main">("div");
|
|
149
|
+
const qaIsResponsive = ref(false);
|
|
150
|
+
const qaMinColSizeSmall = ref("250px");
|
|
151
|
+
const qaMinColSizeDefault = ref("300px");
|
|
152
|
+
const qaMinColSizeLarge = ref("350px");
|
|
153
|
+
const qaGap = ref("1rem");
|
|
154
|
+
|
|
155
|
+
const sizePresets = ["150px", "200px", "250px", "300px", "350px", "400px"];
|
|
156
|
+
const gapPresets = ["0.5rem", "1rem", "1.6rem", "2.4rem", "3.2rem"];
|
|
157
|
+
|
|
158
|
+
const qaStyle = computed(() => ({
|
|
159
|
+
"--auto-grid-min-col-size-small": qaMinColSizeSmall.value,
|
|
160
|
+
"--auto-grid-min-col-size-default": qaMinColSizeDefault.value,
|
|
161
|
+
"--auto-grid-min-col-size-large": qaMinColSizeLarge.value,
|
|
162
|
+
"--auto-grid-gap": qaGap.value,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const statCards = [
|
|
166
|
+
{ id: "item-1", label: "Revenue", value: "£24,500" },
|
|
167
|
+
{ id: "item-2", label: "Clients", value: "142" },
|
|
168
|
+
{ id: "item-3", label: "Bookings", value: "38" },
|
|
169
|
+
{ id: "item-4", label: "Avg. Rating", value: "4.9" },
|
|
170
|
+
{ id: "item-5", label: "New Users", value: "76" },
|
|
171
|
+
{ id: "item-6", label: "Retention", value: "91%" },
|
|
172
|
+
];
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<style lang="css">
|
|
176
|
+
.auto-grid-page {
|
|
177
|
+
code {
|
|
178
|
+
font-family: monospace;
|
|
179
|
+
font-size: 0.9em;
|
|
180
|
+
background: var(--slate-02);
|
|
181
|
+
padding: 0.1em 0.4em;
|
|
182
|
+
border-radius: 0.3rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.auto-grid-demo-container {
|
|
186
|
+
container-type: inline-size;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.stat-card {
|
|
190
|
+
display: flex;
|
|
191
|
+
flex-direction: column;
|
|
192
|
+
gap: 0.8rem;
|
|
193
|
+
padding: 2.4rem;
|
|
194
|
+
background: white;
|
|
195
|
+
border-radius: 0.8rem;
|
|
196
|
+
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.stat-card__label {
|
|
200
|
+
font-size: 1.2rem;
|
|
201
|
+
font-weight: 600;
|
|
202
|
+
text-transform: uppercase;
|
|
203
|
+
letter-spacing: 0.05em;
|
|
204
|
+
color: var(--slate-08, #6b7280);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.stat-card__value {
|
|
208
|
+
font-size: 2.4rem;
|
|
209
|
+
font-weight: 700;
|
|
210
|
+
color: var(--slate-12, #111827);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ── QA Panel ──────────────────────────────────────────────────── */
|
|
214
|
+
|
|
215
|
+
.qa-panel {
|
|
216
|
+
background: oklch(15% 0 0);
|
|
217
|
+
color: white;
|
|
218
|
+
font-size: 1.3rem;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.qa-panel__details {
|
|
222
|
+
padding: 1rem 2rem;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.qa-panel__summary {
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
gap: 1.6rem;
|
|
230
|
+
list-style: none;
|
|
231
|
+
user-select: none;
|
|
232
|
+
|
|
233
|
+
&::-webkit-details-marker { display: none; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.qa-panel__title {
|
|
237
|
+
font-weight: 600;
|
|
238
|
+
font-size: 1.1rem;
|
|
239
|
+
text-transform: uppercase;
|
|
240
|
+
letter-spacing: 0.08em;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.qa-panel__status {
|
|
244
|
+
font-family: monospace;
|
|
245
|
+
font-size: 1.2rem;
|
|
246
|
+
background: oklch(0% 0 0 / 0.3);
|
|
247
|
+
padding: 0.2rem 0.8rem;
|
|
248
|
+
border-radius: 0.4rem;
|
|
249
|
+
user-select: text;
|
|
250
|
+
cursor: text;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.qa-panel__body {
|
|
254
|
+
display: flex;
|
|
255
|
+
flex-wrap: wrap;
|
|
256
|
+
gap: 2.4rem;
|
|
257
|
+
padding-block: 1.2rem 0.4rem;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.qa-panel__group {
|
|
261
|
+
display: flex;
|
|
262
|
+
flex-direction: column;
|
|
263
|
+
gap: 0.6rem;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.qa-panel__label {
|
|
267
|
+
font-size: 1.1rem;
|
|
268
|
+
text-transform: uppercase;
|
|
269
|
+
letter-spacing: 0.08em;
|
|
270
|
+
opacity: 0.55;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.qa-panel__chips {
|
|
274
|
+
display: flex;
|
|
275
|
+
flex-wrap: wrap;
|
|
276
|
+
gap: 0.4rem;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.qa-panel__chip {
|
|
280
|
+
font-family: monospace;
|
|
281
|
+
font-size: 1.2rem;
|
|
282
|
+
color: white;
|
|
283
|
+
background: oklch(0% 0 0 / 0.25);
|
|
284
|
+
border: 1px solid oklch(100% 0 0 / 0.18);
|
|
285
|
+
padding: 0.3rem 1rem;
|
|
286
|
+
border-radius: 0.4rem;
|
|
287
|
+
cursor: pointer;
|
|
288
|
+
transition: background 0.15s;
|
|
289
|
+
|
|
290
|
+
&:hover { background: oklch(0% 0 0 / 0.4); }
|
|
291
|
+
|
|
292
|
+
&.is-active {
|
|
293
|
+
background: oklch(55% 0.18 240);
|
|
294
|
+
border-color: oklch(55% 0.18 240);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.qa-panel__input {
|
|
299
|
+
font-family: monospace;
|
|
300
|
+
font-size: 1.2rem;
|
|
301
|
+
color: white;
|
|
302
|
+
background: oklch(0% 0 0 / 0.25);
|
|
303
|
+
border: 1px solid oklch(100% 0 0 / 0.18);
|
|
304
|
+
padding: 0.3rem 1rem;
|
|
305
|
+
border-radius: 0.4rem;
|
|
306
|
+
width: 18rem;
|
|
307
|
+
|
|
308
|
+
&::placeholder { opacity: 0.45; }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
</style>
|
package/app/pages/index.vue
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
<NuxtLink class="page-body-normal" to="/grid-stack">GridStack</NuxtLink>
|
|
8
8
|
</LayoutRow>
|
|
9
9
|
|
|
10
|
+
<LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
|
|
11
|
+
<h2 class="page-heading-2">AutoGrid</h2>
|
|
12
|
+
<NuxtLink class="page-body-normal" to="/auto-grid">AutoGrid</NuxtLink>
|
|
13
|
+
</LayoutRow>
|
|
14
|
+
|
|
10
15
|
<LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
|
|
11
16
|
<h1 class="page-heading-1">Nuxt3 Components Layer</h1>
|
|
12
17
|
<p>
|
package/package.json
CHANGED
|
@@ -1,39 +0,0 @@
|
|
|
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>
|