srcdev-nuxt-components 9.6.11 → 9.6.13

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 (77) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +2 -1
  2. package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
  3. package/.claude/skills/component-compliance-checklist.md +24 -1
  4. package/.claude/skills/component-naming.md +1 -0
  5. package/.claude/skills/components/banner-video.md +24 -30
  6. package/.claude/skills/components/content-docs.md +18 -5
  7. package/.claude/skills/components/display-dialog.md +16 -6
  8. package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
  9. package/.claude/skills/components/display-tooltip.md +17 -6
  10. package/.claude/skills/components/form-field.md +15 -0
  11. package/.claude/skills/components/form-fieldset.md +18 -2
  12. package/.claude/skills/components/marquee-scroller.md +51 -14
  13. package/.claude/skills/components/skip-links.md +16 -0
  14. package/.claude/skills/composable-tooltips-guide.md +4 -4
  15. package/.claude/skills/index.md +6 -6
  16. package/.claude/skills/testing-add-unit-test.md +8 -0
  17. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  18. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  19. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  20. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  21. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  22. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  23. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  24. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  25. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  26. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  27. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  28. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  29. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  30. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  31. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  32. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  33. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  34. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  35. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  36. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  37. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  38. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  39. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  40. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  41. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  42. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  43. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  44. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  45. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  46. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  47. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  48. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  49. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  50. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  51. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  52. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  53. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  54. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  55. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  56. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  57. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  58. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  59. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  60. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  61. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  62. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  63. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  64. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  65. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  66. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  67. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  68. package/app/composables/tests/useTooltips.spec.ts +3 -3
  69. package/app/types/components/index.ts +1 -1
  70. package/package.json +1 -1
  71. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  72. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  73. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  74. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  75. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  76. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  77. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -1,7 +1,8 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { computed } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import ContentDocs from "../ContentDocs.vue";
4
- import type { DocsNavItem } from "~/types/components";
4
+ import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
5
+ import type { DocsNavItem, MediaCanvas } from "~/types/components";
5
6
 
6
7
  interface ContentDocsStoryArgs {
7
8
  tag: "div" | "section" | "article" | "main";
@@ -9,6 +10,9 @@ interface ContentDocsStoryArgs {
9
10
  docsPageNavItems: DocsNavItem[];
10
11
  docsNavLabel: string;
11
12
  docsPageNavLabel: string;
13
+ docsNavAriaLabel: string;
14
+ docsPageNavAriaLabel: string;
15
+ panelVariant: "modern" | "classic";
12
16
  styleClassPassthrough: string[];
13
17
  activeNavItem: string;
14
18
  activePageNavItem: string;
@@ -50,11 +54,28 @@ export default {
50
54
  docsNavLabel: {
51
55
  control: "text",
52
56
  description: "Heading text for the docsNav panel",
53
- table: { category: "Basic" },
57
+ table: { category: "Copy" },
54
58
  },
55
59
  docsPageNavLabel: {
56
60
  control: "text",
57
61
  description: "Heading text for the docsPageNav panel",
62
+ table: { category: "Copy" },
63
+ },
64
+ docsNavAriaLabel: {
65
+ control: "text",
66
+ description: "aria-label on the docsNav `<nav>` landmark. Override for localisation",
67
+ table: { category: "Copy" },
68
+ },
69
+ docsPageNavAriaLabel: {
70
+ control: "text",
71
+ description: "aria-label on the docsPageNav `<nav>` landmark. Override for localisation",
72
+ table: { category: "Copy" },
73
+ },
74
+ panelVariant: {
75
+ control: { type: "select" },
76
+ options: ["classic", "modern"],
77
+ description:
78
+ "Collapsible panel implementation. `classic` (default) works on WebKit; `modern` uses `::details-content`, which Safari drops",
58
79
  table: { category: "Basic" },
59
80
  },
60
81
  styleClassPassthrough: {
@@ -64,12 +85,12 @@ export default {
64
85
  },
65
86
  activeNavItem: {
66
87
  control: "text",
67
- description: "The `to` of the currently-active docsNav item",
88
+ description: "The `to` of the currently-active docsNav item (`v-model:active-nav-item`)",
68
89
  table: { category: "Model" },
69
90
  },
70
91
  activePageNavItem: {
71
92
  control: "text",
72
- description: "The `to` of the currently-active docsPageNav item",
93
+ description: "The `to` of the currently-active docsPageNav item (`v-model:active-page-nav-item`)",
73
94
  table: { category: "Model" },
74
95
  },
75
96
  },
@@ -79,10 +100,40 @@ export default {
79
100
  docsPageNavItems: defaultPageNavItems,
80
101
  docsNavLabel: "Navigation",
81
102
  docsPageNavLabel: "On this page",
103
+ docsNavAriaLabel: "Docs navigation",
104
+ docsPageNavAriaLabel: "On this page",
105
+ panelVariant: "classic",
82
106
  styleClassPassthrough: [],
83
107
  activeNavItem: defaultNavItems[0]?.to,
84
108
  activePageNavItem: "",
85
109
  },
110
+ decorators: [
111
+ // Breakpoints are container queries on the component's own width, so a constrained wrapper stands in for a narrower viewport.
112
+ (story, context) => ({
113
+ components: { story, CanvasSwitcher },
114
+ setup() {
115
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
116
+ return { canvasName };
117
+ },
118
+ template: `
119
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
120
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
121
+ </div>
122
+ <div :class="canvasName" style="margin-inline: auto; padding: 1.6rem; outline: 1px dashed currentColor;">
123
+ <story />
124
+ </div>
125
+ `,
126
+ }),
127
+ ],
128
+ parameters: {
129
+ layout: "fullscreen",
130
+ docs: {
131
+ description: {
132
+ component:
133
+ "A three-area docs layout: a site-level `docsNavItems` list, an on-this-page `docsPageNavItems` list, and the `docsContent` slot. Breakpoints follow the component's own width (container queries, mirrored in script): below 768px both navs are collapsible panels that overlay the content, from 768px the page nav is pinned open in a right-hand column, and from 1024px both are pinned open in three columns. The active link in each list is a `v-model` (`activeNavItem`, `activePageNavItem`) and gets `aria-current=\"page\"`. Headings and landmark labels are props for localisation, and `panelVariant` picks the collapsible implementation (`classic` by default, for WebKit).",
134
+ },
135
+ },
136
+ },
86
137
  } as Meta<typeof ContentDocs>;
87
138
 
88
139
  // ─── Stories ─────────────────────────────────────────────────────────────────
@@ -104,39 +155,45 @@ const Template: StoryFn<ContentDocsStoryArgs> = (args) => ({
104
155
  <template #docsContent>
105
156
  <h3 style="margin-top:0">Docs Content</h3>
106
157
  <p>
107
- Resize this story's preview panel to see the layout switch between mobile, tablet,
108
- and desktop — thresholds are based on the component's own measured width, not the
109
- browser viewport, so page decoration elsewhere on a real page won't throw them off.
158
+ Switch canvas above to see the layout change between mobile, tablet and desktop. The
159
+ thresholds follow the component's own width, not the browser viewport.
110
160
  </p>
111
161
  </template>
112
162
  </ContentDocs>
113
163
  `,
114
164
  });
115
165
 
166
+ const story = (text: string) => ({ docs: { description: { story: text } } });
167
+
116
168
  export const Default = Template.bind({});
117
169
  Default.args = {};
170
+ Default.parameters = story("Both navs with icons on some items, the first docs link active.");
118
171
 
119
172
  export const NoIcons = Template.bind({});
120
173
  NoIcons.args = {
121
174
  docsNavItems: defaultNavItems.map(({ icon: _icon, ...item }) => item),
122
175
  docsPageNavItems: defaultPageNavItems.map(({ icon: _icon, ...item }) => item),
123
176
  };
177
+ NoIcons.parameters = story("No item has an icon: the icon column stays reserved, so labels still line up.");
124
178
 
125
179
  export const NavOnly = Template.bind({});
126
180
  NavOnly.args = {
127
181
  docsPageNavItems: [],
128
182
  };
183
+ NavOnly.parameters = story("An empty `docsPageNavItems` omits the page nav entirely.");
129
184
 
130
185
  export const PageNavOnly = Template.bind({});
131
186
  PageNavOnly.args = {
132
187
  docsNavItems: [],
133
188
  };
189
+ PageNavOnly.parameters = story("An empty `docsNavItems` omits the docs nav entirely.");
134
190
 
135
191
  export const CustomLabels = Template.bind({});
136
192
  CustomLabels.args = {
137
193
  docsNavLabel: "Sections",
138
194
  docsPageNavLabel: "Contents",
139
195
  };
196
+ CustomLabels.parameters = story("Custom panel headings via `docsNavLabel` and `docsPageNavLabel`.");
140
197
 
141
198
  export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
142
199
  components: { ContentDocs },
@@ -169,3 +226,72 @@ export const IconAtEnd: StoryFn<ContentDocsStoryArgs> = (args) => ({
169
226
  `,
170
227
  });
171
228
  IconAtEnd.args = {};
229
+ IconAtEnd.parameters = story(
230
+ "`--content-docs-nav-link-icon-order: rtl` (and the page-nav equivalent) puts each icon after its label."
231
+ );
232
+
233
+ const stressNavItems: DocsNavItem[] = [
234
+ { label: "Bevorzugtekontaktmethodefürrückfragenundterminvereinbarungen", to: "/docs/a", icon: "lucide:rocket" },
235
+ { label: "https://example.com/a/very/long/path/that/never/breaks/naturally/at/all", to: "/docs/b" },
236
+ { label: "😀 Emoji first", to: "/docs/c", icon: "lucide:smile" },
237
+ { label: "<b>not bold</b>", to: "/docs/d" },
238
+ { label: "طريقة الاتصال المفضلة", to: "/docs/e", icon: "lucide:globe" },
239
+ { label: "", to: "/docs/f" },
240
+ { label: "x", to: "/docs/g" },
241
+ { label: "Duplicate destination", to: "/docs/a" },
242
+ { label: "Broken icon name", to: "/docs/h", icon: "lucide:does-not-exist" },
243
+ ...Array.from({ length: 30 }, (_, i) => ({ label: `Generated page ${i + 1}`, to: `/docs/generated-${i + 1}` })),
244
+ ];
245
+
246
+ const stressPageNavItems: DocsNavItem[] = [
247
+ { label: "Ein sehr langer Abschnittstitel, der in einer schmalen Spalte umbrechen muss", to: "/docs#one" },
248
+ { label: "Abschnittohneleerzeichendernichtumbrechenkannundtrotzdempassenmuss", to: "/docs#two" },
249
+ { label: "طريقة الاتصال", to: "/docs#three" },
250
+ ];
251
+
252
+ export const StressTest: StoryFn<ContentDocsStoryArgs> = (args) => ({
253
+ components: { ContentDocs },
254
+ setup() {
255
+ return { args };
256
+ },
257
+ template: `
258
+ <ContentDocs
259
+ v-bind="args"
260
+ v-model:active-nav-item="args.activeNavItem"
261
+ v-model:active-page-nav-item="args.activePageNavItem"
262
+ >
263
+ <template #docsContent>
264
+ <h1 style="margin-top:0">Dokumentationsseitenüberschriftohneleerzeichen</h1>
265
+ <p>https://example.com/an/unbroken/url/in/the/content/slot/that/would/otherwise/widen/the/middle/column</p>
266
+ <p>Slot content is the consumer's own markup: the wide table below scrolls in its own wrapper rather than stretching the grid.</p>
267
+ <div style="overflow-x: auto;">
268
+ <table style="min-width: 80rem; border-collapse: collapse;">
269
+ <tr><th style="text-align:start">Prop</th><th style="text-align:start">Type</th><th style="text-align:start">Default</th><th style="text-align:start">Description</th></tr>
270
+ <tr><td>docsNavItems</td><td>DocsNavItem[]</td><td>[]</td><td>Items rendered in the docs nav panel</td></tr>
271
+ </table>
272
+ </div>
273
+ </template>
274
+ </ContentDocs>
275
+ `,
276
+ });
277
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
278
+ StressTest.args = {
279
+ docsNavItems: stressNavItems,
280
+ docsPageNavItems: stressPageNavItems,
281
+ docsNavLabel: "Dokumentationsnavigationüberschriftohneleerzeichen 😀",
282
+ docsPageNavLabel: "<i>Auf dieser Seite</i>",
283
+ docsNavAriaLabel: "Dokumentationsnavigation",
284
+ docsPageNavAriaLabel: "Auf dieser Seite",
285
+ activeNavItem: "/docs/a",
286
+ activePageNavItem: "/docs#two",
287
+ };
288
+ StressTest.parameters = {
289
+ initialCanvas: "mobileCanvas",
290
+ docs: {
291
+ story: { inline: false, height: "900px" },
292
+ description: {
293
+ story:
294
+ "39 docs links (unbroken German, a long URL, emoji first, HTML-like and right-to-left labels, an empty label, a one-character label, a duplicate `to`, an unknown icon name) and three page links, with an unbroken heading. Check at every canvas width: unbroken text wraps inside its link and never widens a column, right-to-left labels read and align right-to-left, markup renders as text, both links sharing `/docs/a` show as active, and the unbroken heading and URL in the content slot wrap instead of widening the middle column at tablet and desktop. The empty label renders a thin link with no accessible name: the component can't invent one, so filter empty labels out of your data. Link labels have no line-clamp token on purpose: they name their destinations.",
295
+ },
296
+ },
297
+ };
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { nextTick } from "vue";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { defineComponent, h, nextTick, ref } from "vue";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
4
  import ContentDocs from "../ContentDocs.vue";
5
5
 
@@ -37,67 +37,75 @@ const navItems = [
37
37
  const pageNavItems = [{ label: "Overview", to: "/one#overview" }];
38
38
 
39
39
  describe("ContentDocs", () => {
40
+ let wrapper: Awaited<ReturnType<typeof mountSuspended>> | undefined;
41
+
42
+ afterEach(() => {
43
+ wrapper?.unmount();
44
+ wrapper = undefined;
45
+ vi.restoreAllMocks();
46
+ });
47
+
40
48
  it("mounts without error", async () => {
41
- const wrapper = await mountSuspended(ContentDocs);
49
+ wrapper = await mountSuspended(ContentDocs);
42
50
  expect(wrapper.vm).toBeTruthy();
43
51
  });
44
52
 
45
53
  // ─── docsNav / docsPageNav visibility ──────────────────────────────────────
46
54
 
47
- it("does not render .docs-nav when docsNavItems is empty", async () => {
48
- const wrapper = await mountSuspended(ContentDocs);
49
- expect(wrapper.find(".docs-nav").exists()).toBe(false);
55
+ it("does not render .content-docs-nav when docsNavItems is empty", async () => {
56
+ wrapper = await mountSuspended(ContentDocs);
57
+ expect(wrapper.find(".content-docs-nav").exists()).toBe(false);
50
58
  });
51
59
 
52
- it("renders .docs-nav with a link per item when docsNavItems is provided", async () => {
53
- const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
54
- const links = wrapper.find(".docs-nav").findAll("a");
60
+ it("renders .content-docs-nav with a link per item when docsNavItems is provided", async () => {
61
+ wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
62
+ const links = wrapper.find(".content-docs-nav").findAll("a");
55
63
  expect(links).toHaveLength(2);
56
64
  expect(links[0]?.text()).toBe("One");
57
65
  expect(links[0]?.attributes("href")).toBe("/one");
58
66
  });
59
67
 
60
- it("does not render .docs-page-nav when docsPageNavItems is empty", async () => {
61
- const wrapper = await mountSuspended(ContentDocs);
62
- expect(wrapper.find(".docs-page-nav").exists()).toBe(false);
68
+ it("does not render .content-docs-page-nav when docsPageNavItems is empty", async () => {
69
+ wrapper = await mountSuspended(ContentDocs);
70
+ expect(wrapper.find(".content-docs-page-nav").exists()).toBe(false);
63
71
  });
64
72
 
65
- it("renders .docs-page-nav with a link per item when docsPageNavItems is provided", async () => {
66
- const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
67
- const links = wrapper.find(".docs-page-nav").findAll("a");
73
+ it("renders .content-docs-page-nav with a link per item when docsPageNavItems is provided", async () => {
74
+ wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
75
+ const links = wrapper.find(".content-docs-page-nav").findAll("a");
68
76
  expect(links).toHaveLength(1);
69
77
  expect(links[0]?.text()).toBe("Overview");
70
78
  });
71
79
 
72
80
  it("renders the docsContent slot", async () => {
73
- const wrapper = await mountSuspended(ContentDocs, {
81
+ wrapper = await mountSuspended(ContentDocs, {
74
82
  slots: { docsContent: '<p data-testid="body">Body</p>' },
75
83
  });
76
84
  expect(wrapper.find('[data-testid="body"]').text()).toBe("Body");
77
85
  });
78
86
 
79
87
  it("places docsContent before docsNav and docsPageNav in DOM order, so a consumer's h1 is reachable before either nav panel's h3 heading (visual position is unaffected — grid-template-areas places all three, not source order)", async () => {
80
- const wrapper = await mountSuspended(ContentDocs, {
88
+ wrapper = await mountSuspended(ContentDocs, {
81
89
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
82
90
  slots: { docsContent: "<h1>Title</h1>" },
83
91
  });
84
92
  const inner = wrapper.find(".content-docs-inner").element;
85
93
  const children = [...inner.children].map((el) => el.className);
86
- expect(children).toEqual(["docs-content", "docs-nav", "docs-page-nav"]);
94
+ expect(children).toEqual(["content-docs-content", "content-docs-nav", "content-docs-page-nav"]);
87
95
  });
88
96
 
89
97
  // ─── labels ─────────────────────────────────────────────────────────────
90
98
 
91
99
  it("defaults docsNavLabel to 'Navigation' and docsPageNavLabel to 'On this page'", async () => {
92
- const wrapper = await mountSuspended(ContentDocs, {
100
+ wrapper = await mountSuspended(ContentDocs, {
93
101
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
94
102
  });
95
- expect(wrapper.find(".docs-nav-heading").text()).toBe("Navigation");
96
- expect(wrapper.find(".docs-page-nav-heading").text()).toBe("On this page");
103
+ expect(wrapper.find(".content-docs-nav-heading").text()).toBe("Navigation");
104
+ expect(wrapper.find(".content-docs-page-nav-heading").text()).toBe("On this page");
97
105
  });
98
106
 
99
107
  it("uses custom docsNavLabel / docsPageNavLabel when provided", async () => {
100
- const wrapper = await mountSuspended(ContentDocs, {
108
+ wrapper = await mountSuspended(ContentDocs, {
101
109
  props: {
102
110
  docsNavItems: navItems,
103
111
  docsPageNavItems: pageNavItems,
@@ -105,111 +113,111 @@ describe("ContentDocs", () => {
105
113
  docsPageNavLabel: "Contents",
106
114
  },
107
115
  });
108
- expect(wrapper.find(".docs-nav-heading").text()).toBe("Sections");
109
- expect(wrapper.find(".docs-page-nav-heading").text()).toBe("Contents");
116
+ expect(wrapper.find(".content-docs-nav-heading").text()).toBe("Sections");
117
+ expect(wrapper.find(".content-docs-page-nav-heading").text()).toBe("Contents");
110
118
  });
111
119
 
112
120
  // ─── icons ──────────────────────────────────────────────────────────────
113
121
 
114
122
  it("renders an icon on a docsNav item when item.icon is set", async () => {
115
- const wrapper = await mountSuspended(ContentDocs, {
123
+ wrapper = await mountSuspended(ContentDocs, {
116
124
  props: { docsNavItems: [{ label: "One", to: "/one", icon: "lucide:home" }] },
117
125
  });
118
- expect(wrapper.find(".docs-nav-link-icon").exists()).toBe(true);
126
+ expect(wrapper.find(".content-docs-nav-link-icon").exists()).toBe(true);
119
127
  });
120
128
 
121
129
  it("does not render an icon on a docsNav item when item.icon is omitted", async () => {
122
- const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
123
- expect(wrapper.find(".docs-nav-link-icon").exists()).toBe(false);
130
+ wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
131
+ expect(wrapper.find(".content-docs-nav-link-icon").exists()).toBe(false);
124
132
  });
125
133
 
126
134
  it("renders an icon on a docsPageNav item when item.icon is set", async () => {
127
- const wrapper = await mountSuspended(ContentDocs, {
135
+ wrapper = await mountSuspended(ContentDocs, {
128
136
  props: { docsPageNavItems: [{ label: "Overview", to: "/one#overview", icon: "lucide:list" }] },
129
137
  });
130
- expect(wrapper.find(".docs-page-nav-link-icon").exists()).toBe(true);
138
+ expect(wrapper.find(".content-docs-page-nav-link-icon").exists()).toBe(true);
131
139
  });
132
140
 
133
141
  // ─── active item ────────────────────────────────────────────────────────
134
142
 
135
- it("applies is-active and aria-current to the link matching activeNavItem", async () => {
136
- const wrapper = await mountSuspended(ContentDocs, {
143
+ it("sets aria-current='page' only on the link matching activeNavItem", async () => {
144
+ wrapper = await mountSuspended(ContentDocs, {
137
145
  props: { docsNavItems: navItems, activeNavItem: "/two" },
138
146
  });
139
- const links = wrapper.find(".docs-nav").findAll("a");
140
- expect(links[0]?.classes()).not.toContain("is-active");
141
- expect(links[1]?.classes()).toContain("is-active");
147
+ const links = wrapper.find(".content-docs-nav").findAll("a");
148
+ expect(links[0]?.attributes("aria-current")).toBeUndefined();
142
149
  expect(links[1]?.attributes("aria-current")).toBe("page");
150
+ expect(links[1]?.classes()).not.toContain("is-active");
143
151
  });
144
152
 
145
153
  it("emits update:activeNavItem when a docsNav link is clicked", async () => {
146
- const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
147
- await wrapper.find(".docs-nav").findAll("a")[1]?.trigger("click");
154
+ wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
155
+ await wrapper.find(".content-docs-nav").findAll("a")[1]?.trigger("click");
148
156
  expect(wrapper.emitted("update:activeNavItem")?.[0]).toEqual(["/two"]);
149
157
  });
150
158
 
151
159
  it("emits update:activePageNavItem when a docsPageNav link is clicked", async () => {
152
- const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
153
- await wrapper.find(".docs-page-nav").find("a").trigger("click");
160
+ wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
161
+ await wrapper.find(".content-docs-page-nav").find("a").trigger("click");
154
162
  expect(wrapper.emitted("update:activePageNavItem")?.[0]).toEqual(["/one#overview"]);
155
163
  });
156
164
 
157
165
  // ─── closing on link click (mobile overlay behaviour) ─────────────────────
158
166
 
159
167
  it("closes the docsNav panel when a link inside it is clicked", async () => {
160
- const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
168
+ wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } });
161
169
 
162
- await wrapper.find(".docs-nav summary").trigger("click");
163
- expect(wrapper.find(".docs-nav details").attributes("open")).toBeDefined();
170
+ await wrapper.find(".content-docs-nav summary").trigger("click");
171
+ expect(wrapper.find(".content-docs-nav details").attributes("open")).toBeDefined();
164
172
 
165
- await wrapper.find(".docs-nav").findAll("a")[0]?.trigger("click");
166
- expect(wrapper.find(".docs-nav details").attributes("open")).toBeUndefined();
173
+ await wrapper.find(".content-docs-nav").findAll("a")[0]?.trigger("click");
174
+ expect(wrapper.find(".content-docs-nav details").attributes("open")).toBeUndefined();
167
175
  });
168
176
 
169
177
  it("closes the docsPageNav panel when a link inside it is clicked", async () => {
170
- const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
178
+ wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } });
171
179
 
172
- await wrapper.find(".docs-page-nav summary").trigger("click");
173
- expect(wrapper.find(".docs-page-nav details").attributes("open")).toBeDefined();
180
+ await wrapper.find(".content-docs-page-nav summary").trigger("click");
181
+ expect(wrapper.find(".content-docs-page-nav details").attributes("open")).toBeDefined();
174
182
 
175
- await wrapper.find(".docs-page-nav").find("a").trigger("click");
176
- expect(wrapper.find(".docs-page-nav details").attributes("open")).toBeUndefined();
183
+ await wrapper.find(".content-docs-page-nav").find("a").trigger("click");
184
+ expect(wrapper.find(".content-docs-page-nav details").attributes("open")).toBeUndefined();
177
185
  });
178
186
 
179
187
  // ─── container-width-driven forceOpened ────────────────────────────────────
180
188
 
181
189
  describe("breakpoint-driven forceOpened", () => {
182
190
  it("neither panel is forceOpened below 768px (mobile)", async () => {
183
- const wrapper = await mountSuspended(ContentDocs, {
191
+ wrapper = await mountSuspended(ContentDocs, {
184
192
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
185
193
  });
186
194
  triggerResize(wrapper.find(".content-docs").element as HTMLElement, 500);
187
195
  await nextTick();
188
196
 
189
- expect(wrapper.find(".docs-nav .icon-wrapper").exists()).toBe(true);
190
- expect(wrapper.find(".docs-page-nav .icon-wrapper").exists()).toBe(true);
197
+ expect(wrapper.find(".content-docs-nav .icon-wrapper").exists()).toBe(true);
198
+ expect(wrapper.find(".content-docs-page-nav .icon-wrapper").exists()).toBe(true);
191
199
  });
192
200
 
193
201
  it("only docsPageNav is forceOpened between 768px and 1023px (tablet)", async () => {
194
- const wrapper = await mountSuspended(ContentDocs, {
202
+ wrapper = await mountSuspended(ContentDocs, {
195
203
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
196
204
  });
197
205
  triggerResize(wrapper.find(".content-docs").element as HTMLElement, 900);
198
206
  await nextTick();
199
207
 
200
- expect(wrapper.find(".docs-nav .icon-wrapper").classes()).not.toContain("icon-wrapper--hidden");
201
- expect(wrapper.find(".docs-page-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
208
+ expect(wrapper.find(".content-docs-nav .icon-wrapper").classes()).not.toContain("icon-wrapper--hidden");
209
+ expect(wrapper.find(".content-docs-page-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
202
210
  });
203
211
 
204
212
  it("both panels are forceOpened at 1024px and above (desktop)", async () => {
205
- const wrapper = await mountSuspended(ContentDocs, {
213
+ wrapper = await mountSuspended(ContentDocs, {
206
214
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
207
215
  });
208
216
  triggerResize(wrapper.find(".content-docs").element as HTMLElement, 1200);
209
217
  await nextTick();
210
218
 
211
- expect(wrapper.find(".docs-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
212
- expect(wrapper.find(".docs-page-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
219
+ expect(wrapper.find(".content-docs-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
220
+ expect(wrapper.find(".content-docs-page-nav .icon-wrapper").classes()).toContain("icon-wrapper--hidden");
213
221
  });
214
222
  });
215
223
 
@@ -217,34 +225,92 @@ describe("ContentDocs", () => {
217
225
 
218
226
  describe("panel name grouping", () => {
219
227
  it("shares one details name between docsNav and docsPageNav on mobile", async () => {
220
- const wrapper = await mountSuspended(ContentDocs, {
228
+ wrapper = await mountSuspended(ContentDocs, {
221
229
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
222
230
  });
223
231
  triggerResize(wrapper.find(".content-docs").element as HTMLElement, 500);
224
232
  await nextTick();
225
233
 
226
- const navName = wrapper.find(".docs-nav details").attributes("name");
227
- const pageNavName = wrapper.find(".docs-page-nav details").attributes("name");
234
+ const navName = wrapper.find(".content-docs-nav details").attributes("name");
235
+ const pageNavName = wrapper.find(".content-docs-page-nav details").attributes("name");
228
236
  expect(navName).toBe(pageNavName);
229
237
  });
230
238
 
231
239
  it("uses distinct details names on desktop, where both must stay open simultaneously", async () => {
232
- const wrapper = await mountSuspended(ContentDocs, {
240
+ wrapper = await mountSuspended(ContentDocs, {
233
241
  props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
234
242
  });
235
243
  triggerResize(wrapper.find(".content-docs").element as HTMLElement, 1200);
236
244
  await nextTick();
237
245
 
238
- const navName = wrapper.find(".docs-nav details").attributes("name");
239
- const pageNavName = wrapper.find(".docs-page-nav details").attributes("name");
246
+ const navName = wrapper.find(".content-docs-nav details").attributes("name");
247
+ const pageNavName = wrapper.find(".content-docs-page-nav details").attributes("name");
240
248
  expect(navName).not.toBe(pageNavName);
241
249
  });
242
250
  });
243
251
 
252
+ // ─── worst-case data and markup ─────────────────────────────────────────
253
+
254
+ it("gives both styled lists role=\"list\" so Safari/VoiceOver still announces them", async () => {
255
+ wrapper = await mountSuspended(ContentDocs, {
256
+ props: { docsNavItems: navItems, docsPageNavItems: pageNavItems },
257
+ });
258
+ const lists = wrapper.findAll("ul");
259
+ expect(lists).toHaveLength(2);
260
+ for (const list of lists) expect(list.attributes("role")).toBe("list");
261
+ });
262
+
263
+ it("renders and reorders items that share the same `to`", async () => {
264
+ wrapper = await mountSuspended(ContentDocs, {
265
+ props: {
266
+ docsNavItems: [
267
+ { label: "First", to: "/same" },
268
+ { label: "Second", to: "/same" },
269
+ ],
270
+ },
271
+ });
272
+ const mounted = wrapper;
273
+ const labels = () => mounted.findAll(".content-docs-nav-link-label").map((el: { text: () => string }) => el.text());
274
+ expect(labels()).toEqual(["First", "Second"]);
275
+ await mounted.setProps({
276
+ docsNavItems: [
277
+ { label: "Second", to: "/same" },
278
+ { label: "First", to: "/same" },
279
+ { label: "Third", to: "/same" },
280
+ ],
281
+ });
282
+ expect(labels()).toEqual(["Second", "First", "Third"]);
283
+ });
284
+
285
+ it("renders HTML-like labels as text", async () => {
286
+ wrapper = await mountSuspended(ContentDocs, {
287
+ props: { docsNavItems: [{ label: "<b>not bold</b>", to: "/x" }], docsNavLabel: "<i>x</i>" },
288
+ });
289
+ expect(wrapper.find(".content-docs-nav-link-label b").exists()).toBe(false);
290
+ expect(wrapper.find(".content-docs-nav-link-label").text()).toBe("<b>not bold</b>");
291
+ expect(wrapper.find(".content-docs-nav-heading i").exists()).toBe(false);
292
+ });
293
+
294
+ it("adds and removes the content area when the docsContent slot is toggled after mount", async () => {
295
+ const show = ref(false);
296
+ const Host = defineComponent({
297
+ setup: () => () =>
298
+ h(ContentDocs, null, show.value ? { docsContent: () => h("p", { class: "body" }, "Body") } : {}),
299
+ });
300
+ wrapper = await mountSuspended(Host);
301
+ expect(wrapper.find(".content-docs-content").exists()).toBe(false);
302
+ show.value = true;
303
+ await nextTick();
304
+ expect(wrapper.find(".content-docs-content .body").exists()).toBe(true);
305
+ show.value = false;
306
+ await nextTick();
307
+ expect(wrapper.find(".content-docs-content").exists()).toBe(false);
308
+ });
309
+
244
310
  // ─── styleClassPassthrough ────────────────────────────────────────────────
245
311
 
246
312
  it("applies styleClassPassthrough classes to the root element", async () => {
247
- const wrapper = await mountSuspended(ContentDocs, {
313
+ wrapper = await mountSuspended(ContentDocs, {
248
314
  props: { styleClassPassthrough: ["custom-class"] },
249
315
  });
250
316
  expect(wrapper.find(".content-docs").classes()).toContain("custom-class");
@@ -6,6 +6,9 @@ All `--display-dialog-*` tokens are the stable override surface. Because dialogs
6
6
  elements — not inline components — the recommended approach is to set tokens once in a **global CSS
7
7
  file** rather than per-instance via `styleClassPassthrough`.
8
8
 
9
+ Private tokens (`--_transition-duration`, `--_inner-border-radius`, `--_inner-border`,
10
+ `--_inner-outline`) are not public API: set the public token each one reads instead.
11
+
9
12
  ### Overlay
10
13
 
11
14
  | Token | Default | Controls |
@@ -36,6 +39,8 @@ file** rather than per-instance via `styleClassPassthrough`.
36
39
  | Token | Default | Controls |
37
40
  |---|---|---|
38
41
  | `--display-dialog-header-padding` | `1.2rem` | Header area padding |
42
+ | `--display-dialog-header-gap` | `1.2rem` | Space between the title and the close button |
43
+ | `--display-dialog-header-accent-color` | `var(--theme-accent)` | Bottom border and close-icon colour when `theme` is set |
39
44
  | `--display-dialog-header-button-margin` | `0` | Close button margin |
40
45
  | `--display-dialog-header-button-padding` | `0.4rem` | Close button padding |
41
46
  | `--display-dialog-header-button-border` | `0.1rem solid transparent` | Close button border (resting) |
@@ -46,6 +51,14 @@ file** rather than per-instance via `styleClassPassthrough`.
46
51
  | `--display-dialog-header-button-icon-color` | `var(--colour-text-default)` | Close button icon colour |
47
52
  | `--display-dialog-header-button-icon-size` | `2.4rem` | Close button icon size |
48
53
 
54
+ The close button is an icon-only `InputButton` (`variant="inline"`), so it renders as
55
+ `.display-dialog-close.input-button`; the `--display-dialog-header-button-*` tokens style it.
56
+
57
+ > Changed 2026-10-18: the close button was a raw `<button>` and is now an `InputButton`. Its class
58
+ > (`.display-dialog-close`) and tokens are unchanged, so existing overrides still match. A set `theme` now tints the
59
+ > close icon as documented: the tint rule used to lose to the resting icon colour and never applied.
60
+ > `--display-dialog-header-gap` and `--display-dialog-header-accent-color` are new.
61
+
49
62
  ### Content & footer
50
63
 
51
64
  | Token | Default | Controls |
@@ -64,7 +77,7 @@ file** rather than per-instance via `styleClassPassthrough`.
64
77
  | `[justify-dialog="start|center|end"]` | `.display-dialog` | Horizontal placement of the panel |
65
78
  | `[align-dialog="start|center|end"]` | `.display-dialog` | Vertical placement of the panel |
66
79
  | `.dialog`, `.modal`, `.confirm`, `.alert`, `.fullscreen` | `.display-dialog-inner` | The resolved `variant` |
67
- | `[data-theme]` | `.display-dialog-header` | A `theme` is set: adds a `--theme-accent` bottom border and title colour |
80
+ | `[data-theme]` | `.display-dialog-header` | A `theme` is set: adds a bottom border and close-icon tint in `--display-dialog-header-accent-color` |
68
81
  | `.allow-content-scroll` | `.display-dialog-content` | `allow-content-scroll` is on |
69
82
 
70
83
  Inner classes: `.display-dialog-inner` (panel), `.display-dialog-header`,
@@ -76,16 +89,28 @@ Inner classes: `.display-dialog-inner` (panel), `.display-dialog-header`,
76
89
  > consumer's own unlayered `.header`/`.footer` CSS silently restyled it. Update any overrides
77
90
  > written against the old names.
78
91
 
92
+ ## Sizing
93
+
94
+ The panel never grows past the overlay: `max-inline-size` and `max-block-size` are `100%`, the
95
+ rows are `auto minmax(0, 1fr) auto`, and the header, content and footer each keep their own row
96
+ (so with no content the footer doesn't stretch into the middle). The title column and content area
97
+ wrap long unbroken text (`overflow-wrap: anywhere`), and the footer wraps its buttons onto further
98
+ rows. Content taller than the panel is clipped, or scrolls with `allow-content-scroll`; the header
99
+ and footer stay visible either way.
100
+
101
+ > Changed 2026-10-18: `modal` used to grow with its content past the viewport, a long title pushed
102
+ > the close button out of the panel, and footer buttons never wrapped.
103
+
79
104
  ---
80
105
 
81
106
  ## Global theming
82
107
 
83
108
  Create `assets/styles/setup/07.components/display-dialog.css` in the consuming app and set tokens
84
- on `:root`. This applies to every `DisplayDialog` across the site.
109
+ on `:where(html)`. This applies to every `DisplayDialog` across the site.
85
110
 
86
111
  ```css
87
112
  /* assets/styles/setup/07.components/display-dialog.css */
88
- :root {
113
+ :where(html) {
89
114
  --display-dialog-backdrop-background: rgba(0, 0, 0, 0.6);
90
115
  --display-dialog-inner-border-radius: 1.2rem;
91
116
  --display-dialog-inner-background: var(--brand-surface);