@happyvertical/smrt-ui 0.51.39 → 0.52.0

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 (212) hide show
  1. package/AGENTS.md +14 -2
  2. package/README.md +257 -5
  3. package/dist/actions/__tests__/swipe-dismiss.test.js +69 -0
  4. package/dist/actions/swipe-dismiss.d.ts +42 -0
  5. package/dist/actions/swipe-dismiss.d.ts.map +1 -0
  6. package/dist/actions/swipe-dismiss.js +69 -0
  7. package/dist/components/calendar/Calendar.svelte +5 -0
  8. package/dist/components/calendar/Calendar.svelte.d.ts.map +1 -1
  9. package/dist/components/calendar/CalendarView.svelte +1250 -0
  10. package/dist/components/calendar/CalendarView.svelte.d.ts +66 -0
  11. package/dist/components/calendar/CalendarView.svelte.d.ts.map +1 -0
  12. package/dist/components/calendar/DayView.svelte +5 -0
  13. package/dist/components/calendar/DayView.svelte.d.ts.map +1 -1
  14. package/dist/components/calendar/__tests__/CalendarView.test.js +226 -0
  15. package/dist/components/calendar/__tests__/calendar-model.test.js +138 -0
  16. package/dist/components/calendar/calendar-model.d.ts +178 -0
  17. package/dist/components/calendar/calendar-model.d.ts.map +1 -0
  18. package/dist/components/calendar/calendar-model.js +362 -0
  19. package/dist/components/calendar/index.d.ts +12 -1
  20. package/dist/components/calendar/index.d.ts.map +1 -1
  21. package/dist/components/calendar/index.js +11 -1
  22. package/dist/components/data/CollectionList.svelte +23 -5
  23. package/dist/components/data/CollectionList.svelte.d.ts +8 -2
  24. package/dist/components/data/CollectionList.svelte.d.ts.map +1 -1
  25. package/dist/components/data/DataTable.svelte +118 -40
  26. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  27. package/dist/components/data/DataTableController.d.ts +4 -0
  28. package/dist/components/data/DataTableController.d.ts.map +1 -1
  29. package/dist/components/data/DataTableController.js +11 -4
  30. package/dist/components/data/ListSortSelect.svelte +121 -0
  31. package/dist/components/data/ListSortSelect.svelte.d.ts +22 -0
  32. package/dist/components/data/ListSortSelect.svelte.d.ts.map +1 -0
  33. package/dist/components/data/SortableHeader.svelte +169 -0
  34. package/dist/components/data/SortableHeader.svelte.d.ts +30 -0
  35. package/dist/components/data/SortableHeader.svelte.d.ts.map +1 -0
  36. package/dist/components/data/__tests__/CollectionList.test.js +37 -0
  37. package/dist/components/data/__tests__/DataTable.test.js +76 -0
  38. package/dist/components/data/__tests__/DataTableController.test.js +35 -0
  39. package/dist/components/data/__tests__/SortableHeader.test.js +101 -0
  40. package/dist/components/data/__tests__/link-step-surface.test.js +172 -0
  41. package/dist/components/data/__tests__/list-sort-state.svelte.test.js +71 -0
  42. package/dist/components/data/__tests__/list-sort.test.js +91 -0
  43. package/dist/components/data/__tests__/surface-navigation.test.js +121 -0
  44. package/dist/components/data/data-surface.d.ts +1 -0
  45. package/dist/components/data/data-surface.d.ts.map +1 -1
  46. package/dist/components/data/data-surface.js +1 -0
  47. package/dist/components/data/index.d.ts +6 -0
  48. package/dist/components/data/index.d.ts.map +1 -1
  49. package/dist/components/data/index.js +6 -0
  50. package/dist/components/data/link-surface.d.ts +79 -0
  51. package/dist/components/data/link-surface.d.ts.map +1 -0
  52. package/dist/components/data/link-surface.js +155 -0
  53. package/dist/components/data/list-sort-state.svelte.d.ts +39 -0
  54. package/dist/components/data/list-sort-state.svelte.d.ts.map +1 -0
  55. package/dist/components/data/list-sort-state.svelte.js +47 -0
  56. package/dist/components/data/list-sort.d.ts +94 -0
  57. package/dist/components/data/list-sort.d.ts.map +1 -0
  58. package/dist/components/data/list-sort.js +182 -0
  59. package/dist/components/data/step-surface.d.ts +73 -0
  60. package/dist/components/data/step-surface.d.ts.map +1 -0
  61. package/dist/components/data/step-surface.js +177 -0
  62. package/dist/components/data/surface-navigation.d.ts +49 -0
  63. package/dist/components/data/surface-navigation.d.ts.map +1 -0
  64. package/dist/components/data/surface-navigation.js +100 -0
  65. package/dist/components/data/types.d.ts +19 -0
  66. package/dist/components/data/types.d.ts.map +1 -1
  67. package/dist/components/feedback/PhoneSheet.svelte +224 -0
  68. package/dist/components/feedback/PhoneSheet.svelte.d.ts +35 -0
  69. package/dist/components/feedback/PhoneSheet.svelte.d.ts.map +1 -0
  70. package/dist/components/feedback/WorkingStrip.svelte +426 -0
  71. package/dist/components/feedback/WorkingStrip.svelte.d.ts +37 -0
  72. package/dist/components/feedback/WorkingStrip.svelte.d.ts.map +1 -0
  73. package/dist/components/feedback/__tests__/PhoneSheet.test.js +101 -0
  74. package/dist/components/feedback/__tests__/WorkingStrip.test.js +172 -0
  75. package/dist/components/feedback/index.d.ts +4 -0
  76. package/dist/components/feedback/index.d.ts.map +1 -1
  77. package/dist/components/feedback/index.js +5 -0
  78. package/dist/components/feedback/working-status.d.ts +34 -0
  79. package/dist/components/feedback/working-status.d.ts.map +1 -0
  80. package/dist/components/feedback/working-status.js +5 -0
  81. package/dist/components/forms/Checkbox.svelte +26 -2
  82. package/dist/components/forms/Combobox.svelte +68 -12
  83. package/dist/components/forms/Combobox.svelte.d.ts +6 -0
  84. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  85. package/dist/components/forms/DictationButton.svelte +128 -0
  86. package/dist/components/forms/DictationButton.svelte.d.ts +19 -0
  87. package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -0
  88. package/dist/components/forms/DictationStatus.svelte +164 -0
  89. package/dist/components/forms/DictationStatus.svelte.d.ts +11 -0
  90. package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -0
  91. package/dist/components/forms/Fieldset.svelte +5 -1
  92. package/dist/components/forms/Fieldset.svelte.d.ts +2 -0
  93. package/dist/components/forms/Fieldset.svelte.d.ts.map +1 -1
  94. package/dist/components/forms/FormActionBar.svelte +84 -0
  95. package/dist/components/forms/FormActionBar.svelte.d.ts +23 -0
  96. package/dist/components/forms/FormActionBar.svelte.d.ts.map +1 -0
  97. package/dist/components/forms/Input.svelte +9 -0
  98. package/dist/components/forms/Radio.svelte +15 -4
  99. package/dist/components/forms/SearchInput.svelte +207 -0
  100. package/dist/components/forms/SearchInput.svelte.d.ts +22 -0
  101. package/dist/components/forms/SearchInput.svelte.d.ts.map +1 -0
  102. package/dist/components/forms/Select.svelte +9 -0
  103. package/dist/components/forms/StagedControlReview.svelte +21 -12
  104. package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -1
  105. package/dist/components/forms/Switch.svelte +11 -5
  106. package/dist/components/forms/TagsInput.svelte +8 -1
  107. package/dist/components/forms/Textarea.svelte +8 -0
  108. package/dist/components/forms/__tests__/Combobox.test.js +87 -0
  109. package/dist/components/forms/__tests__/ControlHitArea.test.js +73 -0
  110. package/dist/components/forms/__tests__/DictationButton.test.js +176 -0
  111. package/dist/components/forms/__tests__/Fieldset.test.js +37 -0
  112. package/dist/components/forms/__tests__/FormActionBar.test.js +22 -0
  113. package/dist/components/forms/__tests__/SearchInput.test.js +92 -0
  114. package/dist/components/forms/__tests__/StagedControlReview.test.js +37 -3
  115. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte +19 -0
  116. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts +11 -0
  117. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts.map +1 -0
  118. package/dist/components/forms/__tests__/composite-registration.fixture.svelte +29 -0
  119. package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts +8 -0
  120. package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts.map +1 -0
  121. package/dist/components/forms/__tests__/control-interaction.test.js +19 -1
  122. package/dist/components/forms/__tests__/control-proposals.test.js +63 -0
  123. package/dist/components/forms/__tests__/dictation-recording.test.js +582 -0
  124. package/dist/components/forms/__tests__/dictation.test.js +486 -0
  125. package/dist/components/forms/__tests__/long-press.test.js +175 -0
  126. package/dist/components/forms/__tests__/place-picker.fixture.svelte +33 -0
  127. package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts +12 -0
  128. package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts.map +1 -0
  129. package/dist/components/forms/audio-capture.d.ts +69 -0
  130. package/dist/components/forms/audio-capture.d.ts.map +1 -0
  131. package/dist/components/forms/audio-capture.js +193 -0
  132. package/dist/components/forms/control-interaction.d.ts +6 -0
  133. package/dist/components/forms/control-interaction.d.ts.map +1 -1
  134. package/dist/components/forms/control-interaction.js +12 -1
  135. package/dist/components/forms/control-proposals.d.ts +55 -0
  136. package/dist/components/forms/control-proposals.d.ts.map +1 -0
  137. package/dist/components/forms/control-proposals.js +188 -0
  138. package/dist/components/forms/dictation-transcribe.d.ts +36 -0
  139. package/dist/components/forms/dictation-transcribe.d.ts.map +1 -0
  140. package/dist/components/forms/dictation-transcribe.js +65 -0
  141. package/dist/components/forms/dictation.svelte.d.ts +200 -0
  142. package/dist/components/forms/dictation.svelte.d.ts.map +1 -0
  143. package/dist/components/forms/dictation.svelte.js +686 -0
  144. package/dist/components/forms/index.d.ts +14 -1
  145. package/dist/components/forms/index.d.ts.map +1 -1
  146. package/dist/components/forms/index.js +14 -1
  147. package/dist/components/forms/insert-text.d.ts +12 -0
  148. package/dist/components/forms/insert-text.d.ts.map +1 -0
  149. package/dist/components/forms/insert-text.js +38 -0
  150. package/dist/components/forms/long-press.d.ts +88 -0
  151. package/dist/components/forms/long-press.d.ts.map +1 -0
  152. package/dist/components/forms/long-press.js +213 -0
  153. package/dist/components/forms/ready-beep.d.ts +24 -0
  154. package/dist/components/forms/ready-beep.d.ts.map +1 -0
  155. package/dist/components/forms/ready-beep.js +82 -0
  156. package/dist/components/layout/PageHeader.svelte +151 -17
  157. package/dist/components/layout/PageHeader.svelte.d.ts +47 -7
  158. package/dist/components/layout/PageHeader.svelte.d.ts.map +1 -1
  159. package/dist/components/layout/__tests__/PageHeader.test.js +87 -1
  160. package/dist/components/layout/index.d.ts +1 -0
  161. package/dist/components/layout/index.d.ts.map +1 -1
  162. package/dist/components/layout/index.js +1 -0
  163. package/dist/components/layout/page-header-context.d.ts +25 -0
  164. package/dist/components/layout/page-header-context.d.ts.map +1 -0
  165. package/dist/components/layout/page-header-context.js +16 -0
  166. package/dist/components/nav/Tabs.svelte +241 -0
  167. package/dist/components/nav/Tabs.svelte.d.ts +15 -0
  168. package/dist/components/nav/Tabs.svelte.d.ts.map +1 -1
  169. package/dist/components/nav/__tests__/LinkTabs.test.js +117 -0
  170. package/dist/components/nav/index.d.ts +1 -0
  171. package/dist/components/nav/index.d.ts.map +1 -1
  172. package/dist/components/nav/index.js +1 -0
  173. package/dist/components/nav/tabs-overflow.d.ts +12 -0
  174. package/dist/components/nav/tabs-overflow.d.ts.map +1 -0
  175. package/dist/components/nav/tabs-overflow.js +17 -0
  176. package/dist/components/nav/types.d.ts +8 -0
  177. package/dist/components/nav/types.d.ts.map +1 -1
  178. package/dist/components/ui/Button.svelte +7 -0
  179. package/dist/components/ui/Card.svelte +14 -5
  180. package/dist/components/ui/Dropdown.svelte +55 -1
  181. package/dist/components/ui/Dropdown.svelte.d.ts +8 -0
  182. package/dist/components/ui/Dropdown.svelte.d.ts.map +1 -1
  183. package/dist/components/ui/IconToggle.svelte +139 -0
  184. package/dist/components/ui/IconToggle.svelte.d.ts +32 -0
  185. package/dist/components/ui/IconToggle.svelte.d.ts.map +1 -0
  186. package/dist/components/ui/IconToggleGroup.svelte +56 -0
  187. package/dist/components/ui/IconToggleGroup.svelte.d.ts +21 -0
  188. package/dist/components/ui/IconToggleGroup.svelte.d.ts.map +1 -0
  189. package/dist/components/ui/Pagination.svelte +1 -1
  190. package/dist/components/ui/Popover.svelte +17 -1
  191. package/dist/components/ui/Popover.svelte.d.ts +8 -0
  192. package/dist/components/ui/Popover.svelte.d.ts.map +1 -1
  193. package/dist/components/ui/__tests__/IconToggle.test.js +59 -0
  194. package/dist/components/ui/__tests__/IconToggleGroup.test.js +30 -0
  195. package/dist/components/ui/__tests__/Popover.test.js +44 -0
  196. package/dist/components/ui/__tests__/dropdown-popover-icon.test.js +125 -0
  197. package/dist/components/ui/__tests__/phone-touch-targets.test.js +39 -0
  198. package/dist/components/ui/index.d.ts +2 -0
  199. package/dist/components/ui/index.d.ts.map +1 -1
  200. package/dist/components/ui/index.js +2 -0
  201. package/dist/i18n/strings.ui.d.ts +41 -0
  202. package/dist/i18n/strings.ui.d.ts.map +1 -1
  203. package/dist/i18n/strings.ui.js +46 -0
  204. package/dist/svelte/__tests__/playground.test.js +1 -0
  205. package/dist/svelte/playground/CalendarPreview.svelte +140 -0
  206. package/dist/svelte/playground/CalendarPreview.svelte.d.ts +4 -0
  207. package/dist/svelte/playground/CalendarPreview.svelte.d.ts.map +1 -0
  208. package/dist/svelte/playground.d.ts.map +1 -1
  209. package/dist/svelte/playground.js +10 -0
  210. package/dist/types-generic.d.ts +2 -0
  211. package/dist/types-generic.d.ts.map +1 -1
  212. package/package.json +3 -3
@@ -18,6 +18,14 @@ export interface Props {
18
18
  label?: string;
19
19
  /** Custom trigger content. */
20
20
  trigger?: Snippet;
21
+ /** Accessible name of the trigger button when it shows only an icon (`aria-label`); omit to use its visible content (an icon trigger then falls back to `label`). */
22
+ triggerLabel?: string;
23
+ /**
24
+ * Trigger look: `button` (bordered, the default) or `icon`, a borderless 44px
25
+ * round icon button (a "more" or row-actions menu) that takes the surrounding
26
+ * text colour.
27
+ */
28
+ variant?: 'button' | 'icon';
21
29
  /** Menu items. */
22
30
  items: MenuItem[];
23
31
  /** Menu placement relative to the trigger. */
@@ -31,6 +39,8 @@ export interface Props {
31
39
  const {
32
40
  label,
33
41
  trigger,
42
+ triggerLabel,
43
+ variant = 'button',
34
44
  items,
35
45
  placement = 'bottom-start',
36
46
  onselect,
@@ -39,6 +49,16 @@ const {
39
49
 
40
50
  const instanceId = $props.id();
41
51
  const menuId = `smrt-menu-${instanceId}`;
52
+
53
+ // An icon trigger shows no text, so without triggerLabel or label the button
54
+ // has no accessible name.
55
+ $effect(() => {
56
+ if (variant === 'icon' && !triggerLabel && !label) {
57
+ console.warn(
58
+ '[Dropdown] variant="icon" needs triggerLabel (or label): the trigger button has no accessible name.',
59
+ );
60
+ }
61
+ });
42
62
  let open = $state(false);
43
63
  let wrapEl = $state<HTMLElement | null>(null);
44
64
  let triggerEl = $state<HTMLButtonElement | null>(null);
@@ -132,6 +152,8 @@ $effect(() => {
132
152
  bind:this={triggerEl}
133
153
  type="button"
134
154
  class="dropdown__trigger"
155
+ class:dropdown__trigger--icon={variant === 'icon'}
156
+ aria-label={triggerLabel ?? (variant === 'icon' ? label : undefined)}
135
157
  aria-haspopup="menu"
136
158
  aria-expanded={open}
137
159
  aria-controls={open ? menuId : undefined}
@@ -160,7 +182,10 @@ $effect(() => {
160
182
  tabindex={-1}
161
183
  onclick={() => selectItem(item)}
162
184
  >
163
- {item.label}
185
+ {#if item.icon}
186
+ <span class="dropdown__item-icon" aria-hidden="true">{@render item.icon()}</span>
187
+ {/if}
188
+ <span class="dropdown__item-label">{item.label}</span>
164
189
  </button>
165
190
  {/each}
166
191
  </div>
@@ -187,6 +212,24 @@ $effect(() => {
187
212
  outline: 2px solid var(--smrt-color-primary);
188
213
  outline-offset: 2px;
189
214
  }
215
+ /* Icon trigger: a 44px round "more" button in the surrounding text colour. */
216
+ .dropdown__trigger--icon {
217
+ display: inline-flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ box-sizing: border-box;
221
+ inline-size: 2.75rem;
222
+ block-size: 2.75rem;
223
+ padding: 0;
224
+ border: none;
225
+ border-radius: var(--smrt-radius-full, 999px);
226
+ background: transparent;
227
+ color: inherit;
228
+ }
229
+ .dropdown__trigger--icon:hover:not(:disabled),
230
+ .dropdown__trigger--icon[aria-expanded='true'] {
231
+ background: color-mix(in srgb, currentColor 10%, transparent);
232
+ }
190
233
  .dropdown__trigger:disabled {
191
234
  opacity: 0.5;
192
235
  cursor: not-allowed;
@@ -226,6 +269,12 @@ $effect(() => {
226
269
 
227
270
  .dropdown__item {
228
271
  appearance: none;
272
+ display: flex;
273
+ align-items: center;
274
+ gap: var(--smrt-spacing-2, 8px);
275
+ /* A touch target: menu rows are at least 44px tall. */
276
+ min-block-size: 2.75rem;
277
+ box-sizing: border-box;
229
278
  font: inherit;
230
279
  text-align: left;
231
280
  cursor: pointer;
@@ -236,6 +285,11 @@ $effect(() => {
236
285
  color: var(--smrt-color-on-surface);
237
286
  white-space: nowrap;
238
287
  }
288
+ .dropdown__item-icon {
289
+ display: inline-flex;
290
+ flex: none;
291
+ color: var(--smrt-color-on-surface-variant);
292
+ }
239
293
  .dropdown__item:hover:not(:disabled) {
240
294
  background: var(--smrt-color-surface-container-high);
241
295
  }
@@ -14,6 +14,14 @@ export interface Props {
14
14
  label?: string;
15
15
  /** Custom trigger content. */
16
16
  trigger?: Snippet;
17
+ /** Accessible name of the trigger button when it shows only an icon (`aria-label`); omit to use its visible content (an icon trigger then falls back to `label`). */
18
+ triggerLabel?: string;
19
+ /**
20
+ * Trigger look: `button` (bordered, the default) or `icon`, a borderless 44px
21
+ * round icon button (a "more" or row-actions menu) that takes the surrounding
22
+ * text colour.
23
+ */
24
+ variant?: 'button' | 'icon';
17
25
  /** Menu items. */
18
26
  items: MenuItem[];
19
27
  /** Menu placement relative to the trigger. */
@@ -1 +1 @@
1
- {"version":3,"file":"Dropdown.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Dropdown.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kBAAkB;IAClB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,iBAAiB,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAgID,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Dropdown.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Dropdown.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAGvE,MAAM,WAAW,KAAK;IACpB,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qKAAqK;IACrK,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC5B,kBAAkB;IAClB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,iBAAiB,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA+ID,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ /**
3
+ * IconToggle — an icon-only toggle button for dense filter and view-switcher
4
+ * rows. The icon is the `children` snippet (any icon set); `label` is both the
5
+ * accessible name and the tooltip text. `pressed` is reflected with
6
+ * `aria-pressed`, so a row of them is a one-of-many (or a many) toggle group;
7
+ * the owner decides what pressing means (this component keeps no state). An
8
+ * optional `count` shows as a small badge and is part of the accessible name
9
+ * and tooltip. The hit area is at least 44px square.
10
+ */
11
+ import type { Snippet } from 'svelte';
12
+ import type { HTMLButtonAttributes } from 'svelte/elements';
13
+ import type { TooltipPlacement } from '../../types-generic';
14
+ import Tooltip from './Tooltip.svelte';
15
+
16
+ export interface Props
17
+ extends Omit<
18
+ HTMLButtonAttributes,
19
+ 'class' | 'children' | 'aria-label' | 'aria-pressed' | 'title'
20
+ > {
21
+ /** Accessible name and tooltip text (what the button does or filters). */
22
+ label: string;
23
+ /** Whether the toggle is on (`aria-pressed`). */
24
+ pressed?: boolean;
25
+ /** Optional count badge; included in the accessible name and tooltip. */
26
+ count?: number;
27
+ /** A CSS colour (a token such as `var(--status-draft)` or a value) shown when the toggle is pressed. */
28
+ tone?: string;
29
+ /** The icon. Decorative: it is hidden from assistive technology. */
30
+ children: Snippet;
31
+ /** Where the tooltip opens. */
32
+ tooltipPlacement?: TooltipPlacement;
33
+ /** Additional CSS class names. */
34
+ class?: string;
35
+ }
36
+
37
+ const {
38
+ label,
39
+ pressed = false,
40
+ count,
41
+ tone,
42
+ children,
43
+ tooltipPlacement = 'top',
44
+ disabled = false,
45
+ type = 'button',
46
+ class: className = '',
47
+ ...rest
48
+ }: Props = $props();
49
+
50
+ const name = $derived(count === undefined ? label : `${label} (${count})`);
51
+ </script>
52
+
53
+ <Tooltip text={name} placement={tooltipPlacement}>
54
+ <button
55
+ {...rest}
56
+ {type}
57
+ {disabled}
58
+ class="icon-toggle {className}"
59
+ class:pressed
60
+ class:toned={tone !== undefined}
61
+ style:--icon-toggle-tone={tone}
62
+ aria-pressed={pressed}
63
+ aria-label={name}
64
+ >
65
+ <span class="icon" aria-hidden="true">{@render children()}</span>
66
+ {#if count !== undefined}<span class="count" aria-hidden="true">{count}</span>{/if}
67
+ </button>
68
+ </Tooltip>
69
+
70
+ <style>
71
+ .icon-toggle {
72
+ position: relative;
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ box-sizing: border-box;
77
+ min-width: 2.75rem;
78
+ min-height: 2.75rem;
79
+ padding: 0;
80
+ border: 1px solid var(--smrt-color-outline-variant, var(--smrt-color-outline));
81
+ border-radius: var(--smrt-radius-full, 999px);
82
+ background: var(--smrt-color-surface-container, var(--smrt-color-surface));
83
+ color: var(--smrt-color-on-surface);
84
+ font: inherit;
85
+ cursor: pointer;
86
+ }
87
+ .icon-toggle:hover:not(:disabled) {
88
+ background: var(--smrt-color-surface-container-high, var(--smrt-color-surface-container));
89
+ }
90
+ /* Pressed is not told by colour alone (WCAG 1.4.1): it also draws a 2px ring
91
+ in its text colour, where unpressed has a 1px outline. */
92
+ .icon-toggle.pressed {
93
+ border-color: transparent;
94
+ background: var(--smrt-color-primary-container, var(--smrt-color-secondary-container));
95
+ color: var(--smrt-color-on-primary-container, var(--smrt-color-on-secondary-container));
96
+ box-shadow: inset 0 0 0 2px currentColor;
97
+ }
98
+ /* A toned toggle is neutral until pressed, then wears its tone. */
99
+ .icon-toggle.toned {
100
+ color: var(--smrt-color-on-surface-variant, var(--smrt-color-on-surface));
101
+ }
102
+ .icon-toggle.toned.pressed {
103
+ background: color-mix(in srgb, var(--icon-toggle-tone) 16%, var(--smrt-color-surface));
104
+ color: var(--icon-toggle-tone);
105
+ box-shadow: inset 0 0 0 2px var(--icon-toggle-tone);
106
+ }
107
+ .icon-toggle:focus-visible {
108
+ outline: 2px solid var(--smrt-color-primary);
109
+ outline-offset: 2px;
110
+ }
111
+ .icon-toggle:disabled {
112
+ opacity: 0.5;
113
+ cursor: not-allowed;
114
+ }
115
+ .icon {
116
+ display: inline-flex;
117
+ }
118
+ .count {
119
+ position: absolute;
120
+ z-index: 2;
121
+ top: -0.3rem;
122
+ right: -0.3rem;
123
+ box-sizing: border-box;
124
+ min-width: 1.15rem;
125
+ padding: 0 0.3rem;
126
+ border-radius: var(--smrt-radius-full, 999px);
127
+ /* Outlined, not filled: a surface-coloured face (so the button's edge does
128
+ not show through where the badge overlaps it), a 1px theme outline and
129
+ neutral text, readable on light and dark themes alike. */
130
+ border: 1px solid var(--smrt-color-outline);
131
+ background: var(--smrt-color-surface);
132
+ color: var(--smrt-color-on-surface-variant);
133
+ font-size: 0.68rem;
134
+ font-weight: 700;
135
+ line-height: 1.15rem;
136
+ text-align: center;
137
+ font-variant-numeric: tabular-nums;
138
+ }
139
+ </style>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * IconToggle — an icon-only toggle button for dense filter and view-switcher
3
+ * rows. The icon is the `children` snippet (any icon set); `label` is both the
4
+ * accessible name and the tooltip text. `pressed` is reflected with
5
+ * `aria-pressed`, so a row of them is a one-of-many (or a many) toggle group;
6
+ * the owner decides what pressing means (this component keeps no state). An
7
+ * optional `count` shows as a small badge and is part of the accessible name
8
+ * and tooltip. The hit area is at least 44px square.
9
+ */
10
+ import type { Snippet } from 'svelte';
11
+ import type { HTMLButtonAttributes } from 'svelte/elements';
12
+ import type { TooltipPlacement } from '../../types-generic';
13
+ export interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children' | 'aria-label' | 'aria-pressed' | 'title'> {
14
+ /** Accessible name and tooltip text (what the button does or filters). */
15
+ label: string;
16
+ /** Whether the toggle is on (`aria-pressed`). */
17
+ pressed?: boolean;
18
+ /** Optional count badge; included in the accessible name and tooltip. */
19
+ count?: number;
20
+ /** A CSS colour (a token such as `var(--status-draft)` or a value) shown when the toggle is pressed. */
21
+ tone?: string;
22
+ /** The icon. Decorative: it is hidden from assistive technology. */
23
+ children: Snippet;
24
+ /** Where the tooltip opens. */
25
+ tooltipPlacement?: TooltipPlacement;
26
+ /** Additional CSS class names. */
27
+ class?: string;
28
+ }
29
+ declare const IconToggle: import("svelte").Component<Props, {}, "">;
30
+ type IconToggle = ReturnType<typeof IconToggle>;
31
+ export default IconToggle;
32
+ //# sourceMappingURL=IconToggle.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"IconToggle.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/IconToggle.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAI5D,MAAM,WAAW,KACf,SAAQ,IAAI,CACV,oBAAoB,EACpB,OAAO,GAAG,UAAU,GAAG,YAAY,GAAG,cAAc,GAAG,OAAO,CAC/D;IACD,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAC;IACd,iDAAiD;IACjD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wGAAwG;IACxG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,QAAQ,EAAE,OAAO,CAAC;IAClB,+BAA+B;IAC/B,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAoCD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ /**
3
+ * IconToggleGroup — joins a row of `IconToggle`s into one labelled group: a
4
+ * single segmented strip with shared edges and rounded ends, exposed to
5
+ * assistive technology as `role="group"` named by `label` ("Filter by type").
6
+ * It adds no behaviour; each toggle keeps its own `pressed` state and
7
+ * `onclick`, so it works for one-of-many and many-of-many alike. Put one group
8
+ * per kind of filter and lay the groups out yourself.
9
+ */
10
+ import type { Snippet } from 'svelte';
11
+
12
+ export interface Props {
13
+ /** Names the group for assistive technology (what these toggles filter). */
14
+ label: string;
15
+ /** The `IconToggle`s. */
16
+ children: Snippet;
17
+ /** Additional CSS class names. */
18
+ class?: string;
19
+ }
20
+
21
+ const { label, children, class: className = '' }: Props = $props();
22
+ </script>
23
+
24
+ <div class="icon-toggle-group {className}" role="group" aria-label={label}>
25
+ {@render children()}
26
+ </div>
27
+
28
+ <style>
29
+ .icon-toggle-group {
30
+ display: inline-flex;
31
+ flex-wrap: wrap;
32
+ max-width: 100%;
33
+ isolation: isolate;
34
+ }
35
+ /* Shared edges: one border between neighbours, square inside, round ends.
36
+ Each toggle sits in tooltip wrappers, so the rules reach it by descendant. */
37
+ .icon-toggle-group :global(.icon-toggle) {
38
+ border-radius: 0;
39
+ }
40
+ .icon-toggle-group > :global(* + *) {
41
+ margin-inline-start: -1px;
42
+ }
43
+ .icon-toggle-group > :global(:first-child .icon-toggle) {
44
+ border-start-start-radius: var(--smrt-radius-full, 999px);
45
+ border-end-start-radius: var(--smrt-radius-full, 999px);
46
+ }
47
+ .icon-toggle-group > :global(:last-child .icon-toggle) {
48
+ border-start-end-radius: var(--smrt-radius-full, 999px);
49
+ border-end-end-radius: var(--smrt-radius-full, 999px);
50
+ }
51
+ /* The pressed one sits above its neighbours so its tint is not clipped by them. */
52
+ .icon-toggle-group :global(.icon-toggle.pressed),
53
+ .icon-toggle-group :global(.icon-toggle:focus-visible) {
54
+ z-index: 1;
55
+ }
56
+ </style>
@@ -0,0 +1,21 @@
1
+ /**
2
+ * IconToggleGroup — joins a row of `IconToggle`s into one labelled group: a
3
+ * single segmented strip with shared edges and rounded ends, exposed to
4
+ * assistive technology as `role="group"` named by `label` ("Filter by type").
5
+ * It adds no behaviour; each toggle keeps its own `pressed` state and
6
+ * `onclick`, so it works for one-of-many and many-of-many alike. Put one group
7
+ * per kind of filter and lay the groups out yourself.
8
+ */
9
+ import type { Snippet } from 'svelte';
10
+ export interface Props {
11
+ /** Names the group for assistive technology (what these toggles filter). */
12
+ label: string;
13
+ /** The `IconToggle`s. */
14
+ children: Snippet;
15
+ /** Additional CSS class names. */
16
+ class?: string;
17
+ }
18
+ declare const IconToggleGroup: import("svelte").Component<Props, {}, "">;
19
+ type IconToggleGroup = ReturnType<typeof IconToggleGroup>;
20
+ export default IconToggleGroup;
21
+ //# sourceMappingURL=IconToggleGroup.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"IconToggleGroup.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/IconToggleGroup.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,QAAQ,EAAE,OAAO,CAAC;IAClB,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiBD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -240,7 +240,7 @@ const isLastPage = $derived(currentPage === totalPages);
240
240
 
241
241
  .page-link.current {
242
242
  background: var(--smrt-color-primary, #005ac1);
243
- color: white;
243
+ color: var(--smrt-color-on-primary, white);
244
244
  cursor: default;
245
245
  }
246
246
 
@@ -6,6 +6,14 @@ export interface Props {
6
6
  label: string;
7
7
  /** Content rendered inside the trigger button in place of the label text. */
8
8
  trigger?: Snippet;
9
+ /** Accessible name of the trigger button when it shows only an icon (`aria-label`); omit to use its visible content (an icon trigger then falls back to `label`). */
10
+ triggerLabel?: string;
11
+ /**
12
+ * Trigger look: `button` (bordered, the default) or `icon`, a borderless
13
+ * 44px round icon button that takes the surrounding text colour (set the
14
+ * colour on the popover's `class`).
15
+ */
16
+ variant?: 'button' | 'icon';
9
17
  /** The popover content. */
10
18
  children?: Snippet;
11
19
  /** Whether the popover is visible. */
@@ -25,6 +33,8 @@ export interface Props {
25
33
  let {
26
34
  label,
27
35
  trigger,
36
+ triggerLabel,
37
+ variant = 'button',
28
38
  children,
29
39
  open = $bindable(false),
30
40
  disabled = false,
@@ -74,7 +84,9 @@ $effect(() => {
74
84
  bind:this={triggerEl}
75
85
  type="button"
76
86
  class="popover__trigger"
87
+ class:popover__trigger--icon={variant === 'icon'}
77
88
  aria-haspopup="dialog"
89
+ aria-label={triggerLabel ?? (variant === 'icon' ? label : undefined)}
78
90
  aria-expanded={open}
79
91
  aria-controls={open ? panelId : undefined}
80
92
  {disabled}
@@ -103,7 +115,11 @@ $effect(() => {
103
115
  .popover__trigger { appearance: none; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-medium); background: var(--smrt-color-surface); color: var(--smrt-color-on-surface); font: inherit; cursor: pointer; }
104
116
  .popover__trigger:focus-visible, .popover__panel:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
105
117
  .popover__trigger:disabled { opacity: .5; cursor: not-allowed; }
106
- .popover__panel { position: absolute; z-index: var(--smrt-z-index-popover, 1000); min-width: 16rem; padding: var(--smrt-spacing-4); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-large); background: var(--smrt-color-surface-container); color: var(--smrt-color-on-surface); box-shadow: var(--smrt-elevation-3); }
118
+ /* Icon trigger: a 44px round glyph in the surrounding colour. Hover and open nudge the glyph; focus keeps the standard primary ring (a soft ring in the glyph's colour fell under 3:1 on ordinary surfaces). */
119
+ .popover__trigger--icon { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; inline-size: 2.75rem; block-size: 2.75rem; padding: 0; border: none; border-radius: var(--smrt-radius-full, 999px); background: transparent; color: inherit; }
120
+ .popover__trigger--icon:hover:not(:disabled), .popover__trigger--icon[aria-expanded='true'] { background: transparent; color: color-mix(in srgb, currentColor 80%, var(--smrt-color-on-surface)); }
121
+ .popover__trigger--icon:focus-visible { background: transparent; }
122
+ .popover__panel { position: absolute; z-index: var(--smrt-z-index-popover, 1000); box-sizing: border-box; inline-size: var(--smrt-popover-panel-width, auto); min-width: min(16rem, calc(100vw - 2rem)); max-width: calc(100vw - 2rem); padding: var(--smrt-popover-panel-padding, var(--smrt-spacing-4)); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-large); background: var(--smrt-color-surface-container); color: var(--smrt-color-on-surface); box-shadow: var(--smrt-elevation-3); }
107
123
  .popover__panel--bottom-start { top: calc(100% + var(--smrt-spacing-1)); left: 0; }
108
124
  .popover__panel--bottom-end { top: calc(100% + var(--smrt-spacing-1)); right: 0; }
109
125
  .popover__panel--top-start { bottom: calc(100% + var(--smrt-spacing-1)); left: 0; }
@@ -4,6 +4,14 @@ export interface Props {
4
4
  label: string;
5
5
  /** Content rendered inside the trigger button in place of the label text. */
6
6
  trigger?: Snippet;
7
+ /** Accessible name of the trigger button when it shows only an icon (`aria-label`); omit to use its visible content (an icon trigger then falls back to `label`). */
8
+ triggerLabel?: string;
9
+ /**
10
+ * Trigger look: `button` (bordered, the default) or `icon`, a borderless
11
+ * 44px round icon button that takes the surrounding text colour (set the
12
+ * colour on the popover's `class`).
13
+ */
14
+ variant?: 'button' | 'icon';
7
15
  /** The popover content. */
8
16
  children?: Snippet;
9
17
  /** Whether the popover is visible. */
@@ -1 +1 @@
1
- {"version":3,"file":"Popover.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Popover.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,OAAO,EAAQ,MAAM,QAAQ,CAAC;AAG5C,MAAM,WAAW,KAAK;IACpB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sCAAsC;IACtC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,kCAAkC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uDAAuD;IACvD,SAAS,CAAC,EAAE,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;IACpE,sDAAsD;IACtD,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,yCAAyC;IACzC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAmED,QAAA,MAAM,OAAO,+CAAwC,CAAC;AACtD,KAAK,OAAO,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC;AAC1C,eAAe,OAAO,CAAC"}
1
+ {"version":3,"file":"Popover.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Popover.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,OAAO,EAAQ,MAAM,QAAQ,CAAC;AAG5C,MAAM,WAAW,KAAK;IACpB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qKAAqK;IACrK,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC5B,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sCAAsC;IACtC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,kCAAkC;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uDAAuD;IACvD,SAAS,CAAC,EAAE,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,SAAS,CAAC;IACpE,sDAAsD;IACtD,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,yCAAyC;IACzC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,kCAAkC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAqED,QAAA,MAAM,OAAO,+CAAwC,CAAC;AACtD,KAAK,OAAO,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC;AAC1C,eAAe,OAAO,CAAC"}
@@ -0,0 +1,59 @@
1
+ import { render, screen } from '@testing-library/svelte';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { createRawSnippet } from 'svelte';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
6
+ import IconToggle from '../IconToggle.svelte';
7
+ const icon = createRawSnippet(() => ({
8
+ render: () => '<svg viewBox="0 0 24 24" width="18" height="18"></svg>',
9
+ }));
10
+ describe('IconToggle', () => {
11
+ it('is a labelled toggle button reflecting pressed', () => {
12
+ render(IconToggle, {
13
+ props: { label: 'Grid', pressed: true, children: icon },
14
+ });
15
+ const button = screen.getByRole('button', { name: 'Grid' });
16
+ expect(button).toHaveAttribute('aria-pressed', 'true');
17
+ expect(button).toHaveAttribute('type', 'button');
18
+ });
19
+ it('shows a count badge that is part of the name and the tooltip', () => {
20
+ render(IconToggle, {
21
+ props: { label: 'Draft', count: 12, children: icon },
22
+ });
23
+ const button = screen.getByRole('button', { name: 'Draft (12)' });
24
+ expect(button).toHaveAttribute('aria-pressed', 'false');
25
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Draft (12)');
26
+ expect(button.querySelector('.count')).toHaveTextContent('12');
27
+ });
28
+ it('keeps no state of its own: it calls onclick and the owner decides', async () => {
29
+ const onclick = vi.fn();
30
+ render(IconToggle, {
31
+ props: { label: 'Cards', pressed: false, onclick, children: icon },
32
+ });
33
+ await userEvent.click(screen.getByRole('button', { name: 'Cards' }));
34
+ expect(onclick).toHaveBeenCalledTimes(1);
35
+ expect(screen.getByRole('button', { name: 'Cards' })).toHaveAttribute('aria-pressed', 'false');
36
+ });
37
+ it('is axe-clean', async () => {
38
+ const { container } = render(IconToggle, {
39
+ props: { label: 'Grid', count: 3, children: icon },
40
+ });
41
+ await expectNoA11yViolations(container);
42
+ });
43
+ });
44
+ describe('IconToggle tone', () => {
45
+ it('carries its tone as a custom property and a toned class', () => {
46
+ render(IconToggle, {
47
+ props: { label: 'Draft', tone: 'var(--status-draft)', children: icon },
48
+ });
49
+ const button = screen.getByRole('button', { name: 'Draft' });
50
+ expect(button).toHaveClass('toned');
51
+ expect(button.style.getPropertyValue('--icon-toggle-tone')).toBe('var(--status-draft)');
52
+ });
53
+ it('has no tone styling unless asked', () => {
54
+ render(IconToggle, { props: { label: 'Grid', children: icon } });
55
+ const button = screen.getByRole('button', { name: 'Grid' });
56
+ expect(button).not.toHaveClass('toned');
57
+ expect(button.style.getPropertyValue('--icon-toggle-tone')).toBe('');
58
+ });
59
+ });
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/svelte';
2
+ import { createRawSnippet } from 'svelte';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
5
+ import IconToggleGroup from '../IconToggleGroup.svelte';
6
+ const buttons = createRawSnippet(() => ({
7
+ render: () => '<span><button type="button" aria-pressed="true" aria-label="Draft (2)">D</button><button type="button" aria-pressed="false" aria-label="Review (0)">R</button></span>',
8
+ }));
9
+ describe('IconToggleGroup', () => {
10
+ it('is a named group around its toggles', () => {
11
+ render(IconToggleGroup, {
12
+ props: { label: 'Filter by status', children: buttons },
13
+ });
14
+ const group = screen.getByRole('group', { name: 'Filter by status' });
15
+ expect(group.querySelectorAll('button')).toHaveLength(2);
16
+ });
17
+ it('adds no state: the toggles keep their own pressed value', () => {
18
+ render(IconToggleGroup, {
19
+ props: { label: 'Filter by status', children: buttons },
20
+ });
21
+ expect(screen.getByRole('button', { name: 'Draft (2)' })).toHaveAttribute('aria-pressed', 'true');
22
+ expect(screen.getByRole('button', { name: 'Review (0)' })).toHaveAttribute('aria-pressed', 'false');
23
+ });
24
+ it('is axe-clean', async () => {
25
+ const { container } = render(IconToggleGroup, {
26
+ props: { label: 'Filter by status', children: buttons },
27
+ });
28
+ await expectNoA11yViolations(container);
29
+ });
30
+ });
@@ -0,0 +1,44 @@
1
+ import { render, screen } from '@testing-library/svelte';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { createRawSnippet } from 'svelte';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
6
+ import Popover from '../Popover.svelte';
7
+ const icon = createRawSnippet(() => ({
8
+ render: () => '<svg viewBox="0 0 24 24" width="18" height="18"></svg>',
9
+ }));
10
+ const body = createRawSnippet(() => ({
11
+ render: () => '<p>No main picture</p>',
12
+ }));
13
+ describe('Popover', () => {
14
+ it('names an icon-only trigger with triggerLabel and opens its panel', async () => {
15
+ render(Popover, {
16
+ props: {
17
+ label: 'Issues',
18
+ triggerLabel: '3 issues',
19
+ trigger: icon,
20
+ children: body,
21
+ },
22
+ });
23
+ const trigger = screen.getByRole('button', { name: '3 issues' });
24
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
25
+ await userEvent.click(trigger);
26
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
27
+ expect(screen.getByRole('dialog', { name: 'Issues' })).toHaveTextContent('No main picture');
28
+ });
29
+ it('is named by its visible content when there is no triggerLabel', () => {
30
+ render(Popover, { props: { label: 'Filters', children: body } });
31
+ expect(screen.getByRole('button', { name: 'Filters' })).not.toHaveAttribute('aria-label');
32
+ });
33
+ it('is axe-clean with an icon-only trigger', async () => {
34
+ const { container } = render(Popover, {
35
+ props: {
36
+ label: 'Issues',
37
+ triggerLabel: '3 issues',
38
+ trigger: icon,
39
+ children: body,
40
+ },
41
+ });
42
+ await expectNoA11yViolations(container);
43
+ });
44
+ });