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
@@ -2,19 +2,35 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
+ ### Geometry
6
+
5
7
  | Token | Default | Controls |
6
8
  |---|---|---|
7
- | `--display-chip-colour-offline` | `slategrey` | Dot colour with no status class |
8
- | `--display-chip-colour-online` | `rgb(0, 255, 135)` | Dot colour with `.online` |
9
- | `--display-chip-colour-idle` | `rgb(255, 185, 51)` | Dot colour with `.idle` |
10
- | `--display-chip-colour-dnd` | `rgb(255, 40, 80)` | Dot colour with `.dnd` |
9
+ | `--display-chip-size` | `1.2rem` | Dot diameter. Negative values clamp to `0` (no dot) |
10
+ | `--display-chip-mask-width` | `0.4rem` | Width of the cutout ring around the dot. Negative values clamp to `0` |
11
+ | `--display-chip-offset` | `0rem` | Extra distance from the host's edge (negative pulls the dot inwards) |
12
+ | `--display-chip-angle` | `90deg` | Position around the host, clockwise from the top |
13
+
14
+ A field set in the `config` prop wins over its token for that chip. Fields left out of `config` fall
15
+ back to the token, so `:config="{ label: '5' }"` gets the default geometry.
16
+
17
+ > Changed 2026-10-08: geometry tokens are new. Before, geometry came only from `config`, and a
18
+ > `config` missing any of `size`/`maskWidth`/`offset`/`angle` broke the dot and cutout entirely.
19
+
20
+ ### Colour
21
+
22
+ | Token | Default | Controls |
23
+ |---|---|---|
24
+ | `--display-chip-colour-offline` | `var(--status-neutral)` | Dot colour for `status="offline"` (the default) |
25
+ | `--display-chip-colour-online` | `var(--status-success)` | Dot colour for `status="online"` |
26
+ | `--display-chip-colour-idle` | `var(--status-warning)` | Dot colour for `status="idle"` |
27
+ | `--display-chip-colour-dnd` | `var(--status-danger)` | Dot colour for `status="dnd"` |
11
28
  | `--display-chip-text-colour` | `black` | Icon and label colour on the dot |
12
29
 
13
- Geometry (size, mask width, offset, angle) is set through the `config` prop, not tokens. Those
14
- values are written inline as private `--_chip-size`, `--_chip-mask-width`, `--_chip-offset` and
15
- `--_chip-angle`, which feed the private position maths (`--_mask-diameter`, `--_position-x`,
16
- `--_position-y`, `--_offset`, `--_circle-x`, `--_circle-y`) and the resolved `--_dot-colour`. None
17
- of these are public API.
30
+ Private tokens (not public API): `--_chip-size`, `--_chip-mask-width`, `--_chip-offset` and
31
+ `--_chip-angle` (written inline from `config` or the geometry tokens), `--_size`, and the position
32
+ maths (`--_mask-diameter`, `--_position-x`, `--_position-y`, `--_offset`, `--_circle-x`,
33
+ `--_circle-y`), plus the resolved `--_dot-colour` and the label's `--_font-size-adjust`.
18
34
 
19
35
  > Changed 2026-09-27: the status colours were unprefixed `--color-offline`, `--color-online`,
20
36
  > `--color-idle` and `--color-dnd`, and the geometry and maths vars were unprefixed too
@@ -22,20 +38,34 @@ of these are public API.
22
38
  > consumer variable of the same name. The icon is now sized with `font-size` (its old
23
39
  > `width`/`height` were overridden by `@nuxt/icon`), and the icon/label colour is a token instead
24
40
  > of a hardcoded `black`.
41
+ >
42
+ > Changed 2026-10-07: the status colours default to the global `--status-*` tokens
43
+ > (`03.theming/_status.css`) instead of hardcoded neon `rgb()` values and `slategrey`, so they
44
+ > match `DisplayPill`, `SelectMenu` and anything else reading the shared set. To keep the old
45
+ > look, set the four `--display-chip-colour-*` tokens to the previous values. To restyle every
46
+ > status across the library, override `--status-*` instead. See
47
+ > `.claude/skills/theming-status-tokens.md`.
25
48
 
26
49
  ---
27
50
 
28
51
  ## State hooks
29
52
 
30
- | Class | When |
31
- |---|---|
32
- | `.display-chip.circle`, `.square` | The `shape` prop (must match the slotted content's shape) |
33
- | `.online`, `.idle`, `.dnd` | Added by you through `style-class-passthrough`; no class means offline |
53
+ | Hook | Element | When |
54
+ |---|---|---|
55
+ | `[data-shape="circle\|square"]` | `.display-chip` (root) | The `shape` prop (must match the slotted content's shape) |
56
+ | `[data-status="offline\|online\|idle\|dnd"]` | `.display-chip` (root) | The `status` prop (default `offline`) |
57
+ | `[data-length="1\|2\|3"]` | `.display-chip-label` | Label length in characters (emoji count as one), for font-size stepping |
34
58
 
35
- Inner elements: the dot is `.display-chip::after`; `.chip-icon` and `.chip-label` (with
36
- `.length-1`/`.length-2`/`.length-3` for font-size stepping) sit on top of it. Every other direct
37
- child gets the cutout `mask-image`.
59
+ Inner elements: the dot is `.display-chip::after`; `.display-chip-icon` and `.display-chip-label`
60
+ sit on top of it. The `statusLabel` text is a `.sr-only` span. Every other direct child gets the
61
+ cutout `mask-image`.
38
62
 
63
+ > Changed 2026-10-08: shape and status are `data-shape`/`data-status` attributes instead of the
64
+ > `.circle`/`.square` and `.online`/`.idle`/`.dnd` classes, so they can't collide with a
65
+ > consumer's own classes of the same name. Status is now the `status` prop rather than a class
66
+ > passed through `style-class-passthrough`. `.chip-icon` β†’ `.display-chip-icon`, `.chip-label` β†’
67
+ > `.display-chip-label`, and its `.length-N` classes β†’ `data-length="N"`.
68
+ >
39
69
  > Changed 2026-09-27: root class renamed from `.display-chip-core`.
40
70
 
41
71
  ---
@@ -48,10 +78,13 @@ Set the tokens above on an element you own (a page or section class, or a class
48
78
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
49
79
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
50
80
 
81
+ **Caveat:** a geometry field set in `config` is written inline on the chip, so it beats the
82
+ matching `--display-chip-*` geometry token from any ancestor. Leave the field out of `config` to
83
+ let the token apply.
84
+
51
85
  ---
52
86
 
53
87
  ## Class passthrough
54
88
 
55
- `style-class-passthrough` adds classes to the root `.display-chip`. It's also how you set the
56
- status (`online`, `idle`, `dnd`). Reactive after mount. `DisplayAvatar` with `chip` renders this
57
- component as its root, so the same applies there.
89
+ `style-class-passthrough` adds classes to the root `.display-chip`. Reactive after mount.
90
+ `DisplayAvatar` with `chip` renders this component as its root, so the same applies there.
@@ -1,17 +1,27 @@
1
1
  <template>
2
- <component :is="tag" class="display-chip" :class="[shape, elementClasses]" :style="chipStyles">
2
+ <component
3
+ :is="tag"
4
+ class="display-chip"
5
+ :class="elementClasses"
6
+ :data-shape="shape"
7
+ :data-status="status"
8
+ :style="chipStyles"
9
+ >
3
10
  <slot name="default"></slot>
4
- <Icon v-if="config?.icon" :name="config.icon" class="chip-icon" />
5
- <span v-if="config?.label" class="chip-label" :class="`length-${config.label.length}`">{{ validatedLabel }}</span>
11
+ <Icon v-if="config?.icon" :name="config.icon" class="display-chip-icon" aria-hidden="true" />
12
+ <span v-if="labelGraphemes.length" class="display-chip-label" :data-length="displayLength">{{ displayLabel }}</span>
13
+ <span v-if="statusLabel" class="sr-only">{{ statusLabel }}</span>
6
14
  </component>
7
15
  </template>
8
16
 
9
17
  <script setup lang="ts">
10
- import type { DisplayChipConfig } from "~/types/components";
18
+ import type { DisplayChipConfig, DisplayChipStatus } from "~/types/components";
11
19
 
12
20
  interface Props {
13
21
  tag?: "div" | "span";
14
22
  shape?: "circle" | "square";
23
+ status?: DisplayChipStatus;
24
+ statusLabel?: string;
15
25
  config?: DisplayChipConfig;
16
26
  styleClassPassthrough?: string | string[];
17
27
  }
@@ -19,17 +29,14 @@ interface Props {
19
29
  const props = withDefaults(defineProps<Props>(), {
20
30
  tag: "span",
21
31
  shape: "circle",
22
- config: () => ({
23
- size: "12px",
24
- maskWidth: "4px",
25
- offset: "0px",
26
- angle: "90deg",
27
- icon: undefined,
28
- label: undefined,
29
- }),
32
+ status: "offline",
33
+ statusLabel: "",
34
+ config: () => ({}),
30
35
  styleClassPassthrough: () => [],
31
36
  });
32
37
 
38
+ const MAX_LABEL_LENGTH = 3;
39
+
33
40
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
34
41
 
35
42
  watch(
@@ -39,112 +46,123 @@ watch(
39
46
  }
40
47
  );
41
48
 
42
- const validatedLabel = computed(() => {
43
- if (!props.config?.label) return props.config?.label;
44
- if (props.config.label.length > 3) {
45
- console.warn(
46
- `DisplayChip: label "${
47
- props.config.label
48
- }" exceeds maximum length of 3 characters. Truncating to "${props.config.label.slice(0, 3)}"`
49
- );
50
- return props.config.label.slice(0, 3);
51
- }
52
- return props.config.label;
49
+ const labelGraphemes = computed(() => {
50
+ const label = props.config?.label?.trim();
51
+ if (!label) return [];
52
+ return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(label), ({ segment }) => segment);
53
53
  });
54
54
 
55
+ const displayLabel = computed(() => labelGraphemes.value.slice(0, MAX_LABEL_LENGTH).join(""));
56
+ const displayLength = computed(() => Math.min(labelGraphemes.value.length, MAX_LABEL_LENGTH));
57
+
58
+ watch(
59
+ labelGraphemes,
60
+ (graphemes) => {
61
+ if (graphemes.length > MAX_LABEL_LENGTH) {
62
+ console.warn(
63
+ `DisplayChip: label "${props.config?.label}" exceeds maximum length of ${MAX_LABEL_LENGTH} characters. Truncating to "${displayLabel.value}"`
64
+ );
65
+ }
66
+ },
67
+ { immediate: true }
68
+ );
69
+
70
+ // Always written inline so a nested chip never inherits its parent's geometry.
55
71
  const chipStyles = computed(() => ({
56
- "--_chip-size": props.config?.size,
57
- "--_chip-mask-width": props.config?.maskWidth,
58
- "--_chip-offset": props.config?.offset,
59
- "--_chip-angle": props.config?.angle,
72
+ "--_chip-size": props.config?.size || "var(--display-chip-size, 1.2rem)",
73
+ "--_chip-mask-width": props.config?.maskWidth || "var(--display-chip-mask-width, 0.4rem)",
74
+ "--_chip-offset": props.config?.offset || "var(--display-chip-offset, 0rem)",
75
+ "--_chip-angle": props.config?.angle || "var(--display-chip-angle, 90deg)",
60
76
  }));
61
77
  </script>
62
78
 
63
79
  <style lang="css">
64
80
  @layer components {
65
81
  .display-chip {
66
- --_mask-diameter: calc(var(--_chip-size) + (var(--_chip-mask-width) * 2));
67
- --_dot-colour: var(--display-chip-colour-offline, slategrey);
82
+ --_size: max(0px, var(--_chip-size));
83
+ --_mask-diameter: calc(var(--_size) + (max(0px, var(--_chip-mask-width)) * 2));
84
+ --_dot-colour: var(--display-chip-colour-offline, var(--status-neutral));
68
85
 
69
86
  position: relative;
70
87
  display: inline-block;
71
88
 
72
- &.circle {
89
+ &[data-shape="circle"] {
73
90
  --_offset: calc((100% / 2) + var(--_chip-offset));
74
91
  --_position-x: calc(var(--_offset) * cos(var(--_chip-angle) - 90deg) + (100% / 2));
75
92
  --_position-y: calc(var(--_offset) * sin(var(--_chip-angle) - 90deg) + (100% / 2));
76
93
  }
77
94
 
78
- &.square {
79
- --_circle-x: calc(50% + (50% + var(--_chip-offset) + (var(--_chip-size) / 2)) * cos(var(--_chip-angle) - 90deg));
80
- --_circle-y: calc(50% + (50% + var(--_chip-offset) + (var(--_chip-size) / 2)) * sin(var(--_chip-angle) - 90deg));
95
+ &[data-shape="square"] {
96
+ --_circle-x: calc(50% + (50% + var(--_chip-offset) + (var(--_size) / 2)) * cos(var(--_chip-angle) - 90deg));
97
+ --_circle-y: calc(50% + (50% + var(--_chip-offset) + (var(--_size) / 2)) * sin(var(--_chip-angle) - 90deg));
81
98
  --_position-x: clamp(calc(var(--_chip-offset) * -1), var(--_circle-x), calc(100% + var(--_chip-offset)));
82
99
  --_position-y: clamp(calc(var(--_chip-offset) * -1), var(--_circle-y), calc(100% + var(--_chip-offset)));
83
100
  }
84
101
 
85
- &.online {
86
- --_dot-colour: var(--display-chip-colour-online, rgb(0, 255, 135));
102
+ &[data-status="online"] {
103
+ --_dot-colour: var(--display-chip-colour-online, var(--status-success));
87
104
  }
88
105
 
89
- &.idle {
90
- --_dot-colour: var(--display-chip-colour-idle, rgb(255, 185, 51));
106
+ &[data-status="idle"] {
107
+ --_dot-colour: var(--display-chip-colour-idle, var(--status-warning));
91
108
  }
92
109
 
93
- &.dnd {
94
- --_dot-colour: var(--display-chip-colour-dnd, rgb(255, 40, 80));
110
+ &[data-status="dnd"] {
111
+ --_dot-colour: var(--display-chip-colour-dnd, var(--status-danger));
95
112
  }
96
113
 
97
114
  &::after {
98
115
  content: "";
99
- aspect-ratio: 1;
100
116
  background: var(--_dot-colour);
101
117
  position: absolute;
102
- width: var(--_chip-size);
118
+ width: var(--_size);
119
+ height: var(--_size);
103
120
  border-radius: 100%;
104
121
  z-index: 1;
105
122
  }
106
123
 
107
124
  &::after,
108
- .chip-icon,
109
- .chip-label {
110
- top: calc(var(--_position-y) - (var(--_chip-size) / 2));
111
- left: calc(var(--_position-x) - (var(--_chip-size) / 2));
125
+ .display-chip-icon,
126
+ .display-chip-label {
127
+ top: calc(var(--_position-y) - (var(--_size) / 2));
128
+ left: calc(var(--_position-x) - (var(--_size) / 2));
112
129
  }
113
130
 
114
- .chip-icon {
131
+ .display-chip-icon {
115
132
  position: absolute;
116
- font-size: var(--_chip-size);
133
+ font-size: var(--_size);
117
134
  color: var(--display-chip-text-colour, black);
118
135
  z-index: 2;
119
136
  }
120
137
 
121
- .chip-label {
138
+ .display-chip-label {
122
139
  --_font-size-adjust: 0.7;
123
140
 
124
141
  position: absolute;
125
- width: var(--_chip-size);
126
- height: var(--_chip-size);
142
+ width: var(--_size);
143
+ height: var(--_size);
127
144
  border-radius: 100%;
128
145
  display: flex;
129
146
  align-items: center;
130
147
  justify-content: center;
131
148
  color: var(--display-chip-text-colour, black);
132
149
  z-index: 2;
133
- font-size: calc(var(--_chip-size) * var(--_font-size-adjust));
150
+ font-size: calc(var(--_size) * var(--_font-size-adjust));
134
151
  line-height: 1;
135
152
  letter-spacing: -0.05rem;
153
+ white-space: nowrap;
136
154
  user-select: none;
137
155
 
138
- &.length-2 {
156
+ &[data-length="2"] {
139
157
  --_font-size-adjust: 0.6;
140
158
  }
141
159
 
142
- &.length-3 {
160
+ &[data-length="3"] {
143
161
  --_font-size-adjust: 0.5;
144
162
  }
145
163
  }
146
164
 
147
- & > *:not(.chip-icon, .chip-label) {
165
+ & > *:not(.display-chip-icon, .display-chip-label, .sr-only) {
148
166
  mask-image: radial-gradient(
149
167
  var(--_mask-diameter) var(--_mask-diameter) at var(--_position-x) var(--_position-y),
150
168
  transparent calc(50% - 0.5px),
@@ -14,6 +14,7 @@ interface ChipStoryArgs {
14
14
  icon: string;
15
15
  label: string;
16
16
  status: "offline" | "online" | "idle" | "dnd";
17
+ statusLabel: string;
17
18
  useSlot: boolean;
18
19
  slotContent: string;
19
20
  styleClassPassthrough: string[];
@@ -87,11 +88,18 @@ export default {
87
88
  status: {
88
89
  control: { type: "select" },
89
90
  options: ["offline", "online", "idle", "dnd"],
90
- description: "Status color for the chip",
91
+ description: "Status colour for the chip dot",
91
92
  table: {
92
93
  category: "Appearance",
93
94
  },
94
95
  },
96
+ statusLabel: {
97
+ control: { type: "text" },
98
+ description: "Screen-reader text for the status (e.g. 'Online'), so it isn't conveyed by colour alone",
99
+ table: {
100
+ category: "Accessibility",
101
+ },
102
+ },
95
103
  // Slot Configuration
96
104
  useSlot: {
97
105
  control: { type: "boolean" },
@@ -129,6 +137,7 @@ export default {
129
137
  icon: "",
130
138
  label: "",
131
139
  status: "offline",
140
+ statusLabel: "",
132
141
  useSlot: true,
133
142
  slotContent: "SRC",
134
143
  styleClassPassthrough: [],
@@ -149,7 +158,7 @@ const Template: StoryFn<ChipStoryArgs> = (args) => ({
149
158
  })
150
159
  );
151
160
 
152
- const classes = computed(() => [...(args.styleClassPassthrough || []), args.status]);
161
+ const classes = computed(() => args.styleClassPassthrough || []);
153
162
 
154
163
  return { args, chipConfig, classes };
155
164
  },
@@ -159,6 +168,8 @@ const Template: StoryFn<ChipStoryArgs> = (args) => ({
159
168
  :tag="args.tag"
160
169
  :shape="args.shape"
161
170
  :config="chipConfig"
171
+ :status="args.status"
172
+ :status-label="args.statusLabel"
162
173
  :style-class-passthrough="classes"
163
174
  >
164
175
  <template v-if="args.useSlot" #default>
@@ -290,14 +301,14 @@ const MultipleChipsTemplate: StoryFn<ChipStoryArgs> = (args) => ({
290
301
  <div style="display: flex; gap: 40px; align-items: center; justify-content: center; height: 100vh; flex-wrap: wrap;">
291
302
  <StorybookComponent
292
303
  :config="{ size: '12px', maskWidth: '4px', offset: '0px', angle: '45deg', label: '5' }"
293
- :style-class-passthrough="['online']"
304
+ status="online"
294
305
  >
295
306
  <div style="width: 50px; height: 50px; background: #64748b; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #f8fafc; font-weight: 600; font-size: 1.3rem; font-family: sans-serif;">SRC</div>
296
307
  </StorybookComponent>
297
308
 
298
309
  <StorybookComponent
299
310
  :config="{ size: '10px', maskWidth: '3px', offset: '2px', angle: '315deg', icon: 'mdi:pause' }"
300
- :style-class-passthrough="['idle']"
311
+ status="idle"
301
312
  >
302
313
  <div style="width: 50px; height: 50px; background: #64748b; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #f8fafc; font-weight: 600; font-size: 1.3rem; font-family: sans-serif;">SRC</div>
303
314
  </StorybookComponent>
@@ -305,14 +316,14 @@ const MultipleChipsTemplate: StoryFn<ChipStoryArgs> = (args) => ({
305
316
  <StorybookComponent
306
317
  shape="square"
307
318
  :config="{ size: '14px', maskWidth: '2px', offset: '-5px', angle: '135deg', label: 'DND' }"
308
- :style-class-passthrough="['dnd']"
319
+ status="dnd"
309
320
  >
310
321
  <div style="width: 50px; height: 50px; background: #64748b; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #f8fafc; font-weight: 600; font-size: 1.3rem; font-family: sans-serif;">SRC</div>
311
322
  </StorybookComponent>
312
323
 
313
324
  <StorybookComponent
314
325
  :config="{ size: '16px', maskWidth: '6px', offset: '8px', angle: '90deg' }"
315
- :style-class-passthrough="['offline']"
326
+ status="offline"
316
327
  >
317
328
  <div style="width: 50px; height: 50px; background: #64748b; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #f8fafc; font-weight: 600; font-size: 1.3rem; font-family: sans-serif;">SRC</div>
318
329
  </StorybookComponent>
@@ -328,3 +339,89 @@ MultipleChips.parameters = {
328
339
  },
329
340
  },
330
341
  };
342
+
343
+ const hostStyle = (shape: "circle" | "square", size = 50) =>
344
+ `width: ${size}px; height: ${size}px; background: #64748b; border-radius: ${shape === "circle" ? "50%" : "4px"}; display: flex; align-items: center; justify-content: center; color: #f8fafc; font: 600 1.3rem sans-serif;`;
345
+
346
+ const stressChips: Array<{
347
+ key: string;
348
+ caption: string;
349
+ shape?: "circle" | "square";
350
+ status?: ChipStoryArgs["status"];
351
+ config?: DisplayChipConfig;
352
+ hostSize?: number;
353
+ hostText?: string;
354
+ brokenImage?: boolean;
355
+ noSlot?: boolean;
356
+ dir?: "rtl";
357
+ }> = [
358
+ { key: "family", caption: "ZWJ family emoji (one grapheme)", status: "online", config: { size: "18px", angle: "45deg", label: "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦" } },
359
+ { key: "flags", caption: "4 flags, truncated to 3", status: "idle", config: { size: "20px", angle: "45deg", label: "πŸ‡¬πŸ‡§πŸ‡ΊπŸ‡ΈπŸ‡«πŸ‡·πŸ‡©πŸ‡ͺ" } },
360
+ { key: "count", caption: '"999+", truncated to "999"', status: "dnd", config: { size: "18px", angle: "45deg", label: "999+" } },
361
+ { key: "wide", caption: '"WWW" (widest glyphs)', status: "online", config: { size: "16px", angle: "45deg", label: "WWW" } },
362
+ { key: "html", caption: "HTML-like label", status: "dnd", config: { size: "18px", angle: "45deg", label: "<b>" } },
363
+ { key: "rtl", caption: "Arabic label in an RTL wrapper", status: "online", dir: "rtl", config: { size: "18px", angle: "45deg", label: "Ω…Ψ±Ψ­Ψ¨Ψ§" } },
364
+ { key: "whitespace", caption: "Whitespace-only label (no label)", status: "idle", config: { size: "14px", angle: "45deg", label: " " } },
365
+ { key: "partial", caption: "Config with only a label (geometry from defaults)", status: "online", config: { label: "5" } },
366
+ { key: "empty-config", caption: "Empty config object", status: "dnd", config: {} },
367
+ { key: "zero", caption: "size 0px (no dot)", status: "online", config: { size: "0px", angle: "45deg" } },
368
+ { key: "negative", caption: "size -10px, mask -4px (clamped to 0)", status: "online", config: { size: "-10px", maskWidth: "-4px", angle: "45deg" } },
369
+ { key: "huge", caption: "size 64px on a 50px host", status: "idle", config: { size: "64px", angle: "45deg", label: "9" } },
370
+ { key: "angles", caption: "angle -45deg, offset 40px", status: "online", config: { size: "12px", angle: "-45deg", offset: "40px" } },
371
+ { key: "wrap", caption: "angle 720deg, offset -30px (square)", shape: "square", status: "dnd", config: { size: "12px", angle: "720deg", offset: "-30px" } },
372
+ { key: "invalid", caption: "Invalid units: size 'abc', angle '45'", status: "online", config: { size: "abc", angle: "45" } },
373
+ { key: "broken-icon", caption: "Broken icon name", status: "online", config: { size: "16px", angle: "45deg", icon: "not-a-real-set:missing" } },
374
+ { key: "tiny-host", caption: "8px host", status: "online", hostSize: 8, config: { size: "6px", maskWidth: "1px", angle: "45deg" } },
375
+ { key: "big-host", caption: "200px host, long host text", status: "online", hostSize: 200, hostText: "Donaudampfschifffahrtsgesellschaft", config: { size: "24px", angle: "135deg", label: "!" } },
376
+ { key: "broken-image", caption: "Broken image as host", status: "idle", brokenImage: true, config: { size: "12px", angle: "45deg" } },
377
+ { key: "no-slot", caption: "No slot content", status: "online", noSlot: true, config: { size: "12px", angle: "45deg" } },
378
+ ];
379
+
380
+ export const StressTest: StoryFn<ChipStoryArgs> = () => ({
381
+ components: { StorybookComponent },
382
+ setup() {
383
+ return { stressChips, hostStyle };
384
+ },
385
+ template: `
386
+ <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 4.8rem 2.4rem; padding: 4rem 2rem; font: 1.2rem sans-serif;">
387
+ <figure
388
+ v-for="chip in stressChips"
389
+ :key="chip.key"
390
+ :dir="chip.dir"
391
+ style="margin: 0; display: grid; justify-items: center; align-content: start; gap: 1.6rem; min-inline-size: 0;"
392
+ >
393
+ <StorybookComponent :shape="chip.shape ?? 'circle'" :status="chip.status" :config="chip.config" status-label="Status">
394
+ <template v-if="!chip.noSlot" #default>
395
+ <img
396
+ v-if="chip.brokenImage"
397
+ src="/does-not-exist.jpg"
398
+ alt="Broken avatar"
399
+ width="50"
400
+ height="50"
401
+ :style="hostStyle(chip.shape ?? 'circle')"
402
+ />
403
+ <div v-else :style="hostStyle(chip.shape ?? 'circle', chip.hostSize)">
404
+ <span style="overflow-wrap: anywhere; text-align: center;">{{ chip.hostText ?? (chip.hostSize && chip.hostSize < 20 ? "" : "SRC") }}</span>
405
+ </div>
406
+ </template>
407
+ </StorybookComponent>
408
+ <figcaption style="text-align: center; overflow-wrap: anywhere;">{{ chip.caption }}</figcaption>
409
+ </figure>
410
+ </div>
411
+ `,
412
+ });
413
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
414
+ StressTest.parameters = {
415
+ // Fixed showcase: hardcoded values, so Controls would do nothing here.
416
+ controls: { disable: true },
417
+ docs: {
418
+ description: {
419
+ story:
420
+ "Hostile config and labels: a ZWJ family emoji and a run of flags (must not split into broken characters), a label over the 3-character limit, " +
421
+ "the widest Latin glyphs, HTML-like and right-to-left text, a whitespace-only label, a config with missing or empty geometry, zero, negative, " +
422
+ "oversized and invalid sizes, out-of-range angles and offsets, a broken icon name, tiny and huge hosts, a broken image and no slot at all. " +
423
+ "Check that every label is whole and centred on its dot, missing geometry falls back to the default 12px dot at 90deg, negative and invalid sizes show no dot " +
424
+ "rather than a broken one, and the cutout ring always sits under the dot.",
425
+ },
426
+ },
427
+ };