srcdev-nuxt-components 9.6.0 → 9.6.2

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 (121) hide show
  1. package/.claude/skills/component-compliance-checklist.md +22 -3
  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 +27 -10
  8. package/.claude/skills/components/display-chip.md +38 -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/google-reviews.md +2 -2
  14. package/.claude/skills/components/profile-section.md +59 -21
  15. package/.claude/skills/components/responsive-header.md +11 -1
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/site-header.md +2 -2
  18. package/.claude/skills/components/social-icons-list.md +2 -0
  19. package/.claude/skills/components/tabbed-content.md +300 -0
  20. package/.claude/skills/composable-anchor-scroll.md +16 -15
  21. package/.claude/skills/index.md +8 -7
  22. package/.claude/skills/storybook-add-story.md +55 -0
  23. package/.claude/skills/theming-component-token-pattern.md +48 -0
  24. package/.claude/skills/theming-status-tokens.md +60 -0
  25. package/.vscode/srcdev-component-display-avatar.code-snippets +9 -1
  26. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  27. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  28. package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
  29. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  30. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  31. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  32. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  33. package/app/assets/styles/setup/03.theming/index.css +1 -0
  34. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  35. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  36. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  37. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  38. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  39. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  40. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  41. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  42. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  43. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  44. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  45. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  46. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  47. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  48. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  49. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  50. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  51. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  52. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  53. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  54. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  55. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  56. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  57. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  58. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +33 -15
  59. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +81 -36
  60. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +108 -6
  61. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +104 -13
  62. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +4 -3
  63. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +57 -19
  64. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  65. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  66. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  67. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  68. package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
  69. package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
  70. package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
  71. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
  72. package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
  73. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  74. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  75. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  76. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  77. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  78. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  79. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  80. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  81. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  82. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
  83. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
  84. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  85. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  86. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  87. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  88. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  89. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  90. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  91. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
  92. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
  93. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  94. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  95. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  96. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  97. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  98. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  99. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  100. package/app/composables/useTabs.ts +148 -206
  101. package/app/types/components/display-chip.d.ts +8 -4
  102. package/app/types/components/display-toast.d.ts +2 -0
  103. package/app/types/components/index.ts +0 -1
  104. package/app/types/components/select-menu.d.ts +2 -0
  105. package/app/utils/pausableTimeout.ts +41 -0
  106. package/nuxt.config.ts +1 -1
  107. package/package.json +1 -1
  108. package/.claude/skills/components/tab-navigation.md +0 -288
  109. package/.claude/skills/components/tabs-core.md +0 -187
  110. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  111. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  112. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  113. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  114. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  115. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  116. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  117. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  118. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  119. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  120. package/app/composables/useNavCollapse.ts +0 -86
  121. package/app/types/components/nav-item.d.ts +0 -11
@@ -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
+ };
@@ -37,7 +37,7 @@ describe("DisplayChip", () => {
37
37
 
38
38
  it("renders correct HTML structure (square + styleClassPassthrough)", async () => {
39
39
  const wrapper = await mountSuspended(DisplayChip, {
40
- props: { shape: "square", styleClassPassthrough: ["online"] },
40
+ props: { shape: "square", status: "online", styleClassPassthrough: ["featured"] },
41
41
  });
42
42
  expect(wrapper.html()).toMatchSnapshot();
43
43
  });
@@ -63,15 +63,41 @@ describe("DisplayChip", () => {
63
63
 
64
64
  // ─── Shape ────────────────────────────────────────────────────────────────
65
65
 
66
- it("applies circle class by default", async () => {
66
+ it("sets data-shape='circle' by default", async () => {
67
67
  const wrapper = await mountSuspended(DisplayChip);
68
- expect(wrapper.classes()).toContain("circle");
68
+ expect(wrapper.attributes("data-shape")).toBe("circle");
69
69
  });
70
70
 
71
- it("applies square class when shape='square'", async () => {
71
+ it("sets data-shape='square' when shape='square'", async () => {
72
72
  const wrapper = await mountSuspended(DisplayChip, { props: { shape: "square" } });
73
- expect(wrapper.classes()).toContain("square");
74
- expect(wrapper.classes()).not.toContain("circle");
73
+ expect(wrapper.attributes("data-shape")).toBe("square");
74
+ });
75
+
76
+ it("does not put shape or status on the root as classes", async () => {
77
+ const wrapper = await mountSuspended(DisplayChip, { props: { shape: "square", status: "online" } });
78
+ expect(wrapper.classes()).toEqual(["display-chip"]);
79
+ });
80
+
81
+ // ─── Status ───────────────────────────────────────────────────────────────
82
+
83
+ it("sets data-status='offline' by default", async () => {
84
+ const wrapper = await mountSuspended(DisplayChip);
85
+ expect(wrapper.attributes("data-status")).toBe("offline");
86
+ });
87
+
88
+ it.each(["online", "idle", "dnd"] as const)("sets data-status='%s' from the status prop", async (status) => {
89
+ const wrapper = await mountSuspended(DisplayChip, { props: { status } });
90
+ expect(wrapper.attributes("data-status")).toBe(status);
91
+ });
92
+
93
+ it("renders statusLabel as screen-reader text", async () => {
94
+ const wrapper = await mountSuspended(DisplayChip, { props: { status: "online", statusLabel: "Online" } });
95
+ expect(wrapper.find(".sr-only").text()).toBe("Online");
96
+ });
97
+
98
+ it("renders no screen-reader text without statusLabel", async () => {
99
+ const wrapper = await mountSuspended(DisplayChip);
100
+ expect(wrapper.find(".sr-only").exists()).toBe(false);
75
101
  });
76
102
 
77
103
  // ─── CSS custom properties ────────────────────────────────────────────────
@@ -89,39 +115,81 @@ describe("DisplayChip", () => {
89
115
  expect(style.getPropertyValue("--_chip-angle")).toBe("45deg");
90
116
  });
91
117
 
92
- it("sets default CSS custom properties when no config is provided", async () => {
118
+ it("falls back to the public tokens when no config is provided", async () => {
93
119
  const wrapper = await mountSuspended(DisplayChip);
94
120
  const style = (wrapper.element as HTMLElement).style;
95
- expect(style.getPropertyValue("--_chip-size")).toBe("12px");
96
- expect(style.getPropertyValue("--_chip-mask-width")).toBe("4px");
97
- expect(style.getPropertyValue("--_chip-offset")).toBe("0px");
98
- expect(style.getPropertyValue("--_chip-angle")).toBe("90deg");
121
+ expect(style.getPropertyValue("--_chip-size")).toBe("var(--display-chip-size, 1.2rem)");
122
+ expect(style.getPropertyValue("--_chip-mask-width")).toBe("var(--display-chip-mask-width, 0.4rem)");
123
+ expect(style.getPropertyValue("--_chip-offset")).toBe("var(--display-chip-offset, 0rem)");
124
+ expect(style.getPropertyValue("--_chip-angle")).toBe("var(--display-chip-angle, 90deg)");
125
+ });
126
+
127
+ it("falls back per field when config is partial", async () => {
128
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label: "5", angle: "45deg" } } });
129
+ const style = (wrapper.element as HTMLElement).style;
130
+ expect(style.getPropertyValue("--_chip-size")).toBe("var(--display-chip-size, 1.2rem)");
131
+ expect(style.getPropertyValue("--_chip-angle")).toBe("45deg");
99
132
  });
100
133
 
101
134
  // ─── Label ────────────────────────────────────────────────────────────────
102
135
 
103
- it("does not render .chip-label when config has no label", async () => {
136
+ it("does not render .display-chip-label when config has no label", async () => {
104
137
  const wrapper = await mountSuspended(DisplayChip);
105
- expect(wrapper.find(".chip-label").exists()).toBe(false);
138
+ expect(wrapper.find(".display-chip-label").exists()).toBe(false);
106
139
  });
107
140
 
108
- it("renders .chip-label when config.label is set", async () => {
141
+ it("renders .display-chip-label when config.label is set", async () => {
109
142
  const wrapper = await mountSuspended(DisplayChip, {
110
143
  props: { config: { size: "12px", maskWidth: "4px", offset: "0px", angle: "90deg", label: "5" } },
111
144
  });
112
- expect(wrapper.find(".chip-label").exists()).toBe(true);
113
- expect(wrapper.find(".chip-label").text()).toBe("5");
145
+ expect(wrapper.find(".display-chip-label").exists()).toBe(true);
146
+ expect(wrapper.find(".display-chip-label").text()).toBe("5");
114
147
  });
115
148
 
116
149
  it.each([
117
- ["A", "length-1"],
118
- ["+2", "length-2"],
119
- ["DND", "length-3"],
120
- ])("applies length class for label '%s'", async (label, expectedClass) => {
121
- const wrapper = await mountSuspended(DisplayChip, {
122
- props: { config: { size: "12px", maskWidth: "4px", offset: "0px", angle: "90deg", label } },
123
- });
124
- expect(wrapper.find(".chip-label").classes()).toContain(expectedClass);
150
+ ["A", "1"],
151
+ ["+2", "2"],
152
+ ["DND", "3"],
153
+ ["👨‍👩‍👧‍👦", "1"],
154
+ ["999+", "3"],
155
+ ])("sets data-length for label '%s'", async (label, expected) => {
156
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
157
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label } } });
158
+ expect(wrapper.find(".display-chip-label").attributes("data-length")).toBe(expected);
159
+ warnSpy.mockRestore();
160
+ });
161
+
162
+ it("keeps a multi-codepoint emoji whole instead of splitting it", async () => {
163
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
164
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label: "👨‍👩‍👧‍👦" } } });
165
+ expect(wrapper.find(".display-chip-label").text()).toBe("👨‍👩‍👧‍👦");
166
+ expect(warnSpy).not.toHaveBeenCalled();
167
+ warnSpy.mockRestore();
168
+ });
169
+
170
+ it("truncates by grapheme, not UTF-16 code unit", async () => {
171
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
172
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label: "🇬🇧🇺🇸🇫🇷🇩🇪" } } });
173
+ expect(wrapper.find(".display-chip-label").text()).toBe("🇬🇧🇺🇸🇫🇷");
174
+ warnSpy.mockRestore();
175
+ });
176
+
177
+ it("does not render a label for a whitespace-only label", async () => {
178
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label: " " } } });
179
+ expect(wrapper.find(".display-chip-label").exists()).toBe(false);
180
+ });
181
+
182
+ it("renders an HTML-like label as text", async () => {
183
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label: "<b>" } } });
184
+ expect(wrapper.find(".display-chip-label b").exists()).toBe(false);
185
+ expect(wrapper.find(".display-chip-label").text()).toBe("<b>");
186
+ });
187
+
188
+ it("updates the label when config changes after mount", async () => {
189
+ const wrapper = await mountSuspended(DisplayChip, { props: { config: { label: "1" } } });
190
+ await wrapper.setProps({ config: { label: "42" } });
191
+ expect(wrapper.find(".display-chip-label").text()).toBe("42");
192
+ expect(wrapper.find(".display-chip-label").attributes("data-length")).toBe("2");
125
193
  });
126
194
 
127
195
  it("truncates label to 3 characters when label exceeds maximum length", async () => {
@@ -129,7 +197,7 @@ describe("DisplayChip", () => {
129
197
  const wrapper = await mountSuspended(DisplayChip, {
130
198
  props: { config: { size: "12px", maskWidth: "4px", offset: "0px", angle: "90deg", label: "ABCD" } },
131
199
  });
132
- expect(wrapper.find(".chip-label").text()).toBe("ABC");
200
+ expect(wrapper.find(".display-chip-label").text()).toBe("ABC");
133
201
  warnSpy.mockRestore();
134
202
  });
135
203
 
@@ -144,16 +212,17 @@ describe("DisplayChip", () => {
144
212
 
145
213
  // ─── Icon ─────────────────────────────────────────────────────────────────
146
214
 
147
- it("does not render .chip-icon when config has no icon", async () => {
215
+ it("does not render .display-chip-icon when config has no icon", async () => {
148
216
  const wrapper = await mountSuspended(DisplayChip);
149
- expect(wrapper.find(".chip-icon").exists()).toBe(false);
217
+ expect(wrapper.find(".display-chip-icon").exists()).toBe(false);
150
218
  });
151
219
 
152
- it("renders .chip-icon when config.icon is set", async () => {
220
+ it("renders .display-chip-icon when config.icon is set", async () => {
153
221
  const wrapper = await mountSuspended(DisplayChip, {
154
222
  props: { config: { size: "12px", maskWidth: "4px", offset: "0px", angle: "90deg", icon: "mdi:check" } },
155
223
  });
156
- expect(wrapper.find(".chip-icon").exists()).toBe(true);
224
+ expect(wrapper.find(".display-chip-icon").exists()).toBe(true);
225
+ expect(wrapper.find(".display-chip-icon").attributes("aria-hidden")).toBe("true");
157
226
  });
158
227
 
159
228
  // ─── Slot ─────────────────────────────────────────────────────────────────
@@ -1,12 +1,16 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`DisplayChip > renders correct HTML structure (default) 1`] = `"<span class="display-chip circle" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><!--v-if--><!--v-if--></span>"`;
3
+ exports[`DisplayChip > renders correct HTML structure (default) 1`] = `"<span class="display-chip" data-shape="circle" data-status="offline" style="--_chip-size: var(--display-chip-size, 1.2rem); --_chip-mask-width: var(--display-chip-mask-width, 0.4rem); --_chip-offset: var(--display-chip-offset, 0rem); --_chip-angle: var(--display-chip-angle, 90deg);"><!--v-if--><!--v-if--><!--v-if--></span>"`;
4
4
 
5
- exports[`DisplayChip > renders correct HTML structure (square + styleClassPassthrough) 1`] = `"<span class="display-chip square online" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><!--v-if--><!--v-if--></span>"`;
5
+ exports[`DisplayChip > renders correct HTML structure (square + styleClassPassthrough) 1`] = `"<span class="display-chip featured" data-shape="square" data-status="online" style="--_chip-size: var(--display-chip-size, 1.2rem); --_chip-mask-width: var(--display-chip-mask-width, 0.4rem); --_chip-offset: var(--display-chip-offset, 0rem); --_chip-angle: var(--display-chip-angle, 90deg);"><!--v-if--><!--v-if--><!--v-if--></span>"`;
6
6
 
7
7
  exports[`DisplayChip > renders correct HTML structure (with icon) 1`] = `
8
- "<span class="display-chip circle" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><span class="iconify i-mdi:check chip-icon" aria-hidden="true"></span>
8
+ "<span class="display-chip" data-shape="circle" data-status="offline" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><span class="iconify i-mdi:check display-chip-icon" aria-hidden="true"></span>
9
+ <!--v-if-->
9
10
  <!--v-if--></span>"
10
11
  `;
11
12
 
12
- exports[`DisplayChip > renders correct HTML structure (with label) 1`] = `"<span class="display-chip circle" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><!--v-if--><span class="chip-label length-1">5</span></span>"`;
13
+ exports[`DisplayChip > renders correct HTML structure (with label) 1`] = `
14
+ "<span class="display-chip" data-shape="circle" data-status="offline" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><!--v-if--><span class="display-chip-label" data-length="1">5</span>
15
+ <!--v-if--></span>"
16
+ `;