srcdev-nuxt-components 9.4.6 → 9.4.8

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 (109) hide show
  1. package/.claude/commands/migrate-component.md +34 -3
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/build.mjs +27 -0
  4. package/.claude/component-ledger/output.html +8 -3
  5. package/.claude/component-ledger/template.html +7 -2
  6. package/.claude/skills/component-dynamic-slots.md +1 -1
  7. package/.claude/skills/components/column-flow-grid.md +93 -0
  8. package/.claude/skills/components/entry-animation.md +88 -0
  9. package/.claude/skills/components/header-block.md +92 -0
  10. package/.claude/skills/components/input-copy.md +2 -0
  11. package/.claude/skills/components/input-text-core.md +186 -0
  12. package/.claude/skills/components/masonry-grid.md +153 -0
  13. package/.claude/skills/components/pop-over.md +100 -0
  14. package/.claude/skills/components/rotating-carousel-image.md +101 -0
  15. package/.claude/skills/components/skip-links.md +82 -0
  16. package/.claude/skills/components/tabs-core.md +187 -0
  17. package/.claude/skills/index.md +10 -1
  18. package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
  19. package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
  20. package/.vscode/srcdev-component-header-block.code-snippets +41 -0
  21. package/.vscode/srcdev-component-input-text.code-snippets +107 -0
  22. package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
  23. package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
  24. package/.vscode/srcdev-component-rotating-carousel-image.code-snippets +74 -0
  25. package/.vscode/srcdev-component-skip-links.code-snippets +55 -0
  26. package/.vscode/srcdev-component-tabs-core.code-snippets +78 -0
  27. package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css +2 -1
  28. package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
  29. package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
  30. package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
  31. package/app/components/01.atoms/animations/rotating-carousel-image/CONSUMER-STYLING.md +54 -0
  32. package/app/components/01.atoms/animations/rotating-carousel-image/RotatingCarouselImage.vue +315 -0
  33. package/app/components/01.atoms/animations/rotating-carousel-image/stories/RotatingCarouselImage.stories.ts +156 -0
  34. package/app/components/01.atoms/animations/rotating-carousel-image/tests/RotatingCarouselImage.spec.ts +230 -0
  35. package/app/components/01.atoms/animations/rotating-carousel-image/tests/__snapshots__/RotatingCarouselImage.spec.ts.snap +19 -0
  36. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
  37. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
  38. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
  39. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
  40. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
  41. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
  42. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
  43. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
  44. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
  45. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
  46. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +31 -0
  47. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +92 -0
  48. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +113 -0
  49. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +71 -0
  50. package/app/components/01.atoms/navigation/skip-links/tests/__snapshots__/SkipLinks.spec.ts.snap +15 -0
  51. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +99 -0
  52. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +272 -0
  53. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +199 -0
  54. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +274 -0
  55. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
  56. package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
  57. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
  58. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
  59. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
  60. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +43 -0
  61. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +50 -0
  62. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +111 -0
  63. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +119 -0
  64. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +5 -0
  65. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
  66. package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
  67. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
  68. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
  69. package/app/components/03.organisms/site-header/SiteHeader.vue +1 -1
  70. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +1 -1
  71. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +3 -3
  72. package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
  73. package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
  74. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
  75. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
  76. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
  77. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
  78. package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
  79. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
  80. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
  81. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
  82. package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
  83. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
  84. package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
  85. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
  86. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
  87. package/app/composables/useTabs.ts +225 -207
  88. package/app/types/components/index.ts +2 -0
  89. package/app/types/components/rotating-carousel-image.d.ts +4 -0
  90. package/app/types/components/skip-links.d.ts +4 -0
  91. package/app/types/forms/types.forms.d.ts +1 -1
  92. package/package.json +1 -1
  93. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +0 -11
  94. package/app/components/masonry-grid/MasonryGrid.vue +0 -68
  95. package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
  96. package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
  97. package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
  98. package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
  99. package/app/components/pop-over/PopOver.vue +0 -90
  100. package/app/components/rotating-carousel/RotatingCarouselImage.vue +0 -216
  101. package/app/components/skip-links/SkipLinks.vue +0 -60
  102. package/app/components/tabs/TabsCore.vue +0 -306
  103. package/app/components/typography/HeaderBlock.vue +0 -35
  104. package/app/layouts/default.vue +0 -308
  105. package/app/layouts/site-navigation-demo.vue +0 -188
  106. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/AutoGrid.vue +0 -0
  107. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/stories/AutoGrid.stories.ts +0 -0
  108. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/AutoGrid.spec.ts +0 -0
  109. /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/__snapshots__/AutoGrid.spec.ts.snap +0 -0
@@ -0,0 +1,19 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`RotatingCarouselImage > renders correct HTML structure (all props set) 1`] = `
4
+ "<section class="rotating-carousel hover-pauses" style="--_rotate-x: 10deg; --_perspective: 1200px; --_translateZ: 1200px;" role="region" aria-label="Custom carousel" tabindex="0">
5
+ <div class="sr-only">Custom instructions</div><button class="control-btn" type="button" aria-label="Pause rotation"><span class="iconify i-mdi:pause" aria-hidden="true"></span></button>
6
+ <div class="slider" style="--_quantity: 2;">
7
+ <div class="item" style="--_position: 0;"><img data-nuxt-img="" srcset="/_ipx/_/a.jpg 1x, /_ipx/_/a.jpg 2x" alt="A" src="/_ipx/_/a.jpg"></div>
8
+ <div class="item" style="--_position: 1;"><img data-nuxt-img="" srcset="/_ipx/_/b.jpg 1x, /_ipx/_/b.jpg 2x" alt="B" src="/_ipx/_/b.jpg"></div>
9
+ </div>
10
+ </section>"
11
+ `;
12
+
13
+ exports[`RotatingCarouselImage > renders correct HTML structure (default props) 1`] = `
14
+ "<div class="rotating-carousel" style="--_rotate-x: 0deg; --_perspective: 1000px; --_translateZ: 1000px;" role="region" aria-label="Rotating image carousel" tabindex="0">
15
+ <div class="sr-only">Use spacebar to pause or play the rotation.</div>
16
+ <!--v-if-->
17
+ <div class="slider" style="--_quantity: 0;"></div>
18
+ </div>"
19
+ `;
@@ -37,17 +37,18 @@ export default {
37
37
  },
38
38
  } as Meta<typeof CanvasSwitcher>;
39
39
 
40
+ // `args` is Storybook's own reactive object — bind to it directly (`args.x`) rather than
41
+ // destructuring it into local variables/refs, which would snapshot the values once at setup()
42
+ // and stop reflecting later Controls-panel changes.
40
43
  const Template: StoryFn<CanvasSwitcherStoryArgs> = (args) => ({
41
44
  components: { CanvasSwitcher },
42
45
  setup() {
43
- const { canvasName, ...otherArgs } = args;
44
- const selected = ref(canvasName);
45
- return { selected, args: otherArgs };
46
+ return { args };
46
47
  },
47
48
  template: `
48
49
  <div>
49
- <CanvasSwitcher v-model:canvas-name="selected" v-bind="args" />
50
- <p style="margin-top: 1.6rem;">Selected: {{ selected }}</p>
50
+ <CanvasSwitcher v-model:canvas-name="args.canvasName" v-bind="args" />
51
+ <p style="margin-top: 1.6rem;">Selected: {{ args.canvasName }}</p>
51
52
  </div>
52
53
  `,
53
54
  });
@@ -60,14 +61,12 @@ PreviewWidthDemo.args = {};
60
61
  PreviewWidthDemo.render = (args) => ({
61
62
  components: { CanvasSwitcher },
62
63
  setup() {
63
- const { canvasName } = args;
64
- const selected = ref(canvasName);
65
- return { selected };
64
+ return { args };
66
65
  },
67
66
  template: `
68
67
  <div>
69
- <CanvasSwitcher v-model:canvas-name="selected" />
70
- <div :class="selected" style="border: 1px dashed currentColor; margin-top: 1.6rem; padding: 1.6rem;">
68
+ <CanvasSwitcher v-model:canvas-name="args.canvasName" />
69
+ <div :class="args.canvasName" style="border: 1px dashed currentColor; margin-top: 1.6rem; padding: 1.6rem;">
71
70
  This box is constrained by the selected canvas's utility class.
72
71
  </div>
73
72
  </div>
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { computed } from "vue";
2
3
  import ContentDocs from "../ContentDocs.vue";
3
4
  import type { DocsNavItem } from "~/types/components";
4
5
 
@@ -86,19 +87,19 @@ export default {
86
87
 
87
88
  // ─── Stories ─────────────────────────────────────────────────────────────────
88
89
 
90
+ // `args` is Storybook's own reactive object — bind to it directly (`args.x`) rather than
91
+ // destructuring it into local variables/refs, which would snapshot the values once at setup()
92
+ // and stop reflecting later Controls-panel changes.
89
93
  const Template: StoryFn<ContentDocsStoryArgs> = (args) => ({
90
94
  components: { ContentDocs },
91
95
  setup() {
92
- const { activeNavItem, activePageNavItem, ...rest } = args;
93
- const activeNav = ref(activeNavItem);
94
- const activePageNav = ref(activePageNavItem);
95
- return { args: rest, activeNav, activePageNav };
96
+ return { args };
96
97
  },
97
98
  template: `
98
99
  <ContentDocs
99
100
  v-bind="args"
100
- v-model:active-nav-item="activeNav"
101
- v-model:active-page-nav-item="activePageNav"
101
+ v-model:active-nav-item="args.activeNavItem"
102
+ v-model:active-page-nav-item="args.activePageNavItem"
102
103
  >
103
104
  <template #docsContent>
104
105
  <h3 style="margin-top:0">Docs Content</h3>
@@ -140,10 +141,8 @@ CustomLabels.args = {
140
141
  export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
141
142
  components: { ContentDocs },
142
143
  setup() {
143
- const { activeNavItem, activePageNavItem, ...rest } = args;
144
- const activeNav = ref(activeNavItem);
145
- const activePageNav = ref(activePageNavItem);
146
- return { args: { ...rest, styleClassPassthrough: ["icon-at-end-demo"] }, activeNav, activePageNav };
144
+ const componentArgs = computed(() => ({ ...args, styleClassPassthrough: ["icon-at-end-demo"] }));
145
+ return { args, componentArgs };
147
146
  },
148
147
  template: `
149
148
  <div>
@@ -154,9 +153,9 @@ export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
154
153
  }
155
154
  </style>
156
155
  <ContentDocs
157
- v-bind="args"
158
- v-model:active-nav-item="activeNav"
159
- v-model:active-page-nav-item="activePageNav"
156
+ v-bind="componentArgs"
157
+ v-model:active-nav-item="args.activeNavItem"
158
+ v-model:active-page-nav-item="args.activePageNavItem"
160
159
  >
161
160
  <template #docsContent>
162
161
  <h3 style="margin-top:0">Docs Content</h3>
@@ -0,0 +1,33 @@
1
+ # ColumnFlowGrid — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ | Token | Falls back to | Controls |
6
+ |---|---|---|
7
+ | `--column-flow-grid-item-border-colour` | `var(--theme-border)` | Outline colour around each item |
8
+ | `--column-flow-grid-item-padding` | `1.2rem` | Inner padding of each item |
9
+
10
+ Column width and gap are controlled via props (`item-min-width`, `gap`, `unit`), not CSS custom
11
+ properties — they drive the CSS column layout directly and need JS-computed values, so they aren't
12
+ part of the public token API. See `.claude/skills/components/column-flow-grid.md`.
13
+
14
+ ---
15
+
16
+ ## Global theming — app-level CSS file
17
+
18
+ ```css
19
+ :where(html) {
20
+ --column-flow-grid-item-border-colour: var(--rose-05);
21
+ --column-flow-grid-item-padding: 1.6rem;
22
+ }
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Per-instance overrides
28
+
29
+ ```vue
30
+ <ColumnFlowGrid style="--column-flow-grid-item-border-colour: var(--gold-04);">
31
+ ...
32
+ </ColumnFlowGrid>
33
+ ```
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ class="column-flow-grid-wrapper"
5
+ :class="[elementClasses]"
6
+ :style="`--_column-flow-grid-gap: ${gap}${unit}; --_item-min-width: ${itemMinWidth}px`"
7
+ >
8
+ <div v-for="(_, name) in $slots" :key="name" class="column-flow-grid-item">
9
+ <slot :name="name"></slot>
10
+ </div>
11
+ </component>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ interface Props {
16
+ tag?: "div" | "section" | "article" | "main";
17
+ itemMinWidth?: number;
18
+ gap?: number;
19
+ unit?: string;
20
+ styleClassPassthrough?: string | string[];
21
+ }
22
+
23
+ const props = withDefaults(defineProps<Props>(), {
24
+ tag: "div",
25
+ itemMinWidth: 300,
26
+ gap: 1.2,
27
+ unit: "rem",
28
+ styleClassPassthrough: () => [],
29
+ });
30
+
31
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
32
+
33
+ watch(
34
+ () => props.styleClassPassthrough,
35
+ () => {
36
+ resetElementClasses(props.styleClassPassthrough);
37
+ }
38
+ );
39
+ </script>
40
+
41
+ <style lang="css">
42
+ @layer components {
43
+ .column-flow-grid-wrapper {
44
+ columns: auto var(--_item-min-width);
45
+ column-gap: var(--_column-flow-grid-gap);
46
+
47
+ .column-flow-grid-item {
48
+ break-inside: avoid;
49
+ outline: 0.1rem solid var(--column-flow-grid-item-border-colour, var(--theme-border));
50
+ padding: var(--column-flow-grid-item-padding, 1.2rem);
51
+ margin-block-end: var(--_column-flow-grid-gap);
52
+ }
53
+ }
54
+ }
55
+ </style>
@@ -0,0 +1,178 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import ColumnFlowGrid from "../ColumnFlowGrid.vue";
3
+
4
+ interface ColumnFlowGridArgs {
5
+ tag: "div" | "section" | "article" | "main";
6
+ itemMinWidth: number;
7
+ gap: number;
8
+ unit: string;
9
+ styleClassPassthrough: string[];
10
+ }
11
+
12
+ const meta: Meta<ColumnFlowGridArgs> = {
13
+ title: "Atoms/Grids/ColumnFlowGrid",
14
+ component: ColumnFlowGrid,
15
+ argTypes: {
16
+ tag: {
17
+ control: { type: "select" },
18
+ options: ["div", "section", "article", "main"],
19
+ description: "HTML tag to render as",
20
+ table: { category: "Props" },
21
+ },
22
+ itemMinWidth: {
23
+ control: { type: "select" },
24
+ options: [160, 200, 240, 300, 360, 450],
25
+ description: "Minimum column width in pixels — drives `columns: auto <value>px`",
26
+ table: { category: "Props" },
27
+ },
28
+ gap: {
29
+ control: { type: "select" },
30
+ options: [0, 0.4, 0.8, 1.2, 1.6, 2, 3],
31
+ description: "Gap between columns/items, in `unit`",
32
+ table: { category: "Props" },
33
+ },
34
+ unit: {
35
+ control: { type: "text" },
36
+ description: "CSS unit applied to `gap` (e.g. `rem`, `px`)",
37
+ table: { category: "Props" },
38
+ },
39
+ styleClassPassthrough: {
40
+ table: { disable: true },
41
+ },
42
+ },
43
+ args: {
44
+ tag: "div",
45
+ itemMinWidth: 300,
46
+ gap: 1.2,
47
+ unit: "rem",
48
+ styleClassPassthrough: [],
49
+ },
50
+ parameters: {
51
+ docs: {
52
+ description: {
53
+ component:
54
+ "A CSS `columns` (multi-column text flow) layout. Renders whatever slots the consumer " +
55
+ "passes — no count/data prop needed (named dynamic slots, like `AutoGrid`/`GridStack`). " +
56
+ "Items flow into columns in DOM order, filling one column fully before starting the " +
57
+ "next — this does **not** reorder content, so DOM/reading order and visual order can " +
58
+ "diverge. Use `MasonryGrid` instead when reading order needs to match the visual layout.",
59
+ },
60
+ },
61
+ },
62
+ };
63
+
64
+ export default meta;
65
+
66
+ const itemStyle =
67
+ "padding: 1.6rem; background: white; border-radius: 0.6rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08);";
68
+ const badgeStyle =
69
+ "display: inline-flex; align-items: center; justify-content: center; width: 2.4rem; height: 2.4rem; " +
70
+ "margin-bottom: 0.8rem; border-radius: 50%; background: #111827; color: white; font-weight: 700; " +
71
+ "font-size: 1.3rem; font-family: monospace;";
72
+
73
+ // Each item shows its DOM order (slot index) as a numbered badge so you can see how the column-fill
74
+ // algorithm actually orders items — it's not simple row-by-row order, so an item's position in the
75
+ // visual grid doesn't always match its number sequentially top-to-bottom.
76
+ const itemBadge = (n: number) => `<span style="${badgeStyle}">${n}</span><br />`;
77
+
78
+ const paragraphPool = [
79
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio praesent libero sed cursus ante.",
80
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
81
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
82
+ "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
83
+ "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium totam rem.",
84
+ "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit sed quia consequuntur magni dolores.",
85
+ ];
86
+
87
+ interface DemoItem {
88
+ slotName: string;
89
+ index: number;
90
+ bodyHtml: string;
91
+ }
92
+
93
+ // Generates a random number of paragraphs (1-4) per item so item heights vary unpredictably —
94
+ // closer to real content than a handful of hand-written examples, and enough items (12) to
95
+ // actually show multi-column flow. Built once per mount, not on every render, so a
96
+ // Controls-panel change (itemMinWidth/gap) doesn't reshuffle the demo content underneath it.
97
+ function buildDemoItems(count: number): DemoItem[] {
98
+ return Array.from({ length: count }, (_, i) => {
99
+ const paragraphCount = Math.floor(Math.random() * 4) + 1; // 1-4
100
+ const bodyHtml = Array.from(
101
+ { length: paragraphCount },
102
+ () => `<p style="margin: 0 0 0.8rem;">${paragraphPool[Math.floor(Math.random() * paragraphPool.length)]}</p>`
103
+ ).join("");
104
+ return { slotName: `item-${i + 1}`, index: i + 1, bodyHtml };
105
+ });
106
+ }
107
+
108
+ const Template: StoryFn<ColumnFlowGridArgs> = (args) => ({
109
+ components: { ColumnFlowGrid },
110
+ setup() {
111
+ const items = buildDemoItems(12);
112
+ return { args, items };
113
+ },
114
+ template: `
115
+ <div style="padding: 3.2rem; background: #f9fafb;">
116
+ <ColumnFlowGrid v-bind="args">
117
+ <template v-for="item in items" :key="item.slotName" #[item.slotName]>
118
+ <div style="${itemStyle}">
119
+ <span style="${badgeStyle}">{{ item.index }}</span><br />
120
+ <div v-html="item.bodyHtml"></div>
121
+ </div>
122
+ </template>
123
+ </ColumnFlowGrid>
124
+ </div>
125
+ `,
126
+ });
127
+
128
+ export const Default = Template.bind({});
129
+ Default.parameters = {
130
+ docs: {
131
+ description: {
132
+ story:
133
+ "12 items with a random 1-4 paragraphs each, flowing into auto-sized columns at least " +
134
+ "300px wide. Each item is numbered with its slot/DOM order — resize the preview to change " +
135
+ "the column count and see how the numbering no longer reads strictly top-to-bottom per " +
136
+ "column once items wrap, since column-fill picks whichever column is currently shortest.",
137
+ },
138
+ },
139
+ };
140
+
141
+ export const NarrowColumns = Template.bind({});
142
+ NarrowColumns.args = { itemMinWidth: 160, gap: 0.8 };
143
+ NarrowColumns.parameters = {
144
+ docs: {
145
+ description: { story: "A smaller `itemMinWidth` fits more, narrower columns." },
146
+ },
147
+ };
148
+
149
+ export const WideColumns = Template.bind({});
150
+ WideColumns.args = { itemMinWidth: 450, gap: 2 };
151
+ WideColumns.parameters = {
152
+ docs: {
153
+ description: { story: "A larger `itemMinWidth` fits fewer, wider columns." },
154
+ },
155
+ };
156
+
157
+ export const TwoItems: StoryFn<ColumnFlowGridArgs> = (args) => ({
158
+ components: { ColumnFlowGrid },
159
+ setup() {
160
+ return { args };
161
+ },
162
+ template: `
163
+ <div style="padding: 3.2rem; background: #f9fafb;">
164
+ <ColumnFlowGrid v-bind="args">
165
+ <template #item-1>
166
+ <div style="${itemStyle}">${itemBadge(1)}Only two items provided</div>
167
+ </template>
168
+ <template #item-2>
169
+ <div style="${itemStyle}">
170
+ ${itemBadge(2)}No count prop to keep in sync — the grid just renders whichever
171
+ slots you give it.
172
+ </div>
173
+ </template>
174
+ </ColumnFlowGrid>
175
+ </div>
176
+ `,
177
+ });
178
+ TwoItems.storyName = "Two Items (no count prop needed)";
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import ColumnFlowGrid from "../ColumnFlowGrid.vue";
4
+
5
+ describe("ColumnFlowGrid", () => {
6
+ it("mounts without error", async () => {
7
+ const wrapper = await mountSuspended(ColumnFlowGrid);
8
+ expect(wrapper.vm).toBeTruthy();
9
+ });
10
+
11
+ it("renders as div by default", async () => {
12
+ const wrapper = await mountSuspended(ColumnFlowGrid);
13
+ expect(wrapper.element.tagName).toBe("DIV");
14
+ });
15
+
16
+ it("renders a different tag when provided", async () => {
17
+ const wrapper = await mountSuspended(ColumnFlowGrid, {
18
+ props: { tag: "section" },
19
+ });
20
+ expect(wrapper.element.tagName).toBe("SECTION");
21
+ });
22
+
23
+ it("renders slot content", async () => {
24
+ const wrapper = await mountSuspended(ColumnFlowGrid, {
25
+ slots: {
26
+ "item-1": "<div>Item 1</div>",
27
+ },
28
+ });
29
+ expect(wrapper.text()).toContain("Item 1");
30
+ });
31
+
32
+ it("renders each provided slot as its own column-flow-grid-item", async () => {
33
+ const wrapper = await mountSuspended(ColumnFlowGrid, {
34
+ slots: {
35
+ "item-1": "<div>Item 1</div>",
36
+ "item-2": "<div>Item 2</div>",
37
+ "item-3": "<div>Item 3</div>",
38
+ },
39
+ });
40
+ const items = wrapper.findAll(".column-flow-grid-item");
41
+ expect(items).toHaveLength(3);
42
+ expect(items[0]!.text()).toBe("Item 1");
43
+ expect(items[1]!.text()).toBe("Item 2");
44
+ expect(items[2]!.text()).toBe("Item 3");
45
+ });
46
+
47
+ it("renders no items when no slots are provided", async () => {
48
+ const wrapper = await mountSuspended(ColumnFlowGrid);
49
+ expect(wrapper.findAll(".column-flow-grid-item")).toHaveLength(0);
50
+ });
51
+
52
+ it("applies itemMinWidth and gap as inline CSS custom properties", async () => {
53
+ const wrapper = await mountSuspended(ColumnFlowGrid, {
54
+ props: { itemMinWidth: 250, gap: 2, unit: "em" },
55
+ });
56
+ const style = (wrapper.element as HTMLElement).style;
57
+ expect(style.getPropertyValue("--_item-min-width")).toBe("250px");
58
+ expect(style.getPropertyValue("--_column-flow-grid-gap")).toBe("2em");
59
+ });
60
+
61
+ it("applies styleClassPassthrough", async () => {
62
+ const wrapper = await mountSuspended(ColumnFlowGrid, {
63
+ props: { styleClassPassthrough: "custom-class" },
64
+ });
65
+ expect(wrapper.classes()).toContain("custom-class");
66
+ });
67
+
68
+ it("applies multiple styleClassPassthrough classes", async () => {
69
+ const wrapper = await mountSuspended(ColumnFlowGrid, {
70
+ props: { styleClassPassthrough: ["class-a", "class-b"] },
71
+ });
72
+ expect(wrapper.classes()).toContain("class-a");
73
+ expect(wrapper.classes()).toContain("class-b");
74
+ });
75
+ });
@@ -0,0 +1,35 @@
1
+ # MasonryGrid — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ | Token | Falls back to | Controls |
6
+ |---|---|---|
7
+ | `--masonry-grid-item-border-colour` | `var(--theme-border)` | Outline colour around each item |
8
+ | `--masonry-grid-item-padding` | `1.2rem` | Inner padding of each item |
9
+ | `--masonry-grid-transition-duration` | `0.3s` | How long an item takes to slide into its new position on resize (respects `prefers-reduced-motion`) |
10
+
11
+ Column width, gap, fixed-width mode, and alignment are controlled via props (`item-min-width`,
12
+ `gap`, `fixed-width`, `justify`), not CSS custom properties — column count and item positions are
13
+ computed in JS from measured pixel values. See `.claude/skills/components/masonry-grid.md`.
14
+
15
+ ---
16
+
17
+ ## Global theming — app-level CSS file
18
+
19
+ ```css
20
+ :where(html) {
21
+ --masonry-grid-item-border-colour: var(--rose-05);
22
+ --masonry-grid-item-padding: 1.6rem;
23
+ --masonry-grid-transition-duration: 0.5s;
24
+ }
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Per-instance overrides
30
+
31
+ ```vue
32
+ <MasonryGrid style="--masonry-grid-item-border-colour: var(--gold-04);">
33
+ ...
34
+ </MasonryGrid>
35
+ ```
@@ -0,0 +1,178 @@
1
+ <template>
2
+ <component :is="tag" class="masonry-grid" :class="[elementClasses]">
3
+ <div ref="gridWrapper" class="masonry-grid-wrapper" :class="{ 'multiple-cols': !isSingleColumn }">
4
+ <div v-for="name in slotNames" :key="name" ref="gridItemRefs" class="masonry-grid-item">
5
+ <slot :name="name"></slot>
6
+ </div>
7
+ </div>
8
+ </component>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { useElementSize } from "@vueuse/core";
13
+
14
+ interface Props {
15
+ tag?: "div" | "section" | "article" | "main";
16
+ /** Minimum tile width in pixels — also the fixed tile width when `fixedWidth` is set. */
17
+ itemMinWidth?: number;
18
+ /** Gap between tiles in pixels. */
19
+ gap?: number;
20
+ /** Keep every tile at exactly `itemMinWidth` instead of stretching to fill each column. */
21
+ fixedWidth?: boolean;
22
+ /** How the block of tiles aligns within the wrapper — only visible when `fixedWidth` is set
23
+ * (tiles otherwise stretch to fill the full width, so there's nothing to align). */
24
+ justify?: "left" | "center" | "right";
25
+ styleClassPassthrough?: string | string[];
26
+ }
27
+
28
+ const props = withDefaults(defineProps<Props>(), {
29
+ tag: "div",
30
+ itemMinWidth: 300,
31
+ gap: 12,
32
+ fixedWidth: false,
33
+ justify: "left",
34
+ styleClassPassthrough: () => [],
35
+ });
36
+
37
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
38
+
39
+ const slots = useSlots();
40
+ // Consumer-provided slot names, in natural authoring/DOM order — this is both the "data" this
41
+ // component works with (no separate gridData/itemCount prop needed) and the order items are
42
+ // measured and packed in, so reading order is always preserved: unlike a CSS `columns`-based
43
+ // masonry, this never needs to reorder the DOM to keep visual and reading order in sync.
44
+ const slotNames = computed(() => Object.keys(slots));
45
+
46
+ const gridWrapper = ref<HTMLElement | null>(null);
47
+ const gridItemRefs = ref<HTMLElement[]>([]);
48
+ // Resize-reactive — useElementSize wires up its own ResizeObserver internally.
49
+ const { width } = useElementSize(gridWrapper);
50
+
51
+ const columnCount = computed(() => {
52
+ if (width.value === 0) return 1;
53
+ return Math.max(1, Math.floor(width.value / (props.itemMinWidth + props.gap)));
54
+ });
55
+ const isSingleColumn = computed(() => columnCount.value === 1);
56
+
57
+ const gapPx = computed(() => `${props.gap}px`);
58
+ const itemMinWidthPx = computed(() => `${props.itemMinWidth}px`);
59
+
60
+ // Real masonry packing: each item, in natural DOM order, is measured and placed into whichever
61
+ // column is currently shortest (a greedy bin-pack) — not CSS `columns`' column-fill, which packs
62
+ // by accumulated height per column in DOM order and can't be reordered without breaking reading
63
+ // order. Because placement follows DOM order directly, no reordering is ever needed here.
64
+ function updateGrid() {
65
+ if (gridWrapper.value === null || columnCount.value <= 1) {
66
+ gridItemRefs.value.forEach((item) => {
67
+ item?.style.removeProperty("--_position");
68
+ item?.style.removeProperty("--_position-top");
69
+ item?.style.removeProperty("--_position-left");
70
+ item?.style.removeProperty("--_element-width");
71
+ });
72
+ gridWrapper.value?.style.removeProperty("--_wrapper-height");
73
+ return;
74
+ }
75
+
76
+ const wrapperWidth = width.value;
77
+ const itemWidth = props.fixedWidth
78
+ ? props.itemMinWidth
79
+ : Math.floor((wrapperWidth - (columnCount.value - 1) * props.gap) / columnCount.value);
80
+
81
+ // Columns are placed at fixed pixel steps (itemWidth + gap) rather than equal percentage slots
82
+ // of the container — a percentage slot only matches the item's actual width when the item
83
+ // stretches to fill it (the non-fixedWidth case); with fixedWidth, the item is narrower than an
84
+ // equal-percentage slot, and the leftover slot space would show up as a much bigger gap than
85
+ // `gap` actually specifies. `justify` shifts this whole fixed-width block left/center/right
86
+ // within the leftover space — done here in JS, not via CSS `justify-content`, since it has no
87
+ // effect on children once they're taken out of grid flow by `position: absolute` below.
88
+ const totalContentWidth = columnCount.value * itemWidth + (columnCount.value - 1) * props.gap;
89
+ const startX = props.fixedWidth
90
+ ? { left: 0, center: (wrapperWidth - totalContentWidth) / 2, right: wrapperWidth - totalContentWidth }[props.justify]
91
+ : 0;
92
+
93
+ const colHeights = Array(columnCount.value).fill(0);
94
+
95
+ gridItemRefs.value.forEach((item) => {
96
+ const minHeight = Math.min(...colHeights);
97
+ const minIndex = colHeights.indexOf(minHeight);
98
+
99
+ item?.style.setProperty("--_position", "absolute");
100
+ item?.style.setProperty("--_position-top", `${minHeight}px`);
101
+ item?.style.setProperty("--_position-left", `${startX + minIndex * (itemWidth + props.gap)}px`);
102
+ item?.style.setProperty("--_element-width", `${itemWidth}px`);
103
+
104
+ colHeights[minIndex] += Math.floor((item?.offsetHeight ?? 0) + props.gap);
105
+ });
106
+
107
+ const maxHeight = Math.max(...colHeights);
108
+ gridWrapper.value.style.setProperty("--_wrapper-height", `${maxHeight}px`);
109
+ }
110
+
111
+ // Re-pack whenever the measured width changes (useElementSize already wires up its own
112
+ // ResizeObserver — a second explicit useResizeObserver call here would just duplicate it).
113
+ watch(width, () => nextTick(() => updateGrid()));
114
+
115
+ onMounted(() => {
116
+ nextTick(() => updateGrid());
117
+ });
118
+
119
+ watch(
120
+ () => [props.fixedWidth, props.justify, props.itemMinWidth, props.gap],
121
+ () => updateGrid()
122
+ );
123
+
124
+ watch(
125
+ () => props.styleClassPassthrough,
126
+ () => {
127
+ resetElementClasses(props.styleClassPassthrough);
128
+ }
129
+ );
130
+ </script>
131
+
132
+ <style lang="css">
133
+ @layer components {
134
+ .masonry-grid-wrapper {
135
+ container-type: inline-size;
136
+ position: relative;
137
+
138
+ display: grid;
139
+ gap: v-bind(gapPx);
140
+ /* Single column always stretches to fill the full width — with only one column there's no
141
+ "how many columns fit" question for itemMinWidth to answer, so applying its floor here
142
+ (minmax(itemMinWidth, 1fr)) only ever hurt: on a narrow container it fights the container
143
+ for space instead of just filling it. itemMinWidth starts governing column width once
144
+ there's an actual choice to make, from two columns up (below). */
145
+ grid-template-columns: 1fr;
146
+
147
+ height: var(--_wrapper-height, auto);
148
+
149
+ @container (min-width: 768px) {
150
+ grid-template-columns: repeat(2, minmax(v-bind(itemMinWidthPx), 1fr));
151
+ }
152
+ @container (min-width: 1024px) {
153
+ grid-template-columns: repeat(3, minmax(v-bind(itemMinWidthPx), 1fr));
154
+ }
155
+ @container (min-width: 1280px) {
156
+ grid-template-columns: repeat(4, minmax(v-bind(itemMinWidthPx), 1fr));
157
+ }
158
+
159
+ .masonry-grid-item {
160
+ outline: 0.1rem solid var(--masonry-grid-item-border-colour, var(--theme-border));
161
+ padding: var(--masonry-grid-item-padding, 1.2rem);
162
+ }
163
+
164
+ &.multiple-cols .masonry-grid-item {
165
+ position: var(--_position, static);
166
+ top: var(--_position-top, auto);
167
+ left: var(--_position-left, auto);
168
+ width: var(--_element-width, auto);
169
+
170
+ @media (prefers-reduced-motion: no-preference) {
171
+ transition:
172
+ top var(--masonry-grid-transition-duration, 0.3s) ease,
173
+ left var(--masonry-grid-transition-duration, 0.3s) ease;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ </style>