srcdev-nuxt-components 9.5.7 → 9.6.1

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 (159) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/breadcrumb.md +5 -0
  7. package/.claude/skills/components/container-glow.md +21 -8
  8. package/.claude/skills/components/display-avatar.md +13 -7
  9. package/.claude/skills/components/display-chip.md +37 -24
  10. package/.claude/skills/components/display-pill.md +58 -8
  11. package/.claude/skills/components/display-prompt.md +8 -5
  12. package/.claude/skills/components/display-toast.md +21 -4
  13. package/.claude/skills/components/glowing-border.md +20 -2
  14. package/.claude/skills/components/input-button.md +3 -0
  15. package/.claude/skills/components/responsive-header.md +59 -0
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/service-detail.md +32 -0
  18. package/.claude/skills/components/service-summary.md +8 -0
  19. package/.claude/skills/components/services-card-grid.md +5 -3
  20. package/.claude/skills/components/services-card.md +22 -12
  21. package/.claude/skills/components/site-header.md +2 -2
  22. package/.claude/skills/components/slider-gallery.md +10 -4
  23. package/.claude/skills/components/tabbed-content.md +300 -0
  24. package/.claude/skills/composable-anchor-scroll.md +16 -15
  25. package/.claude/skills/index.md +4 -5
  26. package/.claude/skills/storybook-add-story.md +53 -0
  27. package/.claude/skills/theming-status-tokens.md +60 -0
  28. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  29. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  30. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  31. package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
  32. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  33. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  34. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  35. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  36. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  37. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  38. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  39. package/app/assets/styles/setup/03.theming/index.css +1 -0
  40. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  41. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  42. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  43. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  44. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  45. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  46. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  47. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  48. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  49. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  50. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
  51. package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
  52. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  53. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  54. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  55. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  56. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  57. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  59. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  60. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  61. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  62. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  63. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  64. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  65. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  66. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  67. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  68. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  69. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  70. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  71. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  72. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  73. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  74. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  75. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  76. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  77. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  78. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  79. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  80. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  81. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  82. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  83. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  84. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  85. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  86. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  87. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  88. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  89. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  90. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  91. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  92. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  93. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  94. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  95. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  96. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  97. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  98. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  99. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
  100. package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
  101. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
  102. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  103. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  104. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  105. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  106. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  107. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  108. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  109. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
  110. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  111. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  112. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  113. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  114. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
  115. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  116. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  117. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  118. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  119. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  120. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  121. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  122. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  123. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  124. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  125. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
  126. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  127. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  128. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  129. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  130. package/app/composables/useAnchorScroll.ts +1 -1
  131. package/app/composables/useTabs.ts +148 -206
  132. package/app/types/components/display-chip.d.ts +8 -4
  133. package/app/types/components/display-toast.d.ts +2 -0
  134. package/app/types/components/index.ts +0 -1
  135. package/app/types/components/select-menu.d.ts +2 -0
  136. package/app/utils/pausableTimeout.ts +41 -0
  137. package/nuxt.config.ts +1 -1
  138. package/package.json +1 -1
  139. package/.claude/skills/components/site-navigation.md +0 -145
  140. package/.claude/skills/components/tab-navigation.md +0 -272
  141. package/.claude/skills/components/tabs-core.md +0 -187
  142. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  143. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  144. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  145. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  146. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  147. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  148. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  149. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  150. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  151. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  152. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
  153. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
  154. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
  155. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
  156. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
  157. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  158. package/app/composables/useNavCollapse.ts +0 -86
  159. package/app/types/components/nav-item.d.ts +0 -11
@@ -1,7 +1,9 @@
1
- import { ref } from "vue";
1
+ import { computed, ref } from "vue";
2
2
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
3
  import SelectMenu from "../SelectMenu.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { SelectMenuOption } from "~/types/components/select-menu";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  // ─── Meta ─────────────────────────────────────────────────────────────────────
7
9
 
@@ -10,13 +12,18 @@ interface StoryArgs {
10
12
  label?: string;
11
13
  placeholder?: string;
12
14
  showIcon?: boolean;
15
+ triggerIcon?: string;
16
+ indicator?: "none" | "count" | "dot";
17
+ selectedCountLabel?: string;
13
18
  showLabel?: boolean;
14
19
  showChevron?: boolean;
15
20
  multiple?: boolean;
16
21
  showSelectionInTrigger?: boolean;
22
+ reserveLabelWidth?: boolean;
17
23
  inputVariant?: "normal" | "underlined";
18
24
  modelValue?: string | number | (string | number)[];
19
25
  styleClassPassthrough?: string | string[];
26
+ itemLabelLineClamp?: string;
20
27
  }
21
28
 
22
29
  const meta: Meta<StoryArgs> = {
@@ -35,9 +42,25 @@ const meta: Meta<StoryArgs> = {
35
42
  },
36
43
  showIcon: {
37
44
  control: { type: "boolean" },
38
- description: "Show the selected option's icon in the trigger.",
45
+ description: "Show the leading icon in the trigger: the selected option's icon (single-select), otherwise `triggerIcon`.",
39
46
  table: { category: "Trigger content" },
40
47
  },
48
+ triggerIcon: {
49
+ control: { type: "text" },
50
+ description: "Fixed leading icon, e.g. a filter category icon. In single-select a selected option's own icon replaces it.",
51
+ table: { category: "Trigger content" },
52
+ },
53
+ indicator: {
54
+ control: { type: "select" },
55
+ options: ["none", "count", "dot"],
56
+ description: "Selection indicator after the trigger text, shown only while something is selected: a count badge or a status dot.",
57
+ table: { category: "Trigger content" },
58
+ },
59
+ selectedCountLabel: {
60
+ control: { type: "text" },
61
+ description: "Appended to the trigger's accessible name while the indicator shows. `{count}` is replaced with the number selected.",
62
+ table: { category: "Content" },
63
+ },
41
64
  showLabel: {
42
65
  control: { type: "boolean" },
43
66
  description: "Show the selected option's label text in the trigger. Turn off for an icon-only compact trigger.",
@@ -58,6 +81,11 @@ const meta: Meta<StoryArgs> = {
58
81
  description: "In multiple mode, update the trigger text to a comma-separated list of the currently checked options instead of leaving it fixed on placeholder/label. No effect outside multiple mode.",
59
82
  table: { category: "Content" },
60
83
  },
84
+ reserveLabelWidth: {
85
+ control: { type: "boolean" },
86
+ description: "Single-select only. Size the trigger text to the longest option label (or placeholder), so the trigger doesn't change width as the selection changes.",
87
+ table: { category: "Trigger content" },
88
+ },
61
89
  inputVariant: {
62
90
  control: { type: "select" },
63
91
  options: ["normal", "underlined"],
@@ -67,10 +95,18 @@ const meta: Meta<StoryArgs> = {
67
95
  styleClassPassthrough: {
68
96
  table: { disable: true },
69
97
  },
98
+ itemLabelLineClamp: {
99
+ control: { type: "select" },
100
+ options: ["none", "1", "2", "3"],
101
+ description:
102
+ "**Story control, not a prop.** Sets `--select-menu-item-label-line-clamp` on a wrapper (StressTest only). In your CSS: `.booking-filters { --select-menu-item-label-line-clamp: 2; }`",
103
+ table: { category: "CSS tokens (story only, set in your CSS)" },
104
+ },
70
105
  },
71
106
  args: {
72
107
  label: "Language",
73
108
  showIcon: true,
109
+ indicator: "none",
74
110
  showLabel: true,
75
111
  showChevron: true,
76
112
  inputVariant: "normal",
@@ -328,6 +364,75 @@ export const MultiSelectShowingSelection: Story = {
328
364
  }),
329
365
  };
330
366
 
367
+ /**
368
+ * Filter with a count — a fixed category icon (`triggerIcon`) plus a count
369
+ * badge (`indicator="count"`) showing how many options are checked. The
370
+ * badge only appears once something is selected.
371
+ */
372
+ export const FilterWithCount: Story = {
373
+ name: "Filter — Icon + Count",
374
+ args: {
375
+ label: "Category",
376
+ multiple: true,
377
+ triggerIcon: "lucide:list",
378
+ indicator: "count",
379
+ },
380
+ render: (args) => ({
381
+ components: { SelectMenu },
382
+ setup() {
383
+ const value = ref<string[]>(["trim", "layers", "restyle"]);
384
+ return { args, value, treatmentOptions };
385
+ },
386
+ template: `
387
+ <div style="padding: 4rem 8rem;">
388
+ <SelectMenu v-bind="args" v-model="value" :options="treatmentOptions" />
389
+ <p style="margin: 1.6rem 0 0; font-size: 1.3rem; opacity: 0.6;">
390
+ modelValue: <strong>{{ value.length ? value.join(', ') : '[]' }}</strong>
391
+ </p>
392
+ </div>
393
+ `,
394
+ }),
395
+ };
396
+
397
+ /**
398
+ * Filter with a status dot — single-select with `indicator="dot"`, e.g. a
399
+ * status filter. The dot signals the filter is active and disappears when the
400
+ * value is cleared. Each option's `dotColor` colours the trigger dot while
401
+ * it's selected and shows beside it in the list. `reserveLabelWidth` keeps
402
+ * the trigger at the width of the longest status, so it doesn't resize between
403
+ * "Active" and "Archived" (toggle it off in Controls to compare).
404
+ */
405
+ export const FilterWithDot: Story = {
406
+ name: "Filter — Icon + Status Dot",
407
+ args: {
408
+ label: "Status",
409
+ triggerIcon: "lucide:circle-dot",
410
+ indicator: "dot",
411
+ reserveLabelWidth: true,
412
+ },
413
+ render: (args) => ({
414
+ components: { SelectMenu },
415
+ setup() {
416
+ const value = ref<string | undefined>("active");
417
+ const statusOptions: SelectMenuOption[] = [
418
+ { value: "active", label: "Active", dotColor: "var(--status-success)" },
419
+ { value: "pending", label: "Pending", dotColor: "var(--status-warning)" },
420
+ { value: "archived", label: "Archived", dotColor: "var(--status-neutral)" },
421
+ ];
422
+ return { args, value, statusOptions };
423
+ },
424
+ template: `
425
+ <div style="padding: 4rem 8rem;">
426
+ <SelectMenu v-bind="args" v-model="value" :options="statusOptions" />
427
+ <p style="margin: 1.6rem 0 0; font-size: 1.3rem; opacity: 0.6;">
428
+ modelValue: <strong>{{ value === undefined ? 'undefined' : value }}</strong>
429
+ <button type="button" style="margin-inline-start: 1.2rem;" @click="value = undefined">Clear</button>
430
+ </p>
431
+ </div>
432
+ `,
433
+ }),
434
+ };
435
+
331
436
  /**
332
437
  * Custom tokens — a per-instance styleClassPassthrough overriding the public
333
438
  * --select-menu-* tokens to demonstrate full visual customisation.
@@ -392,3 +497,128 @@ export const Underlined: Story = {
392
497
  `,
393
498
  }),
394
499
  };
500
+
501
+ // ─── Stress test ──────────────────────────────────────────────────────────────
502
+
503
+ const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
504
+
505
+ const stressOptions: SelectMenuOption[] = [
506
+ {
507
+ value: "long",
508
+ label:
509
+ "Full head balayage with toner, Olaplex bond treatment, deep conditioning mask and a blow-dry finish (allow 4 hours)",
510
+ icon: "lucide:sparkles",
511
+ },
512
+ { value: "unbroken", label: longUnbroken },
513
+ { value: "url", label: "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(3) },
514
+ { value: "emoji", label: "🎉 Emoji first option", dotColor: "var(--status-success)" },
515
+ { value: "html", label: "<b>Not bold</b> <script>alert('x')</script>" },
516
+ { value: "rtl", label: "قص الشعر وتصفيفه" },
517
+ { value: "single", label: "A" },
518
+ { value: "empty", label: "" },
519
+ { value: "broken-icon", label: "Broken icon name", icon: "lucide:does-not-exist" },
520
+ { value: "bad-dot", label: "Invalid dot colour", dotColor: "not-a-colour" },
521
+ ...Array.from({ length: 40 }, (_, index) => ({ value: `extra-${index + 1}`, label: `Option ${index + 1}` })),
522
+ ];
523
+
524
+ /**
525
+ * Stress test — hostile option data at every canvas width.
526
+ */
527
+ export const StressTest: Story = {
528
+ name: "Stress Test (Worst-Case Data)",
529
+ args: {
530
+ label: "Behandlung auswählen für Ihren nächsten Termin im Salon (sehr lange Beschriftung)",
531
+ placeholder: "Bitte wählen Sie eine oder mehrere Behandlungen aus der Liste",
532
+ indicator: "count",
533
+ selectedCountLabel: "{count} ausgewählt",
534
+ itemLabelLineClamp: "none",
535
+ },
536
+ render: (args) => ({
537
+ components: { SelectMenu },
538
+ setup() {
539
+ const componentArgs = computed(() => {
540
+ const { itemLabelLineClamp: _itemLabelLineClamp, ...rest } = args;
541
+ return rest;
542
+ });
543
+ const wrapperStyle = computed(() => ({
544
+ "--select-menu-item-label-line-clamp": args.itemLabelLineClamp,
545
+ }));
546
+ const single = ref<string | undefined>("long");
547
+ const reserved = ref<string | undefined>("unbroken");
548
+ const many = ref<string[]>(stressOptions.map((option) => option.value as string));
549
+ const none = ref<string | undefined>(undefined);
550
+ return { componentArgs, wrapperStyle, single, reserved, many, none, stressOptions };
551
+ },
552
+ template: `
553
+ <div :style="wrapperStyle" style="display: grid; gap: 2.4rem; padding: 2.4rem 1.6rem;">
554
+ <section>
555
+ <h3 style="margin: 0 0 0.8rem;">Single-select, longest option selected</h3>
556
+ <SelectMenu v-bind="componentArgs" v-model="single" :options="stressOptions" :multiple="false" />
557
+ </section>
558
+ <section>
559
+ <h3 style="margin: 0 0 0.8rem;">Reserved label width, unbroken word selected, status dot</h3>
560
+ <SelectMenu
561
+ v-bind="componentArgs"
562
+ v-model="reserved"
563
+ :options="stressOptions"
564
+ :multiple="false"
565
+ :reserve-label-width="true"
566
+ indicator="dot"
567
+ />
568
+ </section>
569
+ <section>
570
+ <h3 style="margin: 0 0 0.8rem;">Multi-select, all 50 checked, selection shown in trigger</h3>
571
+ <SelectMenu
572
+ v-bind="componentArgs"
573
+ v-model="many"
574
+ :options="stressOptions"
575
+ :multiple="true"
576
+ :show-selection-in-trigger="true"
577
+ trigger-icon="lucide:list-filter"
578
+ />
579
+ </section>
580
+ <section style="display: flex; justify-content: flex-end;">
581
+ <div>
582
+ <h3 style="margin: 0 0 0.8rem;">Right-aligned trigger (menu should stay on screen)</h3>
583
+ <SelectMenu v-bind="componentArgs" v-model="none" :options="stressOptions" :multiple="false" :show-label="false" trigger-icon="lucide:list-filter" />
584
+ </div>
585
+ </section>
586
+ <section>
587
+ <h3 style="margin: 0 0 0.8rem;">No options</h3>
588
+ <SelectMenu v-bind="componentArgs" v-model="none" :options="[]" :multiple="false" />
589
+ </section>
590
+ </div>
591
+ `,
592
+ }),
593
+ decorators: [
594
+ (story, context) => ({
595
+ components: { story, CanvasSwitcher },
596
+ setup() {
597
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
598
+ return { canvasName };
599
+ },
600
+ template: `
601
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
602
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
603
+ </div>
604
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
605
+ <story />
606
+ </div>
607
+ `,
608
+ }),
609
+ ],
610
+ parameters: {
611
+ initialCanvas: "mobileCanvas",
612
+ docs: {
613
+ description: {
614
+ story:
615
+ "Hostile option data: a very long label, an unbroken word, a long URL, emoji first, HTML-like text (must render as text), " +
616
+ "RTL, a single character, an empty label, a broken icon name, an invalid dot colour, and 50 options in total. " +
617
+ "Check at every canvas width that each trigger stays inside the canvas with its text truncated by an ellipsis, the count badge " +
618
+ "and chevron stay visible, the menu never runs off either side of the screen (the right-aligned trigger flips its menu), " +
619
+ "long options wrap inside the menu, and the list scrolls. `itemLabelLineClamp` is a story-only control for the " +
620
+ "`--select-menu-item-label-line-clamp` CSS token: try `1` for single-line ellipsis. The empty menu shows just its border.",
621
+ },
622
+ },
623
+ },
624
+ };
@@ -462,6 +462,157 @@ describe("SelectMenu", () => {
462
462
  });
463
463
  });
464
464
 
465
+ // -------------------------
466
+ // Trigger icon + indicator
467
+ // -------------------------
468
+ describe("Trigger icon and indicator", () => {
469
+ const plainOptions: SelectMenuOption[] = [
470
+ { value: "a", label: "Alpha" },
471
+ { value: "b", label: "Beta" },
472
+ { value: "c", label: "Gamma" },
473
+ ];
474
+
475
+ it("renders triggerIcon in multiple mode", async () => {
476
+ wrapper = await createWrapper({ multiple: true, modelValue: [], triggerIcon: "lucide:list" });
477
+ expect(wrapper.find(".select-menu-trigger-icon").exists()).toBe(true);
478
+ });
479
+
480
+ it("renders triggerIcon in single mode when the selected option has no icon", async () => {
481
+ wrapper = await createWrapper({ options: plainOptions, modelValue: "a", triggerIcon: "lucide:list" });
482
+ expect(wrapper.find(".select-menu-trigger-icon").exists()).toBe(true);
483
+ });
484
+
485
+ it("hides triggerIcon when showIcon is false", async () => {
486
+ wrapper = await createWrapper({ multiple: true, modelValue: [], triggerIcon: "lucide:list", showIcon: false });
487
+ expect(wrapper.find(".select-menu-trigger-icon").exists()).toBe(false);
488
+ });
489
+
490
+ it("renders no indicator by default", async () => {
491
+ wrapper = await createWrapper({ multiple: true, modelValue: ["en", "fr"] });
492
+ expect(wrapper.find(".select-menu-trigger-count").exists()).toBe(false);
493
+ expect(wrapper.find(".select-menu-trigger-dot").exists()).toBe(false);
494
+ });
495
+
496
+ it("shows the number selected in the count badge", async () => {
497
+ wrapper = await createWrapper({ multiple: true, modelValue: ["en", "fr"], indicator: "count" });
498
+ expect(wrapper.find(".select-menu-trigger-count").text()).toBe("2");
499
+ });
500
+
501
+ it("keeps an empty, hidden count badge in the layout when nothing is selected", async () => {
502
+ wrapper = await createWrapper({ multiple: true, modelValue: [], indicator: "count" });
503
+ const badge = wrapper.find(".select-menu-trigger-count");
504
+ expect(badge.exists()).toBe(true);
505
+ expect(badge.text()).toBe("");
506
+ expect(badge.classes()).toContain("select-menu-trigger-indicator-empty");
507
+ });
508
+
509
+ it("updates the count badge as options are toggled", async () => {
510
+ wrapper = await createWrapper({ multiple: true, modelValue: [], indicator: "count" });
511
+ await wrapper.setProps({ modelValue: ["en", "fr", "de"] });
512
+ const badge = wrapper.find(".select-menu-trigger-count");
513
+ expect(badge.text()).toBe("3");
514
+ expect(badge.classes()).not.toContain("select-menu-trigger-indicator-empty");
515
+ });
516
+
517
+ it("hides the dot, keeping its space, when nothing is selected", async () => {
518
+ wrapper = await createWrapper({ modelValue: "en", indicator: "dot" });
519
+ const dot = () => wrapper.find(".select-menu-trigger-dot");
520
+ expect(dot().classes()).not.toContain("select-menu-trigger-indicator-empty");
521
+ await wrapper.setProps({ modelValue: undefined });
522
+ expect(dot().exists()).toBe(true);
523
+ expect(dot().classes()).toContain("select-menu-trigger-indicator-empty");
524
+ });
525
+
526
+ it("colours the trigger dot with the selected option's dotColor", async () => {
527
+ const statusOptions: SelectMenuOption[] = [
528
+ { value: "active", label: "Active", dotColor: "green" },
529
+ { value: "pending", label: "Pending", dotColor: "orange" },
530
+ ];
531
+ wrapper = await createWrapper({ options: statusOptions, modelValue: "active", indicator: "dot" });
532
+ const dot = () => wrapper.find(".select-menu-trigger-dot").element as HTMLElement;
533
+ expect(dot().style.getPropertyValue("--_dot-color")).toBe("green");
534
+ await wrapper.setProps({ modelValue: "pending" });
535
+ expect(dot().style.getPropertyValue("--_dot-color")).toBe("orange");
536
+ });
537
+
538
+ it("leaves the trigger dot on the token colour when the option has no dotColor", async () => {
539
+ wrapper = await createWrapper({ options: plainOptions, modelValue: "a", indicator: "dot" });
540
+ const dot = wrapper.find(".select-menu-trigger-dot").element as HTMLElement;
541
+ expect(dot.style.getPropertyValue("--_dot-color")).toBe("");
542
+ });
543
+
544
+ it("renders a list dot for options with dotColor and no icon", async () => {
545
+ wrapper = await createWrapper({
546
+ options: [
547
+ { value: "a", label: "Alpha", dotColor: "green" },
548
+ { value: "b", label: "Beta" },
549
+ { value: "c", label: "Gamma", icon: "lucide:star", dotColor: "red" },
550
+ ],
551
+ });
552
+ const rows = wrapper.findAll(".select-menu-list-item");
553
+ const rowDot = rows[0]!.find(".select-menu-item-dot");
554
+ expect((rowDot.element as HTMLElement).style.getPropertyValue("--_dot-color")).toBe("green");
555
+ expect(rows[1]!.find(".select-menu-item-dot").exists()).toBe(false);
556
+ expect(rows[2]!.find(".select-menu-item-dot").exists()).toBe(false);
557
+ });
558
+
559
+ it("hides the indicator from assistive tech", async () => {
560
+ wrapper = await createWrapper({ multiple: true, modelValue: ["en"], indicator: "count" });
561
+ expect(wrapper.find(".select-menu-trigger-count").attributes("aria-hidden")).toBe("true");
562
+ });
563
+
564
+ it("appends the selected count to the trigger's accessible name while the indicator shows", async () => {
565
+ wrapper = await createWrapper({ multiple: true, modelValue: ["en", "fr"], indicator: "count" });
566
+ expect(wrapper.find(".select-menu-trigger").attributes("aria-label")).toBe("Language, 2 selected");
567
+ });
568
+
569
+ it("uses a custom selectedCountLabel", async () => {
570
+ wrapper = await createWrapper({
571
+ multiple: true,
572
+ modelValue: ["en"],
573
+ indicator: "count",
574
+ selectedCountLabel: "{count} sélectionné",
575
+ });
576
+ expect(wrapper.find(".select-menu-trigger").attributes("aria-label")).toBe("Language, 1 sélectionné");
577
+ });
578
+
579
+ it("keeps the plain label as the accessible name when nothing is selected", async () => {
580
+ wrapper = await createWrapper({ multiple: true, modelValue: [], indicator: "count" });
581
+ expect(wrapper.find(".select-menu-trigger").attributes("aria-label")).toBe("Language");
582
+ });
583
+ });
584
+
585
+ // -------------------------
586
+ // reserveLabelWidth
587
+ // -------------------------
588
+ describe("reserveLabelWidth", () => {
589
+ it("renders plain label text by default", async () => {
590
+ wrapper = await createWrapper({ modelValue: "en" });
591
+ expect(wrapper.find(".select-menu-trigger-label-sizer").exists()).toBe(false);
592
+ expect(wrapper.find(".select-menu-trigger-label").text()).toBe("English");
593
+ });
594
+
595
+ it("renders a hidden sizer for the placeholder and every option label", async () => {
596
+ wrapper = await createWrapper({ modelValue: "en", placeholder: "Pick one", reserveLabelWidth: true });
597
+ const sizers = wrapper.findAll(".select-menu-trigger-label-sizer");
598
+ expect(sizers.map((sizer) => sizer.text())).toEqual(["Pick one", "English", "Français", "Deutsch"]);
599
+ expect(sizers.every((sizer) => sizer.attributes("aria-hidden") === "true")).toBe(true);
600
+ });
601
+
602
+ it("shows the selected label in the visible text span", async () => {
603
+ wrapper = await createWrapper({ modelValue: "fr", reserveLabelWidth: true });
604
+ expect(wrapper.find(".select-menu-trigger-label-text").text()).toBe("Français");
605
+ await wrapper.setProps({ modelValue: "de" });
606
+ expect(wrapper.find(".select-menu-trigger-label-text").text()).toBe("Deutsch");
607
+ });
608
+
609
+ it("has no effect in multiple mode", async () => {
610
+ wrapper = await createWrapper({ multiple: true, modelValue: [], reserveLabelWidth: true });
611
+ expect(wrapper.find(".select-menu-trigger-label").classes()).not.toContain("select-menu-trigger-label-reserved");
612
+ expect(wrapper.find(".select-menu-trigger-label-sizer").exists()).toBe(false);
613
+ });
614
+ });
615
+
465
616
  // -------------------------
466
617
  // styleClassPassthrough
467
618
  // -------------------------
@@ -2,7 +2,8 @@
2
2
 
3
3
  exports[`SelectMenu > Snapshots > default 1`] = `
4
4
  "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false">
5
- <!--v-if--><span class="select-menu-trigger-label">Language</span><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span>
5
+ <!--v-if--><span class="select-menu-trigger-label">Language</span>
6
+ <!--v-if--><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span>
6
7
  </button>
7
8
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
8
9
  <ul class="select-menu-list" role="listbox" aria-label="Language">
@@ -18,6 +19,7 @@ exports[`SelectMenu > Snapshots > icon-only trigger 1`] = `
18
19
  "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:gb-4x3 select-menu-trigger-icon" aria-hidden="true"></span>
19
20
  <!--v-if-->
20
21
  <!--v-if-->
22
+ <!--v-if-->
21
23
  </button>
22
24
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
23
25
  <ul class="select-menu-list" role="listbox" aria-label="Language">
@@ -30,7 +32,9 @@ exports[`SelectMenu > Snapshots > icon-only trigger 1`] = `
30
32
  `;
31
33
 
32
34
  exports[`SelectMenu > Snapshots > with a selected value 1`] = `
33
- "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:fr-4x3 select-menu-trigger-icon" aria-hidden="true"></span><span class="select-menu-trigger-label">Français</span><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span></button>
35
+ "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:fr-4x3 select-menu-trigger-icon" aria-hidden="true"></span><span class="select-menu-trigger-label">Français</span>
36
+ <!--v-if--><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span>
37
+ </button>
34
38
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
35
39
  <ul class="select-menu-list" role="listbox" aria-label="Language">
36
40
  <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false" data-label="English"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
@@ -1,41 +1,105 @@
1
1
  # DisplayToast / DisplayToastProvider — Consumer Styling Guide
2
2
 
3
+ Both render the toast body with `AlertContent` (or `AlertMaskedContent` when `masked`), so the
4
+ card's colours, radius and accent bar come from `--alert-content-*` tokens: see
5
+ `02.molecules/alert-content/CONSUMER-STYLING.md`. The tokens below cover the toast's own shell.
6
+
7
+ ## Public token API
8
+
9
+ ### DisplayToast (standalone)
10
+
11
+ | Token | Default | Description |
12
+ |---|---|---|
13
+ | `--display-toast-z-index` | `999999` | Stacking order (see Stacking order). |
14
+ | `--display-toast-max-width` | `min(48rem, 100% - 2 * gutter)` | Maximum toast width. Ignored when `fullWidth` is set. |
15
+ | `--display-toast-progress-colour` | `var(--theme-accent)` | Auto-dismiss progress bar colour. |
16
+ | `--display-toast-focus-ring-colour` | `var(--theme-ring)` | Keyboard focus outline colour. |
17
+
18
+ ### DisplayToastProvider (queue)
19
+
20
+ | Token | Default | Description |
21
+ |---|---|---|
22
+ | `--display-toast-provider-z-index` | `999999` | Stacking order (see Stacking order). |
23
+ | `--display-toast-provider-max-width` | `min(48rem, 100% - 2 * gutter)` | Maximum width of the toast stack. Ignored when `fullWidth` is set. |
24
+ | `--display-toast-provider-progress-colour` | `var(--theme-accent)` | Auto-dismiss progress bar colour. |
25
+ | `--display-toast-provider-focus-ring-colour` | `var(--theme-ring)` | Keyboard focus outline colour. |
26
+
27
+ The gutter is `12px` below `600px` viewport width, `24px` above.
28
+
29
+ > Changed 2026-10-08: the max-width tokens are new. The standalone centred toast used
30
+ > `width: max-content` with no cap, so long text made it wider than the screen; right/left-aligned
31
+ > toasts and the provider stack could stretch almost the full viewport on wide screens. Both now
32
+ > cap at `48rem`. Set the max-width token to `none` (with the gutter still applying) to undo that.
33
+ > The progress and focus-ring colour tokens are also new; they used to read `--theme-accent` and
34
+ > `--theme-ring` directly.
35
+
36
+ Private tokens, not public API: `--_toast-gutter` and `--_gutter` (the responsive gutter), and the
37
+ provider's `--_reveal` / `--_duration` (set inline per toast from its config).
38
+
39
+ ## State hooks
40
+
41
+ | Hook | Where | Meaning |
42
+ |---|---|---|
43
+ | `[data-position="top\|bottom"]` | `.display-toast`, `.display-toast-provider` | Vertical position. |
44
+ | `[data-alignment="left\|center\|right\|full-width"]` | `.display-toast`, `.display-toast-provider` | Horizontal alignment; `full-width` when `fullWidth` is set. Below `600px` every toast is centred. |
45
+ | `[data-state="show\|hide"]` | `.display-toast` | Entering or leaving (standalone only; the provider uses `TransitionGroup`'s `v-enter-*`/`v-leave-*` classes). |
46
+ | `[data-paused]` | `.display-toast`, `.display-toast-provider-item` | Auto-dismiss is paused (hovered, or keyboard focus on or inside the toast). Freezes the progress bar. |
47
+ | `[data-theme]` | `.display-toast`, `.display-toast-provider-item` | The toast's semantic theme. |
48
+
49
+ Inner elements: `.display-toast-progress`, `.display-toast-provider-item`,
50
+ `.display-toast-provider-progress`.
51
+
52
+ > Changed 2026-10-08: position, alignment and state were bare classes (`.top`, `.left`, `.center`,
53
+ > `.full-width`, `.show`, `.hide`). The toast is teleported into `<body>`, so a consumer's own
54
+ > utility classes with those names (a `.hide { display: none }` especially) could match it. They're
55
+ > `data-*` attributes now; update any selector that used the old classes. The keyframes were
56
+ > renamed for the same reason (`show`, `progress`, `hideTop`, … are now `display-toast-*` and
57
+ > `display-toast-provider-*`).
58
+
3
59
  ## Stacking order
4
60
 
5
- ```css
6
- --display-toast-z-index: 999999; /* DisplayToast (standalone) */
7
- --display-toast-provider-z-index: 999999; /* DisplayToastProvider (queue) */
8
- ```
61
+ Both z-index tokens default to `999999`, matching `DisplayDialog`'s `--display-dialog-z-index`. A
62
+ toast is meant to float above ordinary page chrome (headers, sticky nav). If a toast appears behind
63
+ your site header, your header's z-index is at or above `999999`: raise the toast's token, or (more
64
+ robust) lower the header's z-index. If a `DisplayDialog` and a toast are visible at once, they tie
65
+ on paint order, an acceptable edge case for a transient notification.
9
66
 
10
- Both default to `999999`, matching `DisplayDialog`'s own `--display-dialog-z-index` default — a
11
- toast is meant to float above ordinary page chrome (headers, sticky nav), so it needs to clear
12
- whatever z-index a consumer's own layout uses for that. If a toast appears behind your site
13
- header, your header's z-index is at or above `999999`; either raise the toast's token to match/
14
- exceed it, or (more robust) lower the header's z-index to something more ordinary — very few
15
- elements legitimately need to sit above a `dialog`/`alertdialog`.
67
+ ## Motion
16
68
 
17
- If a `DisplayDialog` and a toast happen to be visible at the same time, the two tie on DOM/paint
18
- order at equal z-index — an acceptable edge case for a transient notification, not worth resolving
19
- with a strict priority order between them.
69
+ Toasts slide in and out over `behavior.revealDuration` (default `550ms`). Under
70
+ `prefers-reduced-motion: reduce` they fade instead, with no slide; the progress bar still runs
71
+ because it shows how long is left.
72
+
73
+ Auto-dismiss pauses while the pointer is over a toast, or while keyboard focus is on it or inside
74
+ it, and resumes with the time that was left (WCAG 2.2.2). The toast moves focus to itself when
75
+ shown; that only pauses it when the user was using a keyboard (`:focus-visible`), so mouse users
76
+ still get auto-dismiss.
77
+
78
+ ## Global theming
20
79
 
21
80
  ```css
22
- /* Global override, e.g. assets/styles/setup/07.components/display-toast.css */
23
- :root {
24
- --display-toast-z-index: 1000000;
25
- --display-toast-provider-z-index: 1000000;
81
+ /* e.g. assets/styles/setup/07.components/display-toast.css */
82
+ :where(html) {
83
+ --display-toast-provider-max-width: min(36rem, 100% - 48px);
84
+ --display-toast-provider-progress-colour: var(--brand-accent);
26
85
  }
27
86
  ```
28
87
 
29
- ---
30
-
31
88
  ## Local overrides
32
89
 
33
- The `--display-toast-z-index` and `--display-toast-provider-z-index` tokens above are only read,
34
- never declared on the toast, so they work from any ancestor, though a global `:root` value is usually
35
- what you want for a `position: fixed` overlay. Anything else is a direct property, scoped to an
36
- element you own (a page or section class, or a class added with `:style-class-passthrough`). Keep
37
- the block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer components`. If your
38
- own file uses `<style scoped>`, tokens set on your element still work, but selectors that reach
39
- inside the component need `:deep()`. Patterns and examples:
40
- `.claude/skills/component-local-style-override.md`.
90
+ Set the tokens above on an element you own (a page or section class, or a class added with
91
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
92
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
93
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
94
+ component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
95
+
96
+ **Caveat:** both components teleport to `<body>`, so they aren't inside your page or section
97
+ elements and can't inherit tokens from them. Set toast tokens globally (`:root` or `:where(html)`),
98
+ or on `DisplayToast` itself via `:style-class-passthrough` (the class lands on the toast element).
99
+ `DisplayToastProvider` has no passthrough, so style it globally or with a `.display-toast-provider`
100
+ selector.
101
+
102
+ ## Class passthrough
41
103
 
104
+ `DisplayToast`'s `styleClassPassthrough` adds classes to the teleported `.display-toast` element, so
105
+ it's the way to style one toast instance. `DisplayToastProvider` has none.