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
@@ -22,7 +22,20 @@ global token so it matches the rest of the design system out of the box.
22
22
  | `--select-menu-trigger-text-color` | `var(--theme-text)` | Trigger label text colour |
23
23
  | `--select-menu-trigger-font-size` | `1.6rem` | Trigger label font size |
24
24
  | `--select-menu-trigger-outline-width` | `0.2rem` | Focus-visible outline width (trigger and items) |
25
- | `--select-menu-trigger-icon-size` | `2rem` | Selected option's icon size in the trigger |
25
+ | `--select-menu-trigger-icon-size` | `2rem` | Leading icon size in the trigger (selected option's icon or `triggerIcon`) |
26
+ | `--select-menu-trigger-icon-color` | `currentcolor` | Leading icon colour (monochrome icon sets only) |
27
+ | `--select-menu-trigger-count-surface` | `var(--theme-surface-subtle)` | Count badge background (`indicator="count"`) |
28
+ | `--select-menu-trigger-count-text-color` | `var(--theme-text)` | Count badge text colour |
29
+ | `--select-menu-trigger-count-font-size` | `1.4rem` | Count badge font size |
30
+ | `--select-menu-trigger-count-font-weight` | `600` | Count badge font weight |
31
+ | `--select-menu-trigger-count-min-width` | `2.4rem` | Count badge minimum width, so 1 and 12 read as the same shape |
32
+ | `--select-menu-trigger-count-padding-block` | `0.2rem` | Count badge vertical padding |
33
+ | `--select-menu-trigger-count-padding-inline` | `0.6rem` | Count badge horizontal padding |
34
+ | `--select-menu-trigger-count-border-radius` | `0.4rem` | Count badge corner rounding (`999rem` for a pill) |
35
+ | `--select-menu-trigger-count-margin-inline` | `1.2rem 0` | Extra space around the count badge, on top of `--select-menu-trigger-gap` (start, end) |
36
+ | `--select-menu-trigger-dot-size` | `0.8rem` | Status dot diameter (`indicator="dot"`) |
37
+ | `--select-menu-trigger-dot-margin-inline` | `1.2rem 0` | Extra space around the status dot, on top of `--select-menu-trigger-gap` (start, end) |
38
+ | `--select-menu-trigger-dot-color` | `var(--theme-accent)` | Status dot colour when the selected option has no `dotColor` |
26
39
  | `--select-menu-trigger-chevron-size` | `1.6rem` | Chevron icon size |
27
40
 
28
41
  ### Menu popover
@@ -35,10 +48,11 @@ global token so it matches the rest of the design system out of the box.
35
48
  | `--select-menu-popover-surface` | `var(--theme-input-surface)` | Popover background |
36
49
  | `--select-menu-popover-border-radius` | `0.5rem` | Popover corner rounding |
37
50
  | `--select-menu-popover-min-width` | `18rem` | Minimum popover width |
51
+ | `--select-menu-popover-max-width` | `calc(100vw - 3.2rem)` | Maximum popover width; longer option labels wrap (see `--select-menu-item-label-line-clamp`) |
38
52
  | `--select-menu-popover-max-height` | `32rem` | Maximum popover height before scrolling |
39
53
  | `--select-menu-popover-shadow` | `0 0.4rem 1.6rem rgb(0 0 0 / 12%)` | Popover drop shadow |
40
54
  | `--select-menu-popover-z-index` | `999999` | Stacking order in browsers without CSS anchor positioning (see Notes). Ignored where the popover renders in the top layer |
41
- | `--select-menu-transition-duration` | `200ms` | Open/close fade and chevron-rotate duration |
55
+ | `--select-menu-transition-duration` | `200ms` | Open/close fade and chevron-flip duration |
42
56
 
43
57
  ### Options
44
58
 
@@ -62,6 +76,14 @@ global token so it matches the rest of the design system out of the box.
62
76
  | `--select-menu-item-check-size` | `1.6rem` | Checkmark icon box size |
63
77
  | `--select-menu-item-check-color` | `currentcolor` | Checkmark icon colour |
64
78
  | `--select-menu-item-icon-size` | `1.8rem` | Per-option icon size |
79
+ | `--select-menu-item-dot-size` | `0.8rem` | Per-option status dot size (options with `dotColor` and no `icon`) |
80
+ | `--select-menu-item-label-line-clamp` | `none` | Lines an option label may take before it's cut with an ellipsis. `none` wraps the full label, `1` is single-line ellipsis |
81
+
82
+ > Changed 2026-10-08: long values used to push things off screen. The root now caps at its
83
+ > container's width (`max-inline-size: 100%`), so a long trigger label truncates with its ellipsis
84
+ > instead of overflowing. The popover caps at `--select-menu-popover-max-width`, and option labels
85
+ > wrap (they were `nowrap`, so a long one made the menu wider than the viewport). Set
86
+ > `--select-menu-item-label-line-clamp: 1` for the old single-line look.
65
87
 
66
88
  ---
67
89
 
@@ -70,7 +92,7 @@ global token so it matches the rest of the design system out of the box.
70
92
  | Hook | Where | Meaning |
71
93
  |---|---|---|
72
94
  | `.normal` / `.underlined` | `.select-menu` root | The `inputVariant` prop. `underlined` removes the trigger's top and side borders and its corner radius, matching `InputSelect`'s underlined variant. |
73
- | `.select-menu-trigger[aria-expanded="true"]` | trigger | Menu open (rotates the chevron). Works with and without the Popover API. |
95
+ | `.select-menu-trigger[aria-expanded="true"]` | trigger | Menu open (flips the chevron vertically). Works with and without the Popover API. |
74
96
  | `.select-menu-popover:popover-open` | popover | Menu open, Popover API browsers. |
75
97
  | `.select-menu-popover-open` | popover | Menu open, browsers without the Popover API (Safari 16 and older). Style both open hooks in **separate rules**: a selector list containing `:popover-open` is dropped whole where it's unsupported. |
76
98
  | `[data-placement="top"]` | popover | Flipped above the trigger, browsers without CSS anchor positioning only. |
@@ -142,6 +164,40 @@ the three common shapes:
142
164
  | Icon-only (e.g. flag switcher in a tight header) | `:show-label="false" :show-chevron="false"` |
143
165
  | Text + chevron (e.g. a category filter with no per-option icons) | `:show-icon="false"` (default `showLabel`/`showChevron`) |
144
166
  | Icon + text + chevron (full select) | Defaults — no overrides needed |
167
+ | Filter: category icon + text + count/dot + chevron | `trigger-icon="lucide:list" indicator="count"` (or `"dot"`) |
168
+
169
+ ### Stable trigger width
170
+
171
+ In single-select the trigger text changes with the selection, so by default the trigger resizes
172
+ ("Active" to "Archived"). Set `reserve-label-width` to size it to the longest option label (or
173
+ placeholder) instead: hidden copies of each label (`.select-menu-trigger-label-sizer`) share a grid
174
+ cell with the visible text (`.select-menu-trigger-label-text`), and the root label span gets
175
+ `.select-menu-trigger-label-reserved`. No effect with `multiple`. If only some options have an
176
+ `icon`, the trigger can still change width when the icon appears; give them all an icon, or use
177
+ `triggerIcon`.
178
+
179
+ ### Filter icon and indicator
180
+
181
+ `triggerIcon` sets a fixed leading icon (a category icon for a filter). It shows in both modes;
182
+ in single-select a selected option's own icon replaces it. `showIcon` hides either.
183
+
184
+ `indicator` adds a marker after the label, visible only while something is selected. With nothing
185
+ selected it stays in the layout but hidden (`.select-menu-trigger-indicator-empty`, `visibility:
186
+ hidden`), so the trigger doesn't change width as the filter is set and cleared. The count badge's
187
+ `--select-menu-trigger-count-min-width` fits two digits, so it only grows at 100+:
188
+
189
+ - `count`: a badge with the number selected. Meant for `multiple`.
190
+ - `dot`: a status dot. Meant for single-select, to flag that the filter is active.
191
+
192
+ Give options a `dotColor` (any CSS colour) to colour the dot per status: the trigger dot takes the
193
+ selected option's colour, and options with a `dotColor` and no `icon` show the same dot in the
194
+ list. The per-option colour beats `--select-menu-trigger-dot-color`, which stays the fallback
195
+ (and the only colour in `multiple` mode, where there's no single selected option). Pass a global
196
+ status token, e.g. `dotColor: "var(--status-success)"`, so the colours stay in step with every other
197
+ status indicator (see `.claude/skills/theming-status-tokens.md`).
198
+
199
+ The indicator is `aria-hidden`; instead the trigger's accessible name becomes
200
+ `"<label>, <selectedCountLabel>"` (default `"{count} selected"`, override it for translation).
145
201
 
146
202
  ---
147
203
 
@@ -156,9 +212,9 @@ toggles it in the array without closing the popover — so several can be picked
156
212
  cycle. By default the trigger text stays fixed on `placeholder`/`label` as a static category tag
157
213
  (e.g. `"Services required"`) regardless of how many options are checked, since a comma-joined list
158
214
  of checked options would grow unpredictably long. Set `showSelectionInTrigger` to opt into that
159
- comma-joined list instead (falling back to `placeholder`/`label` when nothing is checked). The
160
- trigger icon is not shown in multi-select mode (`showIcon` has no effect), since there's no single
161
- selected option to represent.
215
+ comma-joined list instead (falling back to `placeholder`/`label` when nothing is checked). No
216
+ option icon is shown in the trigger in multi-select mode, since there's no single selected option
217
+ to represent; a fixed `triggerIcon` still shows.
162
218
 
163
219
  ```vue
164
220
  <script setup lang="ts">
@@ -202,6 +258,8 @@ story.
202
258
  `contain` becomes its containing block and can misplace it.
203
259
  - **Popover left-aligns with the trigger** by default (`left: anchor(left)`), unlike `ActionMenu`
204
260
  which right-aligns — matches native `<select>` dropdown behaviour. Flips above the trigger near
205
- the bottom of the viewport (`position-try-fallbacks: flip-block`).
206
- - **Chevron rotation** is driven by the trigger's `aria-expanded`, which tracks the open state in
261
+ the bottom of the viewport, and right-aligns when there's no room to the right
262
+ (`position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`). The inline flip needs
263
+ CSS anchor positioning; the JS fallback only flips above/below.
264
+ - **Chevron flip** (`scaleY(-1)`, was `rotate(180deg)` until 2026-10-07) is driven by the trigger's `aria-expanded`, which tracks the open state in
207
265
  every browser.
@@ -11,24 +11,45 @@
11
11
  popovertargetaction="toggle"
12
12
  type="button"
13
13
  class="select-menu-trigger"
14
- :aria-label="label"
14
+ :aria-label="triggerAriaLabel"
15
15
  aria-haspopup="listbox"
16
16
  :aria-expanded="isOpen"
17
17
  @click="handleTriggerClick"
18
18
  >
19
- <Icon
20
- v-if="showIcon && !multiple && selectedOption?.icon"
21
- :name="selectedOption.icon"
22
- class="select-menu-trigger-icon"
19
+ <Icon v-if="leadingIcon" :name="leadingIcon" class="select-menu-trigger-icon" aria-hidden="true" />
20
+ <span
21
+ v-if="showLabel"
22
+ class="select-menu-trigger-label"
23
+ :class="{ 'select-menu-trigger-label-reserved': reservedLabels.length }"
24
+ >
25
+ <template v-if="reservedLabels.length">
26
+ <span class="select-menu-trigger-label-text">{{ triggerLabelText }}</span>
27
+ <span
28
+ v-for="(text, index) in reservedLabels"
29
+ :key="index"
30
+ class="select-menu-trigger-label-sizer"
31
+ aria-hidden="true"
32
+ >{{ text }}</span
33
+ >
34
+ </template>
35
+ <template v-else>{{ triggerLabelText }}</template>
36
+ </span>
37
+ <span
38
+ v-if="indicator === 'count'"
39
+ class="select-menu-trigger-count"
40
+ :class="{ 'select-menu-trigger-indicator-empty': !selectedCount }"
23
41
  aria-hidden="true"
24
- />
25
- <span v-if="showLabel" class="select-menu-trigger-label">{{ triggerLabelText }}</span>
26
- <Icon
27
- v-if="showChevron"
28
- name="lucide:chevron-down"
29
- class="select-menu-trigger-chevron"
42
+ >
43
+ {{ selectedCount || "" }}
44
+ </span>
45
+ <span
46
+ v-else-if="indicator === 'dot'"
47
+ class="select-menu-trigger-dot"
48
+ :class="{ 'select-menu-trigger-indicator-empty': !selectedCount }"
49
+ :style="selectedOption?.dotColor ? { '--_dot-color': selectedOption.dotColor } : undefined"
30
50
  aria-hidden="true"
31
- />
51
+ ></span>
52
+ <Icon v-if="showChevron" name="lucide:chevron-down" class="select-menu-trigger-chevron" aria-hidden="true" />
32
53
  </button>
33
54
 
34
55
  <div
@@ -63,6 +84,12 @@
63
84
  <Icon v-else-if="isSelected(option)" name="lucide:check" class="select-menu-item-check-icon" />
64
85
  </span>
65
86
  <Icon v-if="option.icon" :name="option.icon" class="select-menu-item-icon" aria-hidden="true" />
87
+ <span
88
+ v-else-if="option.dotColor"
89
+ class="select-menu-item-dot"
90
+ :style="{ '--_dot-color': option.dotColor }"
91
+ aria-hidden="true"
92
+ ></span>
66
93
  <span class="select-menu-item-label">{{ option.label }}</span>
67
94
  </li>
68
95
  </ul>
@@ -81,8 +108,14 @@ interface Props {
81
108
  label: string;
82
109
  /** Text shown in the trigger when no option is selected. Falls back to `label`. */
83
110
  placeholder?: string;
84
- /** Show the selected option's icon in the trigger. */
111
+ /** Show the leading icon in the trigger: the selected option's icon (single-select), otherwise `triggerIcon`. */
85
112
  showIcon?: boolean;
113
+ /** Fixed leading icon for the trigger, e.g. a filter category icon. Shown in both modes; in single-select a selected option's own icon replaces it. */
114
+ triggerIcon?: string;
115
+ /** Selection indicator after the trigger text, shown only while something is selected: `count` (badge with the number selected) or `dot` (status dot). */
116
+ indicator?: "none" | "count" | "dot";
117
+ /** Appended to the trigger's accessible name while the indicator is showing. `{count}` is replaced with the number selected. */
118
+ selectedCountLabel?: string;
86
119
  /** Show the selected option's label (or placeholder) text in the trigger. Set to false for an icon-only compact trigger. */
87
120
  showLabel?: boolean;
88
121
  /** Show the trailing chevron in the trigger. */
@@ -91,6 +124,8 @@ interface Props {
91
124
  multiple?: boolean;
92
125
  /** In multiple mode, update the trigger text to a comma-separated list of the currently checked options instead of leaving it fixed on placeholder/label. No effect outside multiple mode. */
93
126
  showSelectionInTrigger?: boolean;
127
+ /** Single-select only. Size the trigger text to the longest option label (or placeholder), so the trigger doesn't change width as the selection changes. */
128
+ reserveLabelWidth?: boolean;
94
129
  /** Trigger border style, matching InputSelect: `normal` (bordered box) or `underlined` (bottom border only). */
95
130
  inputVariant?: InputUiVariant;
96
131
  styleClassPassthrough?: string | string[];
@@ -99,10 +134,14 @@ interface Props {
99
134
  const props = withDefaults(defineProps<Props>(), {
100
135
  placeholder: undefined,
101
136
  showIcon: true,
137
+ triggerIcon: undefined,
138
+ indicator: "none",
139
+ selectedCountLabel: "{count} selected",
102
140
  showLabel: true,
103
141
  showChevron: true,
104
142
  multiple: false,
105
143
  showSelectionInTrigger: false,
144
+ reserveLabelWidth: false,
106
145
  inputVariant: "normal",
107
146
  styleClassPassthrough: () => [],
108
147
  });
@@ -130,6 +169,11 @@ const selectedOptions = computed(() => props.options.filter((option) => selected
130
169
  * options would grow unpredictably long and push on adjacent triggers. Set
131
170
  * `showSelectionInTrigger` to opt into the comma-joined list instead.
132
171
  */
172
+ const reservedLabels = computed(() => {
173
+ if (!props.reserveLabelWidth || props.multiple) return [];
174
+ return [props.placeholder ?? props.label, ...props.options.map((option) => option.label)];
175
+ });
176
+
133
177
  const triggerLabelText = computed(() => {
134
178
  if (props.multiple) {
135
179
  if (props.showSelectionInTrigger && selectedOptions.value.length) {
@@ -140,6 +184,21 @@ const triggerLabelText = computed(() => {
140
184
  return selectedOption.value?.label ?? props.placeholder ?? props.label;
141
185
  });
142
186
 
187
+ const selectedCount = computed(() => {
188
+ if (props.multiple) return selectedOptions.value.length;
189
+ return selectedOption.value ? 1 : 0;
190
+ });
191
+
192
+ const leadingIcon = computed(() => {
193
+ if (!props.showIcon) return undefined;
194
+ return (!props.multiple && selectedOption.value?.icon) || props.triggerIcon;
195
+ });
196
+
197
+ const triggerAriaLabel = computed(() => {
198
+ if (props.indicator === "none" || !selectedCount.value) return props.label;
199
+ return `${props.label}, ${props.selectedCountLabel.replace("{count}", String(selectedCount.value))}`;
200
+ });
201
+
143
202
  const isSelected = (option: SelectMenuOption): boolean =>
144
203
  props.multiple ? selectedValues.value.includes(option.value) : option.value === modelValue.value;
145
204
 
@@ -238,6 +297,8 @@ watch(
238
297
  .select-menu {
239
298
  position: relative;
240
299
  display: inline-block;
300
+ max-inline-size: 100%;
301
+ min-inline-size: 0;
241
302
 
242
303
  .select-menu-trigger {
243
304
  all: unset;
@@ -249,7 +310,8 @@ watch(
249
310
  min-height: var(--select-menu-trigger-min-height, 4.4rem);
250
311
  padding-block: var(--select-menu-trigger-padding-block, 0.8rem);
251
312
  padding-inline: var(--select-menu-trigger-padding-inline, 1.2rem);
252
- border: var(--select-menu-trigger-border-width, 0.1rem) solid var(--select-menu-trigger-border, var(--theme-border));
313
+ border: var(--select-menu-trigger-border-width, 0.1rem) solid
314
+ var(--select-menu-trigger-border, var(--theme-border));
253
315
  border-radius: var(--select-menu-trigger-border-radius, 0.5rem);
254
316
  background-color: var(--select-menu-trigger-surface, var(--theme-input-surface));
255
317
  color: var(--select-menu-trigger-text-color, var(--theme-text));
@@ -274,6 +336,7 @@ watch(
274
336
  flex-shrink: 0;
275
337
  width: var(--select-menu-trigger-icon-size, 2rem);
276
338
  height: var(--select-menu-trigger-icon-size, 2rem);
339
+ color: var(--select-menu-trigger-icon-color, currentcolor);
277
340
  }
278
341
 
279
342
  .select-menu-trigger-label {
@@ -285,6 +348,53 @@ watch(
285
348
  text-align: start;
286
349
  }
287
350
 
351
+ /* Hidden copies of every possible label share one grid cell, so the widest sets the width. */
352
+ .select-menu-trigger-label-reserved {
353
+ display: grid;
354
+
355
+ .select-menu-trigger-label-text,
356
+ .select-menu-trigger-label-sizer {
357
+ grid-area: 1 / 1;
358
+ min-width: 0;
359
+ overflow: hidden;
360
+ text-overflow: ellipsis;
361
+ }
362
+
363
+ .select-menu-trigger-label-sizer {
364
+ visibility: hidden;
365
+ block-size: 0;
366
+ }
367
+ }
368
+
369
+ .select-menu-trigger-count {
370
+ flex-shrink: 0;
371
+ min-width: var(--select-menu-trigger-count-min-width, 2.4rem);
372
+ padding-block: var(--select-menu-trigger-count-padding-block, 0.2rem);
373
+ padding-inline: var(--select-menu-trigger-count-padding-inline, 0.6rem);
374
+ margin-inline: var(--select-menu-trigger-count-margin-inline, 1.2rem 0);
375
+ border-radius: var(--select-menu-trigger-count-border-radius, 0.4rem);
376
+ background-color: var(--select-menu-trigger-count-surface, var(--theme-surface-subtle));
377
+ color: var(--select-menu-trigger-count-text-color, var(--theme-text));
378
+ font-size: var(--select-menu-trigger-count-font-size, 1.4rem);
379
+ font-weight: var(--select-menu-trigger-count-font-weight, 600);
380
+ font-variant-numeric: tabular-nums;
381
+ text-align: center;
382
+ }
383
+
384
+ .select-menu-trigger-dot {
385
+ flex-shrink: 0;
386
+ width: var(--select-menu-trigger-dot-size, 0.8rem);
387
+ height: var(--select-menu-trigger-dot-size, 0.8rem);
388
+ margin-inline: var(--select-menu-trigger-dot-margin-inline, 1.2rem 0);
389
+ border-radius: 50%;
390
+ background-color: var(--_dot-color, var(--select-menu-trigger-dot-color, var(--theme-accent)));
391
+ }
392
+
393
+ /* Kept in the layout while nothing is selected, so the trigger width doesn't jump. */
394
+ .select-menu-trigger-indicator-empty {
395
+ visibility: hidden;
396
+ }
397
+
288
398
  .select-menu-trigger-chevron {
289
399
  display: block;
290
400
  flex-shrink: 0;
@@ -306,7 +416,7 @@ watch(
306
416
  }
307
417
 
308
418
  .select-menu-trigger[aria-expanded="true"] .select-menu-trigger-chevron {
309
- transform: rotate(180deg);
419
+ transform: scaleY(-1);
310
420
  }
311
421
 
312
422
  .select-menu-popover {
@@ -318,6 +428,7 @@ watch(
318
428
  background-color: var(--select-menu-popover-surface, var(--theme-input-surface));
319
429
  border-radius: var(--select-menu-popover-border-radius, 0.5rem);
320
430
  min-width: var(--select-menu-popover-min-width, 18rem);
431
+ max-inline-size: var(--select-menu-popover-max-width, calc(100vw - 3.2rem));
321
432
  max-height: var(--select-menu-popover-max-height, 32rem);
322
433
  overflow: auto;
323
434
  box-shadow: var(--select-menu-popover-shadow, 0 0.4rem 1.6rem rgb(0 0 0 / 12%));
@@ -326,7 +437,7 @@ watch(
326
437
  top: calc(anchor(bottom) + var(--select-menu-block-distance, 0.4rem));
327
438
  left: anchor(left);
328
439
  right: auto;
329
- position-try-fallbacks: flip-block;
440
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
330
441
 
331
442
  opacity: 0;
332
443
  display: none;
@@ -455,12 +566,24 @@ watch(
455
566
  height: var(--select-menu-item-icon-size, 1.8rem);
456
567
  }
457
568
 
569
+ .select-menu-item-dot {
570
+ grid-column: 2;
571
+ justify-self: center;
572
+ width: var(--select-menu-item-dot-size, 0.8rem);
573
+ height: var(--select-menu-item-dot-size, 0.8rem);
574
+ border-radius: 50%;
575
+ background-color: var(--_dot-color);
576
+ }
577
+
458
578
  .select-menu-item-label {
459
579
  grid-column: 3;
460
580
  grid-row: 1;
461
- white-space: nowrap;
581
+ display: -webkit-box;
582
+ -webkit-box-orient: vertical;
462
583
  overflow: hidden;
463
- text-overflow: ellipsis;
584
+ overflow-wrap: anywhere;
585
+ -webkit-line-clamp: var(--select-menu-item-label-line-clamp, none);
586
+ line-clamp: var(--select-menu-item-label-line-clamp, none);
464
587
  }
465
588
  }
466
589
  }