srcdev-nuxt-components 9.6.0 → 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 (107) 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/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +13 -7
  8. package/.claude/skills/components/display-chip.md +37 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/responsive-header.md +11 -1
  14. package/.claude/skills/components/select-menu.md +54 -5
  15. package/.claude/skills/components/site-header.md +2 -2
  16. package/.claude/skills/components/tabbed-content.md +300 -0
  17. package/.claude/skills/composable-anchor-scroll.md +16 -15
  18. package/.claude/skills/index.md +4 -4
  19. package/.claude/skills/storybook-add-story.md +53 -0
  20. package/.claude/skills/theming-status-tokens.md +60 -0
  21. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  22. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  23. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  24. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  26. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  27. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  28. package/app/assets/styles/setup/03.theming/index.css +1 -0
  29. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  30. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  31. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  32. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  33. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  34. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  35. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  36. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  37. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  38. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  39. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  40. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  41. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  42. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  43. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  44. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  45. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  46. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  47. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  48. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  49. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  50. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  51. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  52. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  53. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  54. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  55. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  56. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  57. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  58. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  59. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  60. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  61. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  62. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  63. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  64. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  65. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  66. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  67. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  68. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  69. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  70. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  71. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  72. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  73. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  74. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  75. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  76. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  77. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  78. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  79. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  80. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  81. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  82. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  83. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  84. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  85. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  86. package/app/composables/useTabs.ts +148 -206
  87. package/app/types/components/display-chip.d.ts +8 -4
  88. package/app/types/components/display-toast.d.ts +2 -0
  89. package/app/types/components/index.ts +0 -1
  90. package/app/types/components/select-menu.d.ts +2 -0
  91. package/app/utils/pausableTimeout.ts +41 -0
  92. package/nuxt.config.ts +1 -1
  93. package/package.json +1 -1
  94. package/.claude/skills/components/tab-navigation.md +0 -288
  95. package/.claude/skills/components/tabs-core.md +0 -187
  96. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  97. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  98. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  99. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  100. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  101. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  102. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  103. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  104. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  105. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  106. package/app/composables/useNavCollapse.ts +0 -86
  107. package/app/types/components/nav-item.d.ts +0 -11
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="alert-content" :data-theme="theme">
2
+ <div class="alert-content" :class="[elementClasses]" :data-theme="theme">
3
3
  <AlertContentInner
4
4
  :theme="theme"
5
5
  :custom-icon="customIcon"
@@ -41,19 +41,28 @@ interface Props {
41
41
  dismissible?: boolean;
42
42
  contentId?: string;
43
43
  ariaLive?: "polite" | "assertive" | "off";
44
+ styleClassPassthrough?: string | string[];
44
45
  }
45
46
 
46
- withDefaults(defineProps<Props>(), {
47
+ const props = withDefaults(defineProps<Props>(), {
47
48
  customIcon: undefined,
48
49
  showIcon: true,
49
50
  dismissible: false,
50
51
  contentId: undefined,
51
52
  ariaLive: undefined,
53
+ styleClassPassthrough: () => [],
52
54
  });
53
55
 
54
56
  const emit = defineEmits<{ dismiss: [] }>();
55
57
 
56
58
  const slots = useSlots();
59
+
60
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
61
+
62
+ watch(
63
+ () => props.styleClassPassthrough,
64
+ () => resetElementClasses(props.styleClassPassthrough)
65
+ );
57
66
  </script>
58
67
 
59
68
  <style lang="css">
@@ -89,6 +89,7 @@ const dismissIcon = computed(() => appConfig.srcdev?.alertContent?.dismissIcon ?
89
89
  border-start-start-radius: var(--alert-content-border-radius-start, 0.8rem);
90
90
  border-end-start-radius: var(--alert-content-border-radius-start, 0.8rem);
91
91
  padding: var(--alert-content-padding, 1.2rem 1.5rem);
92
+ min-inline-size: 0;
92
93
  overflow: hidden;
93
94
 
94
95
  .alert-content-icon {
@@ -124,6 +125,12 @@ const dismissIcon = computed(() => appConfig.srcdev?.alertContent?.dismissIcon ?
124
125
  font-weight: 600;
125
126
  line-height: 1.2;
126
127
  margin: 0;
128
+ display: -webkit-box;
129
+ -webkit-box-orient: vertical;
130
+ overflow: hidden;
131
+ overflow-wrap: anywhere;
132
+ -webkit-line-clamp: var(--alert-content-title-line-clamp, none);
133
+ line-clamp: var(--alert-content-title-line-clamp, none);
127
134
  }
128
135
 
129
136
  .alert-content-text {
@@ -132,6 +139,12 @@ const dismissIcon = computed(() => appConfig.srcdev?.alertContent?.dismissIcon ?
132
139
  line-height: 1.4;
133
140
  margin: 0;
134
141
  opacity: 0.9;
142
+ display: -webkit-box;
143
+ -webkit-box-orient: vertical;
144
+ overflow: hidden;
145
+ overflow-wrap: anywhere;
146
+ -webkit-line-clamp: var(--alert-content-text-line-clamp, none);
147
+ line-clamp: var(--alert-content-text-line-clamp, none);
135
148
  }
136
149
  }
137
150
 
@@ -141,6 +154,12 @@ const dismissIcon = computed(() => appConfig.srcdev?.alertContent?.dismissIcon ?
141
154
  align-items: center;
142
155
  justify-content: var(--alert-content-actions-justify, flex-end);
143
156
  gap: var(--alert-content-actions-gap, 0.8rem);
157
+ --input-button-text-white-space: normal;
158
+
159
+ & > * {
160
+ min-inline-size: 0;
161
+ max-inline-size: 100%;
162
+ }
144
163
  }
145
164
 
146
165
  .alert-content-dismiss {
@@ -28,6 +28,8 @@ Covers `AlertContent` and its inner `AlertContentInner`. The inner tokens also a
28
28
  | `--alert-content-body-gap` | `0.4rem` | Gap between title and message |
29
29
  | `--alert-content-title-font-size` | `var(--step-4)` | Title size |
30
30
  | `--alert-content-text-font-size` | `var(--step-3)` | Message size |
31
+ | `--alert-content-title-line-clamp` | `none` | Max title lines before an ellipsis (`1` = single-line ellipsis, `none` = show everything) |
32
+ | `--alert-content-text-line-clamp` | `none` | Max message lines before an ellipsis. Leave it at `none` for messages people must read in full (errors, consent text): clamped text is still read by screen readers but hidden from sighted users |
31
33
  | `--alert-content-actions-spacing` | `1.2rem` | Space between the message and the actions row |
32
34
  | `--alert-content-actions-gap` | `0.8rem` | Gap between action controls |
33
35
  | `--alert-content-actions-justify` | `flex-end` | Horizontal alignment of the actions row (`justify-content`) |
@@ -42,6 +44,9 @@ Covers `AlertContent` and its inner `AlertContentInner`. The inner tokens also a
42
44
  | `--alert-content-dismiss-colour-hover` | `var(--theme-on-surface)` | Icon colour on hover/focus |
43
45
  | `--alert-content-dismiss-ring` | `var(--theme-ring)` | Focus/hover outline colour |
44
46
 
47
+ > **Changed 2026-10-08**: added the two line-clamp tokens. The title and message now wrap long
48
+ > unbroken strings (`overflow-wrap: anywhere`) instead of being clipped at the alert's edge.
49
+
45
50
  > **Changed 2026-09-27**: every value above except `--alert-content-inner-background` was hardcoded
46
51
  > (or read a `--theme-*` token directly, CLAUDE.md pitfall #14). Defaults are unchanged apart from
47
52
  > the px radii and stripe width, which are now the same sizes in rem. The icon and dismiss colours now
@@ -66,8 +71,8 @@ radius tokens once for the four corners.
66
71
 
67
72
  ## Local overrides
68
73
 
69
- Set the tokens above on an element you own (a page or section class, or a plain `class` on the
70
- component, which falls through to the root `.alert-content`): they inherit down into the component.
74
+ Set the tokens above on an element you own (a page or section class, or a `:style-class-passthrough`
75
+ class on the component, which lands on the root `.alert-content`): they inherit down into the component.
71
76
  Keep the block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer components`. If
72
77
  your own file uses `<style scoped>`, tokens set on your element still work, but selectors that reach
73
78
  inside the component need `:deep()`. Patterns and examples:
@@ -78,7 +83,15 @@ don't reach it: the alert always uses its own `theme` prop's palette. Override t
78
83
  tokens instead. When it's rendered by `DisplayToast`/`DisplayToastProvider` it's teleported to
79
84
  `<body>`, so set tokens globally or through the toast's own hooks rather than on a page wrapper.
80
85
 
86
+ **Caveat:** `.alert-content-actions` sets `--input-button-text-white-space: normal`, so an
87
+ `InputButton` in the `#actions` slot wraps a long label instead of spilling out of the alert. A value
88
+ for that token set on an ancestor doesn't reach buttons inside the actions row; set it on the button
89
+ itself (its own `style-class-passthrough` class) if you need `nowrap` there.
90
+
81
91
  ## Class passthrough
82
92
 
83
- No `style-class-passthrough` prop. A plain `class` attribute falls through to the root `.alert-content`
84
- (`AlertContent` has a single root), which is enough for setting tokens on one instance.
93
+ `style-class-passthrough` adds classes to the root `.alert-content`, so it's the hook for setting tokens
94
+ on one instance. A plain `class` attribute lands there too (single root). Components that render
95
+ `AlertContent` for you (`DisplayToast`, `DisplayPrompt`, `CookieConsentBanner`) don't forward it.
96
+
97
+ > **Changed 2026-10-08**: added the prop. Before this, a plain `class` was the only option.
@@ -1,11 +1,34 @@
1
+ import { computed, ref } from "vue";
1
2
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
2
3
  import AlertContent from "../AlertContent.vue";
3
4
  import InputButton from "../../../05.forms/input-button/InputButton.vue";
5
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
6
+ import type { MediaCanvas } from "~/types/components";
4
7
 
5
- const meta: Meta<typeof AlertContent> = {
8
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
9
+
10
+ // Story-only args: drive CSS tokens, not AlertContent props. See their argTypes entries.
11
+ type StoryArgs = InstanceType<typeof AlertContent>["$props"] & {
12
+ titleLineClamp: LineClamp;
13
+ textLineClamp: LineClamp;
14
+ };
15
+
16
+ const lineClampArgType = (token: string, example: string) => ({
17
+ control: "select" as const,
18
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
19
+ description:
20
+ `**Story control, not a prop.** Sets the \`${token}\` CSS token on a wrapper so you can try it here. ` +
21
+ `To use it in an app, set the token in your own CSS, e.g. \`.site-notice { ${token}: ${example}; }\`. ` +
22
+ "`1` is single-line ellipsis, `none` (the default) shows everything. Don't clamp messages people need to read in full.",
23
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
24
+ });
25
+
26
+ const meta: Meta<StoryArgs> = {
6
27
  title: "Molecules/AlertContent",
7
28
  component: AlertContent,
8
29
  argTypes: {
30
+ titleLineClamp: lineClampArgType("--alert-content-title-line-clamp", "1"),
31
+ textLineClamp: lineClampArgType("--alert-content-text-line-clamp", "3"),
9
32
  theme: {
10
33
  control: { type: "inline-radio" },
11
34
  options: ["info", "success", "warning", "error"],
@@ -27,6 +50,11 @@ const meta: Meta<typeof AlertContent> = {
27
50
  description: "Show a close button (emits 'dismiss' on click)",
28
51
  table: { category: "Behaviour" },
29
52
  },
53
+ styleClassPassthrough: {
54
+ control: "object",
55
+ description: "Additional CSS classes applied to the root element",
56
+ table: { category: "Styling" },
57
+ },
30
58
  contentId: { table: { disable: true } },
31
59
  ariaLive: { table: { disable: true } },
32
60
  },
@@ -34,22 +62,53 @@ const meta: Meta<typeof AlertContent> = {
34
62
  theme: "info",
35
63
  showIcon: true,
36
64
  dismissible: false,
65
+ titleLineClamp: "none",
66
+ textLineClamp: "none",
37
67
  },
38
68
  };
39
69
 
40
70
  export default meta;
41
- type Story = StoryObj<typeof AlertContent>;
71
+ type Story = StoryObj<StoryArgs>;
72
+
73
+ // Links open in the Storybook manager (target="_top"), not inside the preview iframe.
74
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
75
+ const toastNote = `
76
+ <p style="${storyNoteStyle}">
77
+ To see the dismiss action and auto-dismiss in context, open the
78
+ <a href="/?path=/story/molecules-displaytoast--default" target="_top">DisplayToast</a> and
79
+ <a href="/?path=/story/molecules-displaytoastprovider--default" target="_top">DisplayToastProvider</a> stories.
80
+ </p>
81
+ `;
82
+ const cookieNote = `
83
+ <p style="${storyNoteStyle}">
84
+ For the #actions slot wired up to real accept and reject handlers, open the
85
+ <a href="/?path=/story/molecules-cookieconsentbanner--default" target="_top">CookieConsentBanner</a> story.
86
+ </p>
87
+ `;
88
+
89
+ function useStorySetup(args: StoryArgs) {
90
+ const componentArgs = computed(() => {
91
+ const { titleLineClamp: _titleLineClamp, textLineClamp: _textLineClamp, ...rest } = args;
92
+ return rest;
93
+ });
94
+ const tokenStyles = computed(() => ({
95
+ "--alert-content-title-line-clamp": args.titleLineClamp,
96
+ "--alert-content-text-line-clamp": args.textLineClamp,
97
+ }));
98
+ return { args, componentArgs, tokenStyles };
99
+ }
42
100
 
43
101
  /** Default — info theme with title and body content. */
44
102
  export const Default: Story = {
45
103
  render: (args) => ({
46
104
  components: { AlertContent },
47
105
  setup() {
48
- return { args };
106
+ return useStorySetup(args);
49
107
  },
50
108
  template: `
51
- <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
52
- <AlertContent v-bind="args">
109
+ <div :data-theme="args.theme" :style="tokenStyles" style="max-width: 600px; padding: 2rem;">
110
+ ${toastNote}
111
+ <AlertContent v-bind="componentArgs">
53
112
  <template #title>Alert title</template>
54
113
  <template #content>This is the alert body. It describes what happened and what the user should do next.</template>
55
114
  </AlertContent>
@@ -64,11 +123,12 @@ export const Dismissible: Story = {
64
123
  render: (args) => ({
65
124
  components: { AlertContent },
66
125
  setup() {
67
- return { args };
126
+ return useStorySetup(args);
68
127
  },
69
128
  template: `
70
- <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
71
- <AlertContent v-bind="args">
129
+ <div :data-theme="args.theme" :style="tokenStyles" style="max-width: 600px; padding: 2rem;">
130
+ ${toastNote}
131
+ <AlertContent v-bind="componentArgs">
72
132
  <template #title>Dismissible alert</template>
73
133
  <template #content>Click the close button to emit the dismiss event.</template>
74
134
  </AlertContent>
@@ -84,11 +144,12 @@ export const TitleOnly: Story = {
84
144
  render: (args) => ({
85
145
  components: { AlertContent },
86
146
  setup() {
87
- return { args };
147
+ return useStorySetup(args);
88
148
  },
89
149
  template: `
90
- <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
91
- <AlertContent v-bind="args">
150
+ <div :data-theme="args.theme" :style="tokenStyles" style="max-width: 600px; padding: 2rem;">
151
+ ${toastNote}
152
+ <AlertContent v-bind="componentArgs">
92
153
  <template #title>Your changes have been saved.</template>
93
154
  </AlertContent>
94
155
  </div>
@@ -103,11 +164,12 @@ export const CustomIcon: Story = {
103
164
  render: (args) => ({
104
165
  components: { AlertContent },
105
166
  setup() {
106
- return { args };
167
+ return useStorySetup(args);
107
168
  },
108
169
  template: `
109
- <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
110
- <AlertContent v-bind="args">
170
+ <div :data-theme="args.theme" :style="tokenStyles" style="max-width: 600px; padding: 2rem;">
171
+ ${toastNote}
172
+ <AlertContent v-bind="componentArgs">
111
173
  <template #title>Custom icon via prop</template>
112
174
  <template #content>The icon is overridden using the customIcon prop.</template>
113
175
  </AlertContent>
@@ -126,6 +188,7 @@ export const AllThemes: Story = {
126
188
  },
127
189
  template: `
128
190
  <div style="display: flex; flex-direction: column; gap: 1.6rem; max-width: 600px; padding: 2rem;">
191
+ ${toastNote}
129
192
  <div v-for="theme in themes" :key="theme" :data-theme="theme">
130
193
  <AlertContent :theme="theme" :dismissible="true">
131
194
  <template #title>{{ theme.charAt(0).toUpperCase() + theme.slice(1) }}</template>
@@ -144,11 +207,12 @@ export const WithActions: Story = {
144
207
  render: (args) => ({
145
208
  components: { AlertContent, InputButton },
146
209
  setup() {
147
- return { args };
210
+ return useStorySetup(args);
148
211
  },
149
212
  template: `
150
- <div :data-theme="args.theme" style="max-width: 600px; padding: 2rem;">
151
- <AlertContent v-bind="args">
213
+ <div :data-theme="args.theme" :style="tokenStyles" style="max-width: 600px; padding: 2rem;">
214
+ ${cookieNote}
215
+ <AlertContent v-bind="componentArgs">
152
216
  <template #title>Cookies</template>
153
217
  <template #content>This site uses cookies to understand how it's used. You can accept or reject them.</template>
154
218
  <template #actions>
@@ -163,8 +227,127 @@ export const WithActions: Story = {
163
227
  docs: {
164
228
  description: {
165
229
  story:
230
+ "For the slot wired to real handlers, see the [CookieConsentBanner](?path=/story/molecules-cookieconsentbanner--default) story. " +
166
231
  "The #actions slot renders a wrapping row under the message, outside the aria-live/contentId body so the buttons aren't announced or pulled into aria-describedby. Actions are right-aligned by default (--alert-content-actions-justify). The cookie icon comes from customIcon; set showIcon to false to drop the icon column.",
167
232
  },
168
233
  },
169
234
  },
170
235
  };
236
+
237
+ const stressAlerts = [
238
+ {
239
+ key: "german",
240
+ theme: "error",
241
+ dismissible: true,
242
+ title: "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft",
243
+ content:
244
+ "Ihre Zahlung konnte leider nicht verarbeitet werden. Bitte überprüfen Sie Ihre Kartendaten und versuchen Sie es erneut, oder wenden Sie sich an Ihre Bank. https://example.com/zahlungen/fehler/a-very-long-path-segment-without-any-spaces-at-all-in-it",
245
+ actions: ["Zahlungsinformationenaktualisierung", "Kundendienstmitarbeiterkontaktaufnahme"],
246
+ },
247
+ {
248
+ key: "emoji-html",
249
+ theme: "warning",
250
+ dismissible: true,
251
+ title: "🚨🔥 Heads up <script>alert('xss')</script>",
252
+ content: "<b>Not bold</b> &amp; <img src=x onerror=alert(1)> should all render as plain text. 👍🏽👨‍👩‍👧‍👦",
253
+ },
254
+ {
255
+ key: "rtl",
256
+ theme: "info",
257
+ dir: "rtl",
258
+ dismissible: true,
259
+ title: "تنبيه مهم بخصوص حسابك",
260
+ content: "تم تحديث شروط الخدمة. يرجى مراجعة التغييرات قبل المتابعة لاستخدام الموقع.",
261
+ },
262
+ {
263
+ key: "single-char",
264
+ theme: "success",
265
+ title: "Ok",
266
+ },
267
+ {
268
+ key: "broken-icon-empty-title",
269
+ theme: "info",
270
+ customIcon: "not-a-real-set:missing-icon",
271
+ title: "",
272
+ content: "Broken customIcon name and an empty title slot.",
273
+ },
274
+ {
275
+ key: "content-only-no-icon",
276
+ theme: "success",
277
+ showIcon: false,
278
+ content: "x",
279
+ },
280
+ ];
281
+
282
+ /**
283
+ * Stress test — hostile content across several alerts, inside the CanvasSwitcher so it can be
284
+ * checked at every width.
285
+ */
286
+ export const StressTest: Story = {
287
+ name: "Stress Test (Worst-Case Data)",
288
+ args: { titleLineClamp: "none", textLineClamp: "none" },
289
+ decorators: [
290
+ (story, context) => ({
291
+ components: { story, CanvasSwitcher },
292
+ setup() {
293
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
294
+ return { canvasName };
295
+ },
296
+ template: `
297
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
298
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
299
+ </div>
300
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
301
+ <story />
302
+ </div>
303
+ `,
304
+ }),
305
+ ],
306
+ render: (args) => ({
307
+ components: { AlertContent, InputButton },
308
+ setup() {
309
+ return { ...useStorySetup(args), stressAlerts };
310
+ },
311
+ template: `
312
+ <div :style="tokenStyles" style="display: grid; gap: 1.6rem;">
313
+ ${toastNote}
314
+ <div v-for="alert in stressAlerts" :key="alert.key" :data-theme="alert.theme" :dir="alert.dir">
315
+ <AlertContent
316
+ :theme="alert.theme"
317
+ :custom-icon="alert.customIcon"
318
+ :show-icon="alert.showIcon ?? true"
319
+ :dismissible="alert.dismissible ?? false"
320
+ >
321
+ <template v-if="alert.title !== undefined" #title>{{ alert.title }}</template>
322
+ <template v-if="alert.content" #content>{{ alert.content }}</template>
323
+ <template v-if="alert.actions" #actions>
324
+ <InputButton
325
+ v-for="label in alert.actions"
326
+ :key="label"
327
+ type="button"
328
+ variant="primary"
329
+ :button-text="label"
330
+ />
331
+ </template>
332
+ <template v-if="alert.dir === 'rtl'" #dismissLabel>إغلاق</template>
333
+ </AlertContent>
334
+ </div>
335
+ </div>
336
+ `,
337
+ }),
338
+ parameters: {
339
+ layout: "fullscreen",
340
+ initialCanvas: "mobileCanvas",
341
+ docs: {
342
+ description: {
343
+ story:
344
+ "Hostile content: an unbroken German compound as the title, long German copy ending in a long URL, long German button labels, " +
345
+ "emoji at the start, HTML-like text (must render as text), right-to-left Arabic, a two-character title, an empty title slot, " +
346
+ "a broken customIcon name, and a one-character message with no icon. Check at every CanvasSwitcher width that nothing overflows " +
347
+ "or gets clipped by the alert's rounded edge, unbroken text wraps, the action buttons wrap their labels instead of spilling out, " +
348
+ "the icon and dismiss button keep their size, and the RTL alert puts the accent stripe on the right. " +
349
+ "The two line-clamp controls are CSS tokens (story only), not props: try `1` to see single-line ellipsis.",
350
+ },
351
+ },
352
+ },
353
+ };
@@ -156,6 +156,39 @@ describe("AlertContent", () => {
156
156
  expect(wrapper.find("[data-test-id='alert-actions']").exists()).toBe(false);
157
157
  });
158
158
 
159
+ // ─── styleClassPassthrough / hostile text (added 2026-10-08) ────────────────
160
+
161
+ it("applies styleClassPassthrough classes to the root", async () => {
162
+ const wrapper = await mountSuspended(AlertContent, {
163
+ props: { theme: "info", styleClassPassthrough: ["site-notice", "is-wide"] },
164
+ });
165
+ expect(wrapper.find(".alert-content").classes()).toEqual(expect.arrayContaining(["site-notice", "is-wide"]));
166
+ });
167
+
168
+ it("updates root classes when styleClassPassthrough changes", async () => {
169
+ const wrapper = await mountSuspended(AlertContent, {
170
+ props: { theme: "info", styleClassPassthrough: "first-class" },
171
+ });
172
+ await wrapper.setProps({ styleClassPassthrough: "second-class" });
173
+ const classes = wrapper.find(".alert-content").classes();
174
+ expect(classes).toContain("second-class");
175
+ expect(classes).not.toContain("first-class");
176
+ });
177
+
178
+ it("renders HTML-like slot text as text, not markup", async () => {
179
+ const wrapper = await mountSuspended(
180
+ {
181
+ components: { AlertContent },
182
+ setup: () => ({ text: "<img src=x onerror=alert(1)>" }),
183
+ template: `<AlertContent theme="info"><template #content>{{ text }}</template></AlertContent>`,
184
+ },
185
+ {}
186
+ );
187
+ const content = wrapper.find("[data-test-id='alert-content']");
188
+ expect(content.find("img").exists()).toBe(false);
189
+ expect(content.text()).toBe("<img src=x onerror=alert(1)>");
190
+ });
191
+
159
192
  it("uses component-prefixed classes for the title and text", async () => {
160
193
  const wrapper = await mountSuspended(AlertContent, {
161
194
  props: { theme: "info" },
@@ -38,9 +38,12 @@ scale token.
38
38
  - Size class on the root: `.xs`, `.s`, `.md`, `.lg`, `.xl` (or your custom `size` string).
39
39
  - Classes: `.display-avatar` (root), `.display-avatar-image` (the image), `.display-avatar-icon`
40
40
  (opt-in, for your `#icon` slot content).
41
- - With `chip`, the root is `DisplayChip`, so it also carries `.display-chip` and its shape
42
- class; status classes such as `.online` (through `style-class-passthrough`) are `DisplayChip`'s.
43
- See `display-chip` for its tokens.
41
+ - With `chip`, the root is `DisplayChip`, so it also carries `.display-chip` and its
42
+ `data-shape`/`data-status` attributes (status comes from the `status` prop). See `display-chip`
43
+ for its tokens.
44
+
45
+ > **Changed 2026-10-08**: chip status is the `status` prop (forwarded to `DisplayChip` as
46
+ > `data-status`), not an `online`/`idle`/`dnd` class through `style-class-passthrough`.
44
47
 
45
48
  > **Changed 2026-09-27**: `.avatar-image` → `.display-avatar-image` and `.avatar-icon` →
46
49
  > `.display-avatar-icon`.
@@ -59,5 +62,4 @@ scale (`--display-avatar-size-md` etc.) from an ancestor works, but a one-off di
59
62
 
60
63
  ## Class passthrough
61
64
 
62
- `style-class-passthrough` adds classes to the root, including when the root is `DisplayChip`. That's
63
- how chip status classes like `online` are applied.
65
+ `style-class-passthrough` adds classes to the root, including when the root is `DisplayChip`.
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <component
3
3
  :is="chip ? DisplayChip : as"
4
- v-bind="chip ? { tag: chipTag, config: chipConfig } : {}"
4
+ v-bind="chip ? { tag: chipTag, config: chipConfig, status, statusLabel } : {}"
5
5
  class="display-avatar"
6
6
  :class="[size, elementClasses]"
7
7
  >
@@ -18,7 +18,7 @@
18
18
 
19
19
  <script setup lang="ts">
20
20
  import DisplayChip from "../display-chip/DisplayChip.vue";
21
- import type { DisplayChipConfig } from "~/types/components";
21
+ import type { DisplayChipConfig, DisplayChipStatus } from "~/types/components";
22
22
 
23
23
  interface Props {
24
24
  as?: string | object;
@@ -27,6 +27,8 @@ interface Props {
27
27
  text?: string;
28
28
  size?: "xs" | "s" | "md" | "lg" | "xl" | string;
29
29
  chip?: boolean | DisplayChipConfig;
30
+ status?: DisplayChipStatus;
31
+ statusLabel?: string;
30
32
  styleClassPassthrough?: string | string[];
31
33
  }
32
34
 
@@ -37,6 +39,8 @@ const props = withDefaults(defineProps<Props>(), {
37
39
  text: undefined,
38
40
  size: "md",
39
41
  chip: undefined,
42
+ status: undefined,
43
+ statusLabel: undefined,
40
44
  styleClassPassthrough: () => [],
41
45
  });
42
46
 
@@ -56,6 +56,21 @@ export default {
56
56
  category: "Chip Configuration",
57
57
  },
58
58
  },
59
+ status: {
60
+ control: { type: "inline-radio" },
61
+ options: ["offline", "online", "idle", "dnd"],
62
+ description: "Chip status colour (forwarded to DisplayChip)",
63
+ table: {
64
+ category: "Chip Configuration",
65
+ },
66
+ },
67
+ statusLabel: {
68
+ control: { type: "text" },
69
+ description: "Screen-reader text for the chip status, e.g. 'Online' (forwarded to DisplayChip)",
70
+ table: {
71
+ category: "Chip Configuration",
72
+ },
73
+ },
59
74
  // Hide the chip prop from controls since we're using individual controls
60
75
  chip: {
61
76
  table: {
@@ -77,7 +92,9 @@ export default {
77
92
  chipMaskWidth: 4,
78
93
  chipOffset: 2,
79
94
  chipAngle: 45,
80
- styleClassPassthrough: ["test-storybook--display-avatar", "online"],
95
+ status: "online",
96
+ statusLabel: "Online",
97
+ styleClassPassthrough: ["test-storybook--display-avatar"],
81
98
  },
82
99
  } as Meta<typeof StorybookComponent>;
83
100
 
@@ -102,6 +119,8 @@ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
102
119
  :src="args.src"
103
120
  :alt="args.alt"
104
121
  :chip="chip"
122
+ :status="args.status"
123
+ :status-label="args.statusLabel"
105
124
  :style-class-passthrough="args.styleClassPassthrough"
106
125
  />
107
126
  </div>
@@ -117,8 +136,8 @@ export const Initials: StoryFn<typeof StorybookComponent> = (args) => ({
117
136
  },
118
137
  template: `
119
138
  <div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; height: 100vh;">
120
- <StorybookComponent :size="args.size" :alt="args.alt" :chip="chip" :style-class-passthrough="args.styleClassPassthrough" />
121
- <StorybookComponent :size="args.size" text="?" :chip="chip" :style-class-passthrough="args.styleClassPassthrough" />
139
+ <StorybookComponent :size="args.size" :alt="args.alt" :chip="chip" :status="args.status" :status-label="args.statusLabel" :style-class-passthrough="args.styleClassPassthrough" />
140
+ <StorybookComponent :size="args.size" text="?" :chip="chip" :status="args.status" :status-label="args.statusLabel" :style-class-passthrough="args.styleClassPassthrough" />
122
141
  </div>
123
142
  `,
124
143
  });
@@ -139,6 +158,8 @@ export const Sizes: StoryFn<typeof StorybookComponent> = (args) => ({
139
158
  :src="args.src"
140
159
  :alt="args.alt"
141
160
  :chip="chip"
161
+ :status="args.status"
162
+ :status-label="args.statusLabel"
142
163
  :style-class-passthrough="args.styleClassPassthrough"
143
164
  />
144
165
  </div>
@@ -232,6 +232,25 @@ describe("DisplayAvatar", () => {
232
232
  expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-chip", "display-avatar", "lg", "online"]));
233
233
  });
234
234
 
235
+ it("forwards status and statusLabel to the chip", async () => {
236
+ const wrapper = await mountSuspended(DisplayAvatar, {
237
+ props: { chip: true, alt: "Jane Smith", status: "dnd", statusLabel: "Do not disturb" },
238
+ });
239
+ expect(wrapper.attributes("data-status")).toBe("dnd");
240
+ expect(wrapper.findAll(".sr-only").map((el) => el.text())).toContain("Do not disturb");
241
+ });
242
+
243
+ it("leaves the chip offline when no status is given", async () => {
244
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { chip: true } });
245
+ expect(wrapper.attributes("data-status")).toBe("offline");
246
+ });
247
+
248
+ it("does not put status attributes on a plain avatar", async () => {
249
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { status: "online" } });
250
+ expect(wrapper.attributes("data-status")).toBeUndefined();
251
+ expect(wrapper.attributes("status")).toBeUndefined();
252
+ });
253
+
235
254
  it("updates the chip config when the chip prop changes", async () => {
236
255
  const wrapper = await mountSuspended(DisplayAvatar, { props: { chip: true } });
237
256
  await wrapper.setProps({ chip: { size: "20px", maskWidth: "2px", offset: "0px", angle: "0deg" } });
@@ -3,7 +3,8 @@
3
3
  exports[`DisplayAvatar > renders correct HTML structure (default) 1`] = `"<span class="display-avatar md"><span aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span>"`;
4
4
 
5
5
  exports[`DisplayAvatar > renders correct HTML structure (with chip) 1`] = `
6
- "<span class="display-chip circle display-avatar md" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><span aria-hidden="true">JD</span><span class="sr-only">John Doe</span>
6
+ "<span class="display-chip display-avatar md" data-shape="circle" data-status="offline" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><span aria-hidden="true">JD</span><span class="sr-only">John Doe</span>
7
+ <!--v-if-->
7
8
  <!--v-if-->
8
9
  <!--v-if--></span>"
9
10
  `;