srcdev-nuxt-components 9.6.18 → 9.6.20

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 (187) hide show
  1. package/.claude/rules/consumer/css-relative-colour-calc.md +15 -0
  2. package/.claude/rules/consumer/ids-from-useid.md +3 -2
  3. package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
  4. package/.claude/skills/component-compliance-checklist.md +11 -1
  5. package/.claude/skills/component-dynamic-slots.md +2 -2
  6. package/.claude/skills/component-naming.md +1 -1
  7. package/.claude/skills/components/accordion-chat.md +99 -0
  8. package/.claude/skills/components/accordion-group.md +98 -0
  9. package/.claude/skills/components/action-menu.md +11 -5
  10. package/.claude/skills/components/auto-grid.md +3 -1
  11. package/.claude/skills/components/capture-qr-code.md +9 -5
  12. package/.claude/skills/components/carousel-flip.md +41 -30
  13. package/.claude/skills/components/column-flow-grid.md +2 -0
  14. package/.claude/skills/components/dashboard-quad-grid.md +7 -2
  15. package/.claude/skills/components/dashboard-stats-grid.md +4 -0
  16. package/.claude/skills/components/decode-qr-code.md +7 -2
  17. package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
  18. package/.claude/skills/components/deep-expanding-menu.md +22 -21
  19. package/.claude/skills/components/expanding-panel-classic.md +9 -7
  20. package/.claude/skills/components/expanding-panel.md +9 -8
  21. package/.claude/skills/components/grid-stack.md +2 -0
  22. package/.claude/skills/components/layout-grid-by-cols.md +2 -0
  23. package/.claude/skills/components/masonry-grid.md +9 -1
  24. package/.claude/skills/components/page-row.md +3 -0
  25. package/.claude/skills/components/samaritan-prompt.md +24 -8
  26. package/.claude/skills/components/site-header.md +16 -2
  27. package/.claude/skills/components/sparkle-effect.md +79 -0
  28. package/.claude/skills/index.md +16 -14
  29. package/.claude/skills/storybook-add-story.md +33 -0
  30. package/.claude/skills/testing-add-unit-test.md +26 -0
  31. package/.claude/skills/using-component-skills.md +1 -1
  32. package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
  33. package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
  34. package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
  35. package/.vscode/srcdev-component-capture-decode-qr-code.code-snippets +11 -2
  36. package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
  37. package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
  38. package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
  39. package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
  40. package/.vscode/srcdev-component-site-header.code-snippets +13 -1
  41. package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
  42. package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
  43. package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
  44. package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
  45. package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
  46. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
  47. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
  48. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
  49. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
  50. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
  51. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
  52. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
  53. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
  54. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
  55. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
  56. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
  57. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
  58. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
  59. package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
  60. package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
  61. package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
  62. package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
  63. package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  64. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
  65. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
  66. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
  67. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
  68. package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
  69. package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
  70. package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
  71. package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
  72. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
  73. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
  74. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
  75. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
  76. package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
  77. package/app/components/01.atoms/page-row/PageRow.vue +16 -15
  78. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
  79. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
  80. package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
  81. package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
  82. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
  83. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
  84. package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
  85. package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
  86. package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
  87. package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
  88. package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
  89. package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue → accordion-group/AccordionGroup.vue} +32 -20
  90. package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
  91. package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
  92. package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
  93. package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
  94. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
  95. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
  96. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
  97. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
  98. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
  99. package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
  100. package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
  101. package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
  102. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
  103. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
  104. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
  105. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
  106. package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
  107. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
  108. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
  109. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
  110. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
  111. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
  112. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
  113. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
  114. package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
  115. package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
  116. package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
  117. package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
  118. package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
  119. package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
  120. package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
  121. package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
  122. package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
  123. package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
  124. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
  125. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
  126. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
  127. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
  128. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
  129. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
  130. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
  131. package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
  132. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
  133. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
  134. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
  135. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
  136. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
  137. package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
  138. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
  139. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
  140. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
  141. package/app/types/components/qr-code.d.ts +12 -0
  142. package/app/utils/graphemes.ts +8 -0
  143. package/package.json +1 -1
  144. package/.claude/skills/components/accordian-core.md +0 -180
  145. package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
  146. package/app/components/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
  147. package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
  148. package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
  149. package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
  150. package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
  151. package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
  152. package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
  153. package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
  154. package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
  155. package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
  156. package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
  157. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
  158. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
  159. package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
  160. package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
  161. package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
  162. package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
  163. package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
  164. package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
  165. package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  166. package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
  167. package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
  168. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
  169. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -95
  170. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
  171. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
  172. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
  173. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
  174. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
  175. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
  176. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
  177. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
  178. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
  179. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
  180. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
  181. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
  182. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
  183. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
  184. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
  185. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
  186. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  187. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
@@ -1,7 +1,7 @@
1
1
  <template>
2
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">
3
+ <div ref="gridWrapper" class="masonry-grid-wrapper" :data-multiple-cols="isSingleColumn ? undefined : ''">
4
+ <div v-for="name in slotNames()" :key="name" class="masonry-grid-item">
5
5
  <slot :name="name"></slot>
6
6
  </div>
7
7
  </div>
@@ -9,7 +9,7 @@
9
9
  </template>
10
10
 
11
11
  <script setup lang="ts">
12
- import { useElementSize } from "@vueuse/core";
12
+ import { useElementSize, useResizeObserver } from "@vueuse/core";
13
13
 
14
14
  interface Props {
15
15
  tag?: "div" | "section" | "article" | "main";
@@ -37,14 +37,15 @@ const props = withDefaults(defineProps<Props>(), {
37
37
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
38
38
 
39
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));
40
+ // Slot names in authoring order; a function, not a computed, because slots aren't reactive.
41
+ const slotNames = () => Object.keys(slots);
45
42
 
46
43
  const gridWrapper = ref<HTMLElement | null>(null);
47
- const gridItemRefs = ref<HTMLElement[]>([]);
44
+ // Read from the DOM: a v-for ref array isn't guaranteed to follow DOM order, and packing depends on it.
45
+ const gridItems = shallowRef<HTMLElement[]>([]);
46
+ const readItems = () => {
47
+ gridItems.value = Array.from(gridWrapper.value?.children ?? []) as HTMLElement[];
48
+ };
48
49
  // Resize-reactive — useElementSize wires up its own ResizeObserver internally.
49
50
  const { width } = useElementSize(gridWrapper);
50
51
 
@@ -63,13 +64,13 @@ const itemMinWidthPx = computed(() => `${props.itemMinWidth}px`);
63
64
  // order. Because placement follows DOM order directly, no reordering is ever needed here.
64
65
  function updateGrid() {
65
66
  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");
67
+ gridItems.value.forEach((item) => {
68
+ item?.style.removeProperty("--_masonry-grid-item-position");
69
+ item?.style.removeProperty("--_masonry-grid-item-top");
70
+ item?.style.removeProperty("--_masonry-grid-item-left");
71
+ item?.style.removeProperty("--_masonry-grid-item-width");
71
72
  });
72
- gridWrapper.value?.style.removeProperty("--_wrapper-height");
73
+ gridWrapper.value?.style.removeProperty("--_masonry-grid-height");
73
74
  return;
74
75
  }
75
76
 
@@ -92,30 +93,47 @@ function updateGrid() {
92
93
 
93
94
  const colHeights = Array(columnCount.value).fill(0);
94
95
 
95
- gridItemRefs.value.forEach((item) => {
96
+ gridItems.value.forEach((item) => {
96
97
  const minHeight = Math.min(...colHeights);
97
98
  const minIndex = colHeights.indexOf(minHeight);
98
99
 
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`);
100
+ item?.style.setProperty("--_masonry-grid-item-position", "absolute");
101
+ item?.style.setProperty("--_masonry-grid-item-top", `${minHeight}px`);
102
+ item?.style.setProperty("--_masonry-grid-item-left", `${startX + minIndex * (itemWidth + props.gap)}px`);
103
+ item?.style.setProperty("--_masonry-grid-item-width", `${itemWidth}px`);
103
104
 
104
105
  colHeights[minIndex] += Math.floor((item?.offsetHeight ?? 0) + props.gap);
105
106
  });
106
107
 
107
108
  const maxHeight = Math.max(...colHeights);
108
- gridWrapper.value.style.setProperty("--_wrapper-height", `${maxHeight}px`);
109
+ gridWrapper.value.style.setProperty("--_masonry-grid-height", `${maxHeight}px`);
109
110
  }
110
111
 
111
112
  // Re-pack whenever the measured width changes (useElementSize already wires up its own
112
113
  // ResizeObserver — a second explicit useResizeObserver call here would just duplicate it).
114
+ let frame = 0;
115
+ const scheduleUpdate = () => {
116
+ cancelAnimationFrame(frame);
117
+ frame = requestAnimationFrame(() => updateGrid());
118
+ };
119
+
113
120
  watch(width, () => nextTick(() => updateGrid()));
114
121
 
122
+ // A tile that changes height (an image loading, text wrapping) re-packs without a width change.
123
+ useResizeObserver(() => gridItems.value, scheduleUpdate);
124
+
115
125
  onMounted(() => {
126
+ readItems();
116
127
  nextTick(() => updateGrid());
117
128
  });
118
129
 
130
+ onUpdated(() => {
131
+ readItems();
132
+ scheduleUpdate();
133
+ });
134
+
135
+ onBeforeUnmount(() => cancelAnimationFrame(frame));
136
+
119
137
  watch(
120
138
  () => [props.fixedWidth, props.justify, props.itemMinWidth, props.gap],
121
139
  () => updateGrid()
@@ -142,30 +160,32 @@ watch(
142
160
  (minmax(itemMinWidth, 1fr)) only ever hurt: on a narrow container it fights the container
143
161
  for space instead of just filling it. itemMinWidth starts governing column width once
144
162
  there's an actual choice to make, from two columns up (below). */
145
- grid-template-columns: 1fr;
163
+ grid-template-columns: minmax(0, 1fr);
146
164
 
147
- height: var(--_wrapper-height, auto);
165
+ height: var(--_masonry-grid-height, auto);
148
166
 
149
167
  @container (min-width: 768px) {
150
- grid-template-columns: repeat(2, minmax(v-bind(itemMinWidthPx), 1fr));
168
+ grid-template-columns: repeat(2, minmax(min(100%, v-bind(itemMinWidthPx)), 1fr));
151
169
  }
152
170
  @container (min-width: 1024px) {
153
- grid-template-columns: repeat(3, minmax(v-bind(itemMinWidthPx), 1fr));
171
+ grid-template-columns: repeat(3, minmax(min(100%, v-bind(itemMinWidthPx)), 1fr));
154
172
  }
155
173
  @container (min-width: 1280px) {
156
- grid-template-columns: repeat(4, minmax(v-bind(itemMinWidthPx), 1fr));
174
+ grid-template-columns: repeat(4, minmax(min(100%, v-bind(itemMinWidthPx)), 1fr));
157
175
  }
158
176
 
159
177
  .masonry-grid-item {
178
+ min-inline-size: 0;
179
+ overflow-wrap: anywhere;
160
180
  outline: 0.1rem solid var(--masonry-grid-item-border-colour, var(--theme-border));
161
181
  padding: var(--masonry-grid-item-padding, 1.2rem);
162
182
  }
163
183
 
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);
184
+ &[data-multiple-cols] .masonry-grid-item {
185
+ position: var(--_masonry-grid-item-position, static);
186
+ top: var(--_masonry-grid-item-top, auto);
187
+ left: var(--_masonry-grid-item-left, auto);
188
+ width: var(--_masonry-grid-item-width, auto);
169
189
 
170
190
  @media (prefers-reduced-motion: no-preference) {
171
191
  transition:
@@ -1,4 +1,7 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
3
+ import type { MediaCanvas } from "~/types/components";
4
+ import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
2
5
  import MasonryGrid from "../MasonryGrid.vue";
3
6
 
4
7
  interface MasonryGridArgs {
@@ -197,3 +200,48 @@ export const TwoItems: StoryFn<MasonryGridArgs> = (args) => ({
197
200
  `,
198
201
  });
199
202
  TwoItems.storyName = "Two Items (no count prop needed)";
203
+
204
+ export const StressTest: StoryFn = () => ({
205
+ components: { MasonryGrid },
206
+ template: `
207
+ <MasonryGrid :item-min-width="220" :gap="12">
208
+ <template #item-1><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><h3 style="margin: 0;">Supercalifragilisticexpialidociousandthensomemoretextwithoutanyspaces</h3><p style="margin: 0;">https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own</p></div></template>
209
+ <template #item-2><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">Ein sehr langer Beschreibungstext aus Northtown, der auf schmalen Bildschirmen mehrfach umbrechen muss, ohne das Raster zu sprengen oder die Nachbarspalten zusammenzudrücken.</p></div></template>
210
+ <template #item-3><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><img src="/images/banners/banner-1.webp" width="1600" height="400" alt="" style="display: block; max-inline-size: 100%; block-size: auto;"></div></template>
211
+ <template #item-4><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">🙂 👍🏽 👨‍👩‍👧 &lt;b&gt;Not bold&lt;/b&gt;</p></div></template>
212
+ <template #item-5><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;" dir="rtl">مرحبا بالعالم، نص طويل من اليمين إلى اليسار</p></div></template>
213
+ <template #item-6><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"></div></template>
214
+ <template #item-7><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">x</p></div></template>
215
+ <template #item-8><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><h3 style="margin: 0;">Supercalifragilisticexpialidociousandthensomemoretextwithoutanyspaces</h3><p style="margin: 0;">https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own</p></div></template>
216
+ <template #item-9><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">Ein sehr langer Beschreibungstext aus Northtown, der auf schmalen Bildschirmen mehrfach umbrechen muss, ohne das Raster zu sprengen oder die Nachbarspalten zusammenzudrücken.</p></div></template>
217
+ </MasonryGrid>
218
+ `,
219
+ });
220
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
221
+ StressTest.decorators = [
222
+ (story, context) => ({
223
+ components: { story, CanvasSwitcher },
224
+ setup() {
225
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
226
+ return { canvasName };
227
+ },
228
+ template: `
229
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
230
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
231
+ </div>
232
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
233
+ <story />
234
+ </div>
235
+ `,
236
+ }),
237
+ ];
238
+ StressTest.parameters = {
239
+ layout: "fullscreen",
240
+ docs: {
241
+ story: { inline: false, height: "720px" },
242
+ description: {
243
+ story:
244
+ "Oversized and hostile slot content at every `CanvasSwitcher` width. Check that: the unbroken heading and URL wrap inside their cell instead of widening it; the German paragraph wraps; the 1600px image scales down to its cell; emoji, `<b>` and right-to-left text render as text; the empty and one-character cells keep their place; no neighbouring cell is squeezed to nothing and nothing scrolls sideways. When a tile changes height after the first layout (a late-loading font or image, or text re-wrapping), the tiles below it re-pack instead of overlapping; until 2026-10-21 only a container width change re-packed.",
245
+ },
246
+ },
247
+ };
@@ -1,6 +1,6 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
- import { nextTick } from "vue";
3
+ import { defineComponent, h, nextTick, ref } from "vue";
4
4
  import MasonryGrid from "../MasonryGrid.vue";
5
5
 
6
6
  // useElementSize/useResizeObserver (from @vueuse/core) require ResizeObserver, which jsdom
@@ -9,9 +9,9 @@ import MasonryGrid from "../MasonryGrid.vue";
9
9
  // way to feed the component a non-zero measured width.
10
10
  // Vitest 4: a mock's implementation must be a regular function (not an arrow function)
11
11
  // to remain constructible via `new`.
12
- let resizeCallback: ResizeObserverCallback | undefined;
12
+ const resizeCallbacks: ResizeObserverCallback[] = [];
13
13
  const mockResizeObserver = vi.fn(function (callback: ResizeObserverCallback) {
14
- resizeCallback = callback;
14
+ resizeCallbacks.push(callback);
15
15
  return {
16
16
  observe: vi.fn(),
17
17
  unobserve: vi.fn(),
@@ -20,16 +20,18 @@ const mockResizeObserver = vi.fn(function (callback: ResizeObserverCallback) {
20
20
  });
21
21
 
22
22
  beforeEach(() => {
23
- resizeCallback = undefined;
23
+ resizeCallbacks.length = 0;
24
24
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
25
25
  // ⚠️ Do NOT call vi.unstubAllGlobals() in afterEach — it removes global stubs from
26
26
  // vitest.setup.ts ($fetch, etc.)
27
27
  });
28
28
 
29
29
  function simulateResizeTo(width: number) {
30
- resizeCallback?.(
31
- [{ contentBoxSize: [{ inlineSize: width, blockSize: 0 }], contentRect: { width } } as unknown as ResizeObserverEntry],
30
+ resizeCallbacks.forEach((callback) =>
31
+ callback(
32
+ [{ contentBoxSize: [{ inlineSize: width, blockSize: 0 }], contentRect: { width } } as unknown as ResizeObserverEntry],
32
33
  {} as ResizeObserver
34
+ )
33
35
  );
34
36
  }
35
37
 
@@ -37,26 +39,39 @@ function stubOffsetHeight(el: Element, height: number) {
37
39
  Object.defineProperty(el, "offsetHeight", { value: height, configurable: true });
38
40
  }
39
41
 
42
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
43
+
44
+ const mountGrid = async (options: Parameters<typeof mountSuspended<typeof MasonryGrid>>[1] = {}) => {
45
+ const wrapper = await mountSuspended(MasonryGrid, options);
46
+ mounted.push(wrapper);
47
+ return wrapper;
48
+ };
49
+
50
+ afterEach(() => {
51
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
52
+ vi.restoreAllMocks();
53
+ });
54
+
40
55
  describe("MasonryGrid", () => {
41
56
  it("mounts without error", async () => {
42
- const wrapper = await mountSuspended(MasonryGrid);
57
+ const wrapper = await mountGrid();
43
58
  expect(wrapper.vm).toBeTruthy();
44
59
  });
45
60
 
46
61
  it("renders as div by default", async () => {
47
- const wrapper = await mountSuspended(MasonryGrid);
62
+ const wrapper = await mountGrid();
48
63
  expect(wrapper.element.tagName).toBe("DIV");
49
64
  });
50
65
 
51
66
  it("renders a different tag when provided", async () => {
52
- const wrapper = await mountSuspended(MasonryGrid, {
67
+ const wrapper = await mountGrid({
53
68
  props: { tag: "section" },
54
69
  });
55
70
  expect(wrapper.element.tagName).toBe("SECTION");
56
71
  });
57
72
 
58
73
  it("renders slot content in natural authoring order", async () => {
59
- const wrapper = await mountSuspended(MasonryGrid, {
74
+ const wrapper = await mountGrid({
60
75
  slots: {
61
76
  "item-1": "<div>Item 1</div>",
62
77
  "item-2": "<div>Item 2</div>",
@@ -67,23 +82,23 @@ describe("MasonryGrid", () => {
67
82
  });
68
83
 
69
84
  it("renders no items when no slots are provided", async () => {
70
- const wrapper = await mountSuspended(MasonryGrid);
85
+ const wrapper = await mountGrid();
71
86
  expect(wrapper.findAll(".masonry-grid-item")).toHaveLength(0);
72
87
  });
73
88
 
74
89
  it("does not absolutely position items when only one column fits (single-column fallback)", async () => {
75
- const wrapper = await mountSuspended(MasonryGrid, {
90
+ const wrapper = await mountGrid({
76
91
  slots: { "item-1": "<div>Item 1</div>", "item-2": "<div>Item 2</div>" },
77
92
  });
78
93
  await nextTick();
79
94
  await nextTick();
80
- expect(wrapper.find(".masonry-grid-wrapper").classes()).not.toContain("multiple-cols");
95
+ expect(wrapper.find(".masonry-grid-wrapper").attributes("data-multiple-cols")).toBeUndefined();
81
96
  const item = wrapper.find(".masonry-grid-item").element as HTMLElement;
82
- expect(item.style.getPropertyValue("--_position")).toBe("");
97
+ expect(item.style.getPropertyValue("--_masonry-grid-item-position")).toBe("");
83
98
  });
84
99
 
85
100
  it("packs items into whichever column is currently shortest", async () => {
86
- const wrapper = await mountSuspended(MasonryGrid, {
101
+ const wrapper = await mountGrid({
87
102
  // itemMinWidth (300, default) + gap (12, default) = 312 -> floor(700/312) = 2 columns
88
103
  slots: {
89
104
  "item-1": "<div>Item 1</div>",
@@ -107,16 +122,16 @@ describe("MasonryGrid", () => {
107
122
  const el1 = items[0]!.element as HTMLElement;
108
123
  const el2 = items[1]!.element as HTMLElement;
109
124
  const el3 = items[2]!.element as HTMLElement;
110
- expect(el1.style.getPropertyValue("--_position-top")).toBe("0px");
111
- expect(el1.style.getPropertyValue("--_position-left")).toBe("0px");
112
- expect(el2.style.getPropertyValue("--_position-top")).toBe("0px");
113
- expect(el2.style.getPropertyValue("--_position-left")).toBe("356px");
114
- expect(el3.style.getPropertyValue("--_position-top")).toBe("62px");
115
- expect(el3.style.getPropertyValue("--_position-left")).toBe("356px");
125
+ expect(el1.style.getPropertyValue("--_masonry-grid-item-top")).toBe("0px");
126
+ expect(el1.style.getPropertyValue("--_masonry-grid-item-left")).toBe("0px");
127
+ expect(el2.style.getPropertyValue("--_masonry-grid-item-top")).toBe("0px");
128
+ expect(el2.style.getPropertyValue("--_masonry-grid-item-left")).toBe("356px");
129
+ expect(el3.style.getPropertyValue("--_masonry-grid-item-top")).toBe("62px");
130
+ expect(el3.style.getPropertyValue("--_masonry-grid-item-left")).toBe("356px");
116
131
  });
117
132
 
118
133
  it("uses a fixed item width when fixedWidth is set, ignoring stretch sizing", async () => {
119
- const wrapper = await mountSuspended(MasonryGrid, {
134
+ const wrapper = await mountGrid({
120
135
  props: { fixedWidth: true, itemMinWidth: 200 },
121
136
  slots: { "item-1": "<div>Item 1</div>", "item-2": "<div>Item 2</div>" },
122
137
  });
@@ -124,11 +139,11 @@ describe("MasonryGrid", () => {
124
139
  await nextTick();
125
140
  await nextTick();
126
141
  const item = wrapper.find(".masonry-grid-item").element as HTMLElement;
127
- expect(item.style.getPropertyValue("--_element-width")).toBe("200px");
142
+ expect(item.style.getPropertyValue("--_masonry-grid-item-width")).toBe("200px");
128
143
  });
129
144
 
130
145
  it("spaces fixed-width columns by exactly itemWidth + gap, and centers the block via justify", async () => {
131
- const wrapper = await mountSuspended(MasonryGrid, {
146
+ const wrapper = await mountGrid({
132
147
  props: { fixedWidth: true, itemMinWidth: 200, gap: 20, justify: "center" },
133
148
  slots: {
134
149
  "item-1": "<div>Item 1</div>",
@@ -144,15 +159,55 @@ describe("MasonryGrid", () => {
144
159
  // available -> 30px leftover on each side.
145
160
  const items = wrapper.findAll(".masonry-grid-item");
146
161
  const [el1, el2, el3] = items.map((item) => item.element as HTMLElement);
147
- expect(el1!.style.getPropertyValue("--_position-left")).toBe("30px");
148
- expect(el2!.style.getPropertyValue("--_position-left")).toBe("250px");
149
- expect(el3!.style.getPropertyValue("--_position-left")).toBe("470px");
162
+ expect(el1!.style.getPropertyValue("--_masonry-grid-item-left")).toBe("30px");
163
+ expect(el2!.style.getPropertyValue("--_masonry-grid-item-left")).toBe("250px");
164
+ expect(el3!.style.getPropertyValue("--_masonry-grid-item-left")).toBe("470px");
150
165
  });
151
166
 
152
167
  it("applies styleClassPassthrough", async () => {
153
- const wrapper = await mountSuspended(MasonryGrid, {
168
+ const wrapper = await mountGrid({
154
169
  props: { styleClassPassthrough: "custom-class" },
155
170
  });
156
171
  expect(wrapper.classes()).toContain("custom-class");
157
172
  });
173
+
174
+ it("re-packs when a tile changes height without a width change", async () => {
175
+ const wrapper = await mountGrid({
176
+ slots: { "item-1": "<div>1</div>", "item-2": "<div>2</div>", "item-3": "<div>3</div>" },
177
+ });
178
+ const items = wrapper.findAll(".masonry-grid-item");
179
+ stubOffsetHeight(items[0]!.element, 100);
180
+ stubOffsetHeight(items[1]!.element, 50);
181
+ stubOffsetHeight(items[2]!.element, 80);
182
+ simulateResizeTo(700);
183
+ await nextTick();
184
+ await nextTick();
185
+ const el3 = items[2]!.element as HTMLElement;
186
+ expect(el3.style.getPropertyValue("--_masonry-grid-item-left")).toBe("356px");
187
+
188
+ stubOffsetHeight(items[0]!.element, 10);
189
+ resizeCallbacks.at(-1)!([], {} as ResizeObserver);
190
+ vi.advanceTimersByTime(20);
191
+ await nextTick();
192
+ expect(el3.style.getPropertyValue("--_masonry-grid-item-top")).toBe("22px");
193
+ expect(el3.style.getPropertyValue("--_masonry-grid-item-left")).toBe("0px");
194
+ });
195
+
196
+ it("renders a slot that appears after mount", async () => {
197
+ const showSecond = ref(false);
198
+ const Host = defineComponent({
199
+ setup: () => () =>
200
+ h(MasonryGrid, null, {
201
+ "item-1": () => h("div", "First"),
202
+ ...(showSecond.value ? { "item-2": () => h("div", "Second") } : {}),
203
+ }),
204
+ });
205
+ const wrapper = await mountSuspended(Host);
206
+ mounted.push(wrapper);
207
+ expect(wrapper.findAll(".masonry-grid-item")).toHaveLength(1);
208
+
209
+ showSecond.value = true;
210
+ await nextTick();
211
+ expect(wrapper.findAll(".masonry-grid-item").map((item) => item.text())).toEqual(["First", "Second"]);
212
+ });
158
213
  });
@@ -27,7 +27,7 @@ computed tracks `--_full-track-min`, `--_full-track`, `--_popout-track`, `--_con
27
27
 
28
28
  | Hook | When |
29
29
  |---|---|
30
- | `.page-row.full`, `.popout`, `.content`, `.inset-content` | The `variant` prop (default `content`). Sets the row's own track when nested, and the default track for its direct non-PageRow children |
30
+ | `.page-row[data-variant="full"]`, `"popout"`, `"content"`, `"inset-content"` | The `variant` prop (default `content`). Sets the row's own track when nested, and the default track for its direct non-PageRow children |
31
31
  | `[data-align="start"]`, `[data-align="end"]` | The `align` prop on a nested row: bleeds to the left or right edge instead of centring |
32
32
 
33
33
  Direct children of a `.page-row` are grid items placed on a named track (`full`, `popout`,
@@ -35,6 +35,11 @@ Direct children of a `.page-row` are grid items placed on a named track (`full`,
35
35
 
36
36
  ---
37
37
 
38
+ > Changed 2026-10-21: the variant was a bare class on the root (`.page-row.full`, `.content`, ...);
39
+ > it is now the `data-variant` attribute. A consumer's own `.content` or `.full` CSS no longer
40
+ > matches page rows, and selectors like `.page-row.full` need updating to
41
+ > `.page-row[data-variant="full"]`.
42
+
38
43
  ## Global theming
39
44
 
40
45
  Create `assets/styles/setup/07.components/page-row.css` in the consuming app and set tokens on
@@ -3,7 +3,8 @@
3
3
  :is="tag"
4
4
  :id
5
5
  class="page-row"
6
- :class="[elementClasses, variant]"
6
+ :class="[elementClasses]"
7
+ :data-variant="variant"
7
8
  :data-align="align || undefined"
8
9
  :aria-labelledby="ariaLabelledby"
9
10
  >
@@ -42,7 +43,7 @@ watch(
42
43
  /*
43
44
  * Page layout grid — https://layout-breakouts-builder.vercel.app
44
45
  * Each .page-row is both a CSS grid container and a grid item when nested.
45
- * Direct children default to the content column; nest a .page-row.{variant}
46
+ * Direct children default to the content column; nest a .page-row[data-variant]
46
47
  * to break out to a wider or narrower track.
47
48
  */
48
49
  .page-row {
@@ -99,13 +100,13 @@ watch(
99
100
  * its own children span full width, not just control placement when nested.
100
101
  * Specificity (0,2,0) — nested .page-row variant overrides below win at (0,3,0).
101
102
  */
102
- .page-row.full > * {
103
+ .page-row[data-variant="full"] > * {
103
104
  grid-column: full;
104
105
  }
105
- .page-row.popout > * {
106
+ .page-row[data-variant="popout"] > * {
106
107
  grid-column: popout;
107
108
  }
108
- .page-row.inset-content > * {
109
+ .page-row[data-variant="inset-content"] > * {
109
110
  grid-column: inset-content;
110
111
  }
111
112
 
@@ -118,16 +119,16 @@ watch(
118
119
  .page-row > .page-row {
119
120
  align-content: start;
120
121
 
121
- &.full {
122
+ &[data-variant="full"] {
122
123
  grid-column: full;
123
124
  }
124
- &.popout {
125
+ &[data-variant="popout"] {
125
126
  grid-column: popout;
126
127
  }
127
- &.content {
128
+ &[data-variant="content"] {
128
129
  grid-column: content;
129
130
  }
130
- &.inset-content {
131
+ &[data-variant="inset-content"] {
131
132
  grid-column: inset-content;
132
133
  }
133
134
  }
@@ -138,25 +139,25 @@ watch(
138
139
  * Specificity (0,4,0) beats the symmetric rules above at (0,3,0).
139
140
  */
140
141
  .page-row > .page-row[data-align="start"] {
141
- &.popout {
142
+ &[data-variant="popout"] {
142
143
  grid-column: full-start / popout-end;
143
144
  }
144
- &.content {
145
+ &[data-variant="content"] {
145
146
  grid-column: full-start / content-end;
146
147
  }
147
- &.inset-content {
148
+ &[data-variant="inset-content"] {
148
149
  grid-column: full-start / inset-content-end;
149
150
  }
150
151
  }
151
152
 
152
153
  .page-row > .page-row[data-align="end"] {
153
- &.popout {
154
+ &[data-variant="popout"] {
154
155
  grid-column: popout-start / full-end;
155
156
  }
156
- &.content {
157
+ &[data-variant="content"] {
157
158
  grid-column: content-start / full-end;
158
159
  }
159
- &.inset-content {
160
+ &[data-variant="inset-content"] {
160
161
  grid-column: inset-content-start / full-end;
161
162
  }
162
163
  }
@@ -351,3 +351,26 @@ AriaLabelledBy.parameters = {
351
351
  },
352
352
  },
353
353
  };
354
+
355
+ export const StressTest: StoryFn = () => ({
356
+ components: { PageRowComponent },
357
+ template: `
358
+ <PageRowComponent variant="full">
359
+ <PageRowComponent variant="popout"><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><h3 style="margin: 0;">Supercalifragilisticexpialidociousandthensomemoretextwithoutanyspaces</h3><p style="margin: 0;">https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own</p></div></PageRowComponent>
360
+ <PageRowComponent variant="content"><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">Ein sehr langer Beschreibungstext aus Northtown, der auf schmalen Bildschirmen mehrfach umbrechen muss, ohne das Raster zu sprengen oder die Nachbarspalten zusammenzudrücken.</p><img src="/images/banners/banner-1.webp" width="1600" height="400" alt="" style="display: block; max-inline-size: 100%; block-size: auto;"></div></PageRowComponent>
361
+ <PageRowComponent variant="inset-content" align="end"><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">🙂 👍🏽 👨‍👩‍👧 &lt;b&gt;Not bold&lt;/b&gt;</p><p style="margin: 0;" dir="rtl">مرحبا بالعالم، نص طويل من اليمين إلى اليسار</p></div></PageRowComponent>
362
+ <PageRowComponent variant="full"><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">x</p></div></PageRowComponent>
363
+ </PageRowComponent>
364
+ `,
365
+ });
366
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
367
+ StressTest.parameters = {
368
+ layout: "fullscreen",
369
+ docs: {
370
+ story: { inline: false, height: "720px" },
371
+ description: {
372
+ story:
373
+ "Oversized and hostile slot content at every `CanvasSwitcher` width. Check that: the unbroken heading and URL wrap inside their cell instead of widening it; the German paragraph wraps; the 1600px image scales down to its cell; emoji, `<b>` and right-to-left text render as text; the empty and one-character cells keep their place; no neighbouring cell is squeezed to nothing and nothing scrolls sideways. Each nested row uses a different variant; every one keeps its track instead of pushing the page wider.",
374
+ },
375
+ },
376
+ };
@@ -23,6 +23,7 @@ describe("PageRow", () => {
23
23
 
24
24
  afterEach(() => {
25
25
  wrapper?.unmount();
26
+ vi.restoreAllMocks();
26
27
  });
27
28
 
28
29
  describe("Rendering", () => {
@@ -62,12 +63,12 @@ describe("PageRow", () => {
62
63
  describe("Props — variant", () => {
63
64
  it("applies content class by default", async () => {
64
65
  await createWrapper();
65
- expect(wrapper.classes()).toContain("content");
66
+ expect(wrapper.attributes("data-variant")).toBe("content");
66
67
  });
67
68
 
68
69
  it.each(["full", "popout", "content", "inset-content"])("applies variant class %s", async (variant) => {
69
70
  await createWrapper({ variant });
70
- expect(wrapper.classes()).toContain(variant);
71
+ expect(wrapper.attributes("data-variant")).toBe(variant);
71
72
  });
72
73
  });
73
74
 
@@ -121,7 +122,7 @@ describe("PageRow", () => {
121
122
 
122
123
  it("combines variant and align on the same element", async () => {
123
124
  await createWrapper({ variant: "content", align: "start" });
124
- expect(wrapper.classes()).toContain("content");
125
+ expect(wrapper.attributes("data-variant")).toBe("content");
125
126
  expect(wrapper.attributes("data-align")).toBe("start");
126
127
  });
127
128
  });
@@ -199,13 +200,13 @@ describe("PageRow", () => {
199
200
  it("includes both base class and variant class", async () => {
200
201
  await createWrapper({ variant: "full" });
201
202
  expect(wrapper.classes()).toContain("page-row");
202
- expect(wrapper.classes()).toContain("full");
203
+ expect(wrapper.attributes("data-variant")).toBe("full");
203
204
  });
204
205
 
205
206
  it("includes elementClasses from styleClassPassthrough alongside other classes", async () => {
206
207
  await createWrapper({ variant: "popout", styleClassPassthrough: ["extra-class"] });
207
208
  expect(wrapper.classes()).toContain("page-row");
208
- expect(wrapper.classes()).toContain("popout");
209
+ expect(wrapper.attributes("data-variant")).toBe("popout");
209
210
  expect(wrapper.classes()).toContain("extra-class");
210
211
  });
211
212
  });
@@ -174,6 +174,7 @@ watch(
174
174
  background-color: var(--action-menu-popover-background, var(--slate-00));
175
175
  border-radius: var(--action-menu-popover-border-radius, 0.8rem);
176
176
  min-width: var(--action-menu-popover-min-width, 20rem);
177
+ max-inline-size: var(--action-menu-popover-max-width, min(32rem, calc(100vw - 3.2rem)));
177
178
  box-shadow: var(--action-menu-popover-shadow, 0 0.4rem 1.6rem rgba(0, 0, 0, 0.1));
178
179
  overflow: hidden;
179
180
 
@@ -181,7 +182,10 @@ watch(
181
182
  top: calc(anchor(bottom) + var(--action-menu-block-distance, 0.4rem));
182
183
  right: anchor(right);
183
184
  left: auto;
184
- position-try-fallbacks: flip-block;
185
+ position-try-fallbacks:
186
+ flip-block,
187
+ flip-inline,
188
+ flip-block flip-inline;
185
189
 
186
190
  opacity: 0;
187
191
  display: none;
@@ -221,7 +225,7 @@ watch(
221
225
 
222
226
  .action-menu-list {
223
227
  display: grid;
224
- grid-template-columns: auto 1fr auto;
228
+ grid-template-columns: auto minmax(0, 1fr) auto;
225
229
 
226
230
  list-style: none;
227
231
  padding: 0;