srcdev-nuxt-components 9.4.6 → 9.4.7

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 (64) 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/components/column-flow-grid.md +93 -0
  7. package/.claude/skills/components/entry-animation.md +88 -0
  8. package/.claude/skills/components/input-copy.md +2 -0
  9. package/.claude/skills/components/input-text-core.md +186 -0
  10. package/.claude/skills/components/masonry-grid.md +153 -0
  11. package/.claude/skills/components/pop-over.md +100 -0
  12. package/.claude/skills/index.md +6 -1
  13. package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
  14. package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
  15. package/.vscode/srcdev-component-input-text.code-snippets +107 -0
  16. package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
  17. package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
  18. package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
  19. package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
  20. package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
  21. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
  22. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
  23. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
  24. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
  25. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
  26. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
  27. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
  28. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
  29. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
  30. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
  31. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
  32. package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
  33. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
  34. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
  35. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
  36. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
  37. package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
  38. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
  39. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
  40. package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
  41. package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
  42. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
  43. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
  44. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
  45. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
  46. package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
  47. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
  48. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
  49. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
  50. package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
  51. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
  52. package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
  53. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
  54. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
  55. package/app/types/forms/types.forms.d.ts +1 -1
  56. package/package.json +1 -1
  57. package/app/components/masonry-grid/MasonryGrid.vue +0 -68
  58. package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
  59. package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
  60. package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
  61. package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
  62. package/app/components/pop-over/PopOver.vue +0 -90
  63. package/app/layouts/default.vue +0 -308
  64. package/app/layouts/site-navigation-demo.vue +0 -188
@@ -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>
@@ -0,0 +1,199 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import MasonryGrid from "../MasonryGrid.vue";
3
+
4
+ interface MasonryGridArgs {
5
+ tag: "div" | "section" | "article" | "main";
6
+ itemMinWidth: number;
7
+ gap: number;
8
+ fixedWidth: boolean;
9
+ justify: "left" | "center" | "right";
10
+ styleClassPassthrough: string[];
11
+ }
12
+
13
+ const meta: Meta<MasonryGridArgs> = {
14
+ title: "Atoms/Grids/MasonryGrid",
15
+ component: MasonryGrid,
16
+ argTypes: {
17
+ tag: {
18
+ control: { type: "select" },
19
+ options: ["div", "section", "article", "main"],
20
+ description: "HTML tag to render as",
21
+ table: { category: "Props" },
22
+ },
23
+ itemMinWidth: {
24
+ control: { type: "select" },
25
+ options: [160, 200, 240, 300, 360, 450],
26
+ description: "Minimum tile width in pixels — also the fixed tile width when fixedWidth is set",
27
+ table: { category: "Props" },
28
+ },
29
+ gap: {
30
+ control: { type: "select" },
31
+ options: [0, 4, 8, 12, 16, 24, 32],
32
+ description: "Gap between tiles in pixels",
33
+ table: { category: "Props" },
34
+ },
35
+ fixedWidth: {
36
+ control: { type: "boolean" },
37
+ description: "Keep every tile at exactly itemMinWidth instead of stretching to fill each column",
38
+ table: { category: "Props" },
39
+ },
40
+ justify: {
41
+ control: { type: "select" },
42
+ options: ["left", "center", "right"],
43
+ description: "How the block of tiles aligns within the wrapper — only visible when fixedWidth is set",
44
+ table: { category: "Props" },
45
+ },
46
+ styleClassPassthrough: {
47
+ table: { disable: true },
48
+ },
49
+ },
50
+ args: {
51
+ tag: "div",
52
+ itemMinWidth: 300,
53
+ gap: 12,
54
+ fixedWidth: false,
55
+ justify: "left",
56
+ styleClassPassthrough: [],
57
+ },
58
+ parameters: {
59
+ docs: {
60
+ description: {
61
+ component:
62
+ "A real masonry layout: each item is measured and placed into whichever column is " +
63
+ "currently shortest (a greedy bin-pack), not CSS `columns`' column-fill. Renders " +
64
+ "whatever slots the consumer passes, in natural DOM/reading order — no count/data prop " +
65
+ "needed, and no reordering trick required (unlike a `columns`-based masonry) since " +
66
+ "placement already follows the order items were authored in. Resize-reactive: items " +
67
+ "animate into their new position (respecting `prefers-reduced-motion`) as the container " +
68
+ "resizes. See `ColumnFlowGrid` for the lighter, non-measuring CSS `columns` alternative.",
69
+ },
70
+ },
71
+ },
72
+ };
73
+
74
+ export default meta;
75
+
76
+ const itemStyle =
77
+ "padding: 1.6rem; background: white; border-radius: 0.6rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08);";
78
+ const badgeStyle =
79
+ "display: inline-flex; align-items: center; justify-content: center; width: 2.4rem; height: 2.4rem; " +
80
+ "margin-bottom: 0.8rem; border-radius: 50%; background: #111827; color: white; font-weight: 700; " +
81
+ "font-size: 1.3rem; font-family: monospace;";
82
+
83
+ // Each item shows its DOM/reading order as a numbered badge. Unlike a reading-order effect based
84
+ // on rearranging DOM order to match a visual grid, here the number is simply the authoring order —
85
+ // tab/screen-reader order always reads 1, 2, 3... regardless of which column an item's measured
86
+ // height ends up placing it in visually.
87
+ const paragraphPool = [
88
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio praesent libero sed cursus ante.",
89
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
90
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
91
+ "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
92
+ ];
93
+
94
+ const itemBadge = (n: number) => `<span style="${badgeStyle}">${n}</span><br />`;
95
+
96
+ interface DemoItem {
97
+ slotName: string;
98
+ index: number;
99
+ bodyHtml: string;
100
+ }
101
+
102
+ // Built once per mount (not on every render) so a Controls-panel change doesn't reshuffle the
103
+ // demo content underneath it. Random paragraph counts (1-4) give genuinely uneven heights —
104
+ // necessary to actually see the shortest-column packing at work, unlike uniform-height tiles.
105
+ function buildDemoItems(count: number): DemoItem[] {
106
+ return Array.from({ length: count }, (_, i) => {
107
+ const paragraphCount = Math.floor(Math.random() * 4) + 1; // 1-4
108
+ const bodyHtml = Array.from(
109
+ { length: paragraphCount },
110
+ () => `<p style="margin: 0 0 0.8rem;">${paragraphPool[Math.floor(Math.random() * paragraphPool.length)]}</p>`
111
+ ).join("");
112
+ return { slotName: `item-${i + 1}`, index: i + 1, bodyHtml };
113
+ });
114
+ }
115
+
116
+ const Template: StoryFn<MasonryGridArgs> = (args) => ({
117
+ components: { MasonryGrid },
118
+ setup() {
119
+ const items = buildDemoItems(12);
120
+ return { args, items };
121
+ },
122
+ template: `
123
+ <div style="padding: 3.2rem; background: #f9fafb;">
124
+ <MasonryGrid v-bind="args">
125
+ <template v-for="item in items" :key="item.slotName" #[item.slotName]>
126
+ <div style="${itemStyle}">
127
+ <span style="${badgeStyle}">{{ item.index }}</span><br />
128
+ <div v-html="item.bodyHtml"></div>
129
+ </div>
130
+ </template>
131
+ </MasonryGrid>
132
+ </div>
133
+ `,
134
+ });
135
+
136
+ export const Default = Template.bind({});
137
+ Default.parameters = {
138
+ docs: {
139
+ description: {
140
+ story:
141
+ "12 numbered items with 1-4 random paragraphs each, flowing into whichever column is " +
142
+ "currently shortest. Resize the preview panel — items animate into their new positions, " +
143
+ "and DOM/tab order always stays 1, 2, 3... regardless of which column each ends up in.",
144
+ },
145
+ },
146
+ };
147
+
148
+ export const NarrowColumns = Template.bind({});
149
+ NarrowColumns.args = { itemMinWidth: 160, gap: 8 };
150
+ NarrowColumns.parameters = {
151
+ docs: {
152
+ description: { story: "A smaller `itemMinWidth` fits more, narrower columns." },
153
+ },
154
+ };
155
+
156
+ export const WideColumns = Template.bind({});
157
+ WideColumns.args = { itemMinWidth: 450, gap: 16 };
158
+ WideColumns.parameters = {
159
+ docs: {
160
+ description: { story: "A larger `itemMinWidth` fits fewer, wider columns." },
161
+ },
162
+ };
163
+
164
+ export const FixedWidthCentered = Template.bind({});
165
+ FixedWidthCentered.storyName = "Fixed Width, Centered";
166
+ FixedWidthCentered.args = { fixedWidth: true, justify: "center", itemMinWidth: 240 };
167
+ FixedWidthCentered.parameters = {
168
+ docs: {
169
+ description: {
170
+ story:
171
+ "`fixedWidth` keeps every tile at exactly `itemMinWidth` instead of stretching to fill " +
172
+ "each column — `justify` then controls how the resulting (narrower) block of tiles " +
173
+ "aligns within the available width.",
174
+ },
175
+ },
176
+ };
177
+
178
+ export const TwoItems: StoryFn<MasonryGridArgs> = (args) => ({
179
+ components: { MasonryGrid },
180
+ setup() {
181
+ return { args };
182
+ },
183
+ template: `
184
+ <div style="padding: 3.2rem; background: #f9fafb;">
185
+ <MasonryGrid v-bind="args">
186
+ <template #item-1>
187
+ <div style="${itemStyle}">${itemBadge(1)}Only two items provided</div>
188
+ </template>
189
+ <template #item-2>
190
+ <div style="${itemStyle}">
191
+ ${itemBadge(2)}No count prop to keep in sync — the grid just renders whichever
192
+ slots you give it.
193
+ </div>
194
+ </template>
195
+ </MasonryGrid>
196
+ </div>
197
+ `,
198
+ });
199
+ TwoItems.storyName = "Two Items (no count prop needed)";