@happyvertical/smrt-ui 0.51.38 → 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
@@ -10,9 +10,19 @@
10
10
  * - Proper ARIA roles: tablist, tab, tabpanel
11
11
  * - aria-selected indicates active tab
12
12
  * - aria-controls links tab to panel
13
+ *
14
+ * Link tabs: give every tab an `href` and the row renders as navigation — a
15
+ * `<nav>` of links with `aria-current="page"` on the active one, each tab its
16
+ * own URL (a content item's Article / Images / Review pages). With
17
+ * `maxVisible`, extra link tabs move to a "More" menu while the active tab
18
+ * always stays in the row; `badge` marks a tab needing attention (and puts a
19
+ * dot on "More" when a hidden tab has one). The row scrolls sideways on
20
+ * phones and keeps the active tab scrolled into view. It carries
21
+ * `data-shell-tabs`, so AdminShell keeps it sticky under its phone top bar.
13
22
  */
14
23
  import type { Snippet } from 'svelte';
15
24
  import { ripple } from '../../actions/ripple.js';
25
+ import { splitTabs } from './tabs-overflow.js';
16
26
  import type { Tab } from './types.js';
17
27
 
18
28
  export interface Props {
@@ -30,6 +40,12 @@ export interface Props {
30
40
  children?: Snippet;
31
41
  /** Accessible label for the tablist */
32
42
  'aria-label'?: string;
43
+ /** Link tabs only: most tabs shown in the row; the rest go under "More". */
44
+ maxVisible?: number;
45
+ /** Link tabs only: label of the overflow menu (default "More"). */
46
+ moreLabel?: string;
47
+ /** Link tabs only: screen-reader text for the "More" attention dot. */
48
+ moreBadgeLabel?: string;
33
49
  }
34
50
 
35
51
  const {
@@ -40,8 +56,39 @@ const {
40
56
  variant = 'primary',
41
57
  children,
42
58
  'aria-label': ariaLabel,
59
+ maxVisible,
60
+ moreLabel = 'More',
61
+ moreBadgeLabel = 'has updates',
43
62
  }: Props = $props();
44
63
 
64
+ /** Link tabs: every tab has an href, so the row is navigation, not a tablist. */
65
+ const linkMode = $derived(tabs.length > 0 && tabs.every((tab) => !!tab.href));
66
+ const split = $derived(
67
+ linkMode
68
+ ? splitTabs(tabs, active, maxVisible)
69
+ : { visible: tabs, overflow: [] as Tab[] },
70
+ );
71
+ // Same rule as the tab's own badge, so an empty badge dots neither.
72
+ const overflowHasBadge = $derived(split.overflow.some((tab) => hasBadge(tab)));
73
+ let moreMenu = $state<HTMLDetailsElement | null>(null);
74
+ let linkRow = $state<HTMLElement | null>(null);
75
+
76
+ // Keep the active link tab scrolled into view in a sideways-scrolling row,
77
+ // and close "More" once the active tab changes (a link was followed).
78
+ $effect(() => {
79
+ const current = active;
80
+ if (!linkMode || !linkRow) return;
81
+ if (moreMenu) moreMenu.open = false;
82
+ const activeLink = linkRow.querySelector<HTMLElement>(
83
+ `[data-tab-id="${CSS.escape(current)}"]`,
84
+ );
85
+ activeLink?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
86
+ });
87
+
88
+ function hasBadge(tab: Tab): boolean {
89
+ return tab.badge !== undefined && tab.badge !== null && tab.badge !== '';
90
+ }
91
+
45
92
  /** Get enabled tabs only */
46
93
  const enabledTabs = $derived(tabs.filter((t) => !t.disabled));
47
94
 
@@ -51,6 +98,9 @@ let tablistEl: HTMLElement | null = $state(null);
51
98
  const instanceId = $props.id();
52
99
 
53
100
  function handleClick(id: string) {
101
+ // A disabled tab (a link one has no href but is still clickable) never
102
+ // changes the active tab.
103
+ if (tabs.find((tab) => tab.id === id)?.disabled) return;
54
104
  if (id !== active) {
55
105
  onchange?.(id);
56
106
  }
@@ -101,6 +151,70 @@ function handleKeydown(event: KeyboardEvent, tabId: string) {
101
151
  }
102
152
  </script>
103
153
 
154
+ {#snippet linkTab(tab: Tab)}
155
+ <a
156
+ class="tab-link"
157
+ class:active={tab.id === active}
158
+ href={tab.disabled ? undefined : tab.href}
159
+ aria-current={tab.id === active ? 'page' : undefined}
160
+ aria-disabled={tab.disabled ? 'true' : undefined}
161
+ data-tab-id={tab.id}
162
+ onclick={(event) => {
163
+ if (tab.disabled) {
164
+ event.preventDefault();
165
+ return;
166
+ }
167
+ handleClick(tab.id);
168
+ }}
169
+ >
170
+ <span class="tab-label">{tab.label}</span>
171
+ {#if tab.count !== undefined}
172
+ <span class="tab-count">{tab.count}</span>
173
+ {/if}
174
+ {#if hasBadge(tab)}
175
+ <span class="tab-badge">{tab.badge}</span>
176
+ {/if}
177
+ </a>
178
+ {/snippet}
179
+
180
+ {#if linkMode}
181
+ <div class="tabs-container">
182
+ <nav
183
+ class="tabs-links"
184
+ class:sm={size === 'sm'}
185
+ class:lg={size === 'lg'}
186
+ aria-label={ariaLabel}
187
+ data-shell-tabs
188
+ >
189
+ <div class="tabs-links-row" bind:this={linkRow}>
190
+ {#each split.visible as tab (tab.id)}
191
+ {@render linkTab(tab)}
192
+ {/each}
193
+ </div>
194
+ {#if split.overflow.length > 0}
195
+ <details class="tabs-more" bind:this={moreMenu}>
196
+ <summary class="tab-link">
197
+ <span class="tab-label">{moreLabel}</span>
198
+ {#if overflowHasBadge}
199
+ <span class="tab-dot" aria-hidden="true"></span>
200
+ <span class="visually-hidden">({moreBadgeLabel})</span>
201
+ {/if}
202
+ </summary>
203
+ <div class="tabs-more-menu">
204
+ {#each split.overflow as tab (tab.id)}
205
+ {@render linkTab(tab)}
206
+ {/each}
207
+ </div>
208
+ </details>
209
+ {/if}
210
+ </nav>
211
+ {#if children}
212
+ <div class="tab-panel">
213
+ {@render children()}
214
+ </div>
215
+ {/if}
216
+ </div>
217
+ {:else}
104
218
  <div class="tabs-container">
105
219
  <div
106
220
  class="tabs-nav"
@@ -132,6 +246,9 @@ function handleKeydown(event: KeyboardEvent, tabId: string) {
132
246
  {#if tab.count !== undefined}
133
247
  <span class="tab-count">{tab.count}</span>
134
248
  {/if}
249
+ {#if hasBadge(tab)}
250
+ <span class="tab-badge">{tab.badge}</span>
251
+ {/if}
135
252
  </span>
136
253
  <div class="active-indicator"></div>
137
254
  </button>
@@ -149,6 +266,7 @@ function handleKeydown(event: KeyboardEvent, tabId: string) {
149
266
  </div>
150
267
  {/if}
151
268
  </div>
269
+ {/if}
152
270
 
153
271
  <style>
154
272
  .tabs-container {
@@ -249,4 +367,127 @@ function handleKeydown(event: KeyboardEvent, tabId: string) {
249
367
  .tab-panel {
250
368
  padding-top: 1.5rem;
251
369
  }
370
+
371
+ .tab-badge {
372
+ min-width: 1.25rem;
373
+ padding: 0.05rem 0.4rem;
374
+ border-radius: 999px;
375
+ background: var(--smrt-color-surface-container-high);
376
+ color: var(--smrt-color-on-surface);
377
+ font: var(--smrt-typography-label-small-font);
378
+ font-weight: var(--smrt-typography-weight-bold, 700);
379
+ text-align: center;
380
+ }
381
+
382
+ /* Link tabs: a navigation row, one URL per tab. */
383
+ .tabs-links {
384
+ display: flex;
385
+ align-items: stretch;
386
+ gap: var(--smrt-spacing-1, 4px);
387
+ width: 100%;
388
+ border-bottom: 1px solid var(--smrt-color-outline-variant);
389
+ }
390
+
391
+ .tabs-links-row {
392
+ display: flex;
393
+ flex: 1 1 auto;
394
+ min-width: 0;
395
+ gap: var(--smrt-spacing-1, 4px);
396
+ overflow-x: auto;
397
+ scrollbar-width: thin;
398
+ }
399
+
400
+ .tab-link {
401
+ display: inline-flex;
402
+ flex: 0 0 auto;
403
+ align-items: center;
404
+ gap: var(--smrt-spacing-2, 8px);
405
+ min-height: 44px;
406
+ padding: 0 var(--smrt-spacing-4, 16px);
407
+ margin-bottom: -1px;
408
+ border-bottom: 2px solid transparent;
409
+ color: var(--smrt-color-on-surface-variant);
410
+ font: var(--smrt-typography-title-small-font);
411
+ font-weight: var(--smrt-typography-weight-semibold, 600);
412
+ text-decoration: none;
413
+ white-space: nowrap;
414
+ cursor: pointer;
415
+ }
416
+
417
+ .tabs-links.sm .tab-link {
418
+ min-height: 40px;
419
+ font: var(--smrt-typography-label-large-font);
420
+ }
421
+
422
+ .tabs-links.lg .tab-link {
423
+ min-height: 56px;
424
+ font: var(--smrt-typography-title-medium-font);
425
+ }
426
+
427
+ .tab-link:hover {
428
+ color: var(--smrt-color-on-surface);
429
+ }
430
+
431
+ .tab-link.active {
432
+ border-bottom-color: var(--smrt-color-primary);
433
+ color: var(--smrt-color-primary);
434
+ }
435
+
436
+ .tab-link[aria-disabled='true'] {
437
+ opacity: 0.38;
438
+ cursor: not-allowed;
439
+ }
440
+
441
+ .tab-link:focus-visible {
442
+ outline: 2px solid var(--smrt-color-primary);
443
+ outline-offset: -2px;
444
+ }
445
+
446
+ .tab-dot {
447
+ width: 0.5rem;
448
+ height: 0.5rem;
449
+ border-radius: 999px;
450
+ background: var(--smrt-color-primary);
451
+ }
452
+
453
+ .tabs-more {
454
+ position: relative;
455
+ flex: 0 0 auto;
456
+ }
457
+
458
+ .tabs-more summary {
459
+ list-style: none;
460
+ }
461
+
462
+ .tabs-more summary::-webkit-details-marker {
463
+ display: none;
464
+ }
465
+
466
+ .tabs-more-menu {
467
+ position: absolute;
468
+ inset-inline-end: 0;
469
+ z-index: var(--smrt-z-index-dropdown, 20);
470
+ display: grid;
471
+ min-width: 12rem;
472
+ padding: var(--smrt-spacing-1, 4px);
473
+ border: 1px solid var(--smrt-color-outline-variant);
474
+ border-radius: var(--smrt-radius-small, 0.5rem);
475
+ background: var(--smrt-color-surface);
476
+ box-shadow: var(--smrt-elevation-2);
477
+ }
478
+
479
+ .tabs-more-menu .tab-link {
480
+ margin: 0;
481
+ border-bottom: 0;
482
+ border-radius: var(--smrt-radius-extra-small, 0.35rem);
483
+ }
484
+
485
+ .visually-hidden {
486
+ position: absolute;
487
+ width: 1px;
488
+ height: 1px;
489
+ overflow: hidden;
490
+ clip: rect(0 0 0 0);
491
+ white-space: nowrap;
492
+ }
252
493
  </style>
@@ -9,6 +9,15 @@
9
9
  * - Proper ARIA roles: tablist, tab, tabpanel
10
10
  * - aria-selected indicates active tab
11
11
  * - aria-controls links tab to panel
12
+ *
13
+ * Link tabs: give every tab an `href` and the row renders as navigation — a
14
+ * `<nav>` of links with `aria-current="page"` on the active one, each tab its
15
+ * own URL (a content item's Article / Images / Review pages). With
16
+ * `maxVisible`, extra link tabs move to a "More" menu while the active tab
17
+ * always stays in the row; `badge` marks a tab needing attention (and puts a
18
+ * dot on "More" when a hidden tab has one). The row scrolls sideways on
19
+ * phones and keeps the active tab scrolled into view. It carries
20
+ * `data-shell-tabs`, so AdminShell keeps it sticky under its phone top bar.
12
21
  */
13
22
  import type { Snippet } from 'svelte';
14
23
  import type { Tab } from './types.js';
@@ -27,6 +36,12 @@ export interface Props {
27
36
  children?: Snippet;
28
37
  /** Accessible label for the tablist */
29
38
  'aria-label'?: string;
39
+ /** Link tabs only: most tabs shown in the row; the rest go under "More". */
40
+ maxVisible?: number;
41
+ /** Link tabs only: label of the overflow menu (default "More"). */
42
+ moreLabel?: string;
43
+ /** Link tabs only: screen-reader text for the "More" attention dot. */
44
+ moreBadgeLabel?: string;
30
45
  }
31
46
  declare const Tabs: import("svelte").Component<Props, {}, "">;
32
47
  type Tabs = ReturnType<typeof Tabs>;
@@ -1 +1 @@
1
- {"version":3,"file":"Tabs.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/nav/Tabs.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAGtC,MAAM,WAAW,KAAK;IACpB,qBAAqB;IACrB,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,mBAAmB;IACnB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,qBAAqB;IACrB,OAAO,CAAC,EAAE,SAAS,GAAG,WAAW,CAAC;IAClC,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAsGD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
1
+ {"version":3,"file":"Tabs.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/nav/Tabs.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAGtC,MAAM,WAAW,KAAK;IACpB,qBAAqB;IACrB,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,mBAAmB;IACnB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,qBAAqB;IACrB,OAAO,CAAC,EAAE,SAAS,GAAG,WAAW,CAAC;IAClC,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AA+LD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
@@ -0,0 +1,117 @@
1
+ /** Link tabs: a navigation row of URLs with overflow, badges, active-in-view. */
2
+ import { render, screen, within } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
6
+ import Tabs from '../Tabs.svelte';
7
+ import { splitTabs } from '../tabs-overflow.js';
8
+ const TABS = [
9
+ { id: 'article', label: 'Article', href: '/c/1' },
10
+ { id: 'images', label: 'Images', href: '/c/1/images', badge: 3 },
11
+ { id: 'videos', label: 'Videos', href: '/c/1/videos' },
12
+ { id: 'social', label: 'Social', href: '/c/1/social' },
13
+ { id: 'review', label: 'Review', href: '/c/1/review', badge: '!' },
14
+ { id: 'jobs', label: 'Background work', href: '/c/1/jobs' },
15
+ ];
16
+ describe('splitTabs', () => {
17
+ it('keeps everything in the row under the limit', () => {
18
+ expect(splitTabs(TABS, 'article', 10).overflow).toEqual([]);
19
+ expect(splitTabs(TABS, 'article', undefined).overflow).toEqual([]);
20
+ });
21
+ it('promotes the active tab into the row', () => {
22
+ const { visible, overflow } = splitTabs(TABS, 'jobs', 4);
23
+ expect(visible.map((tab) => tab.id)).toEqual([
24
+ 'article',
25
+ 'images',
26
+ 'videos',
27
+ 'jobs',
28
+ ]);
29
+ expect(overflow.map((tab) => tab.id)).toEqual(['social', 'review']);
30
+ });
31
+ });
32
+ describe('Tabs with hrefs', () => {
33
+ it('renders navigation links, not a tablist, with aria-current on the active tab', () => {
34
+ render(Tabs, {
35
+ props: { tabs: TABS, active: 'images', 'aria-label': 'Content sections' },
36
+ });
37
+ const nav = screen.getByRole('navigation', { name: 'Content sections' });
38
+ expect(nav).toHaveAttribute('data-shell-tabs');
39
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
40
+ const active = within(nav).getByRole('link', { name: /Images/ });
41
+ expect(active).toHaveAttribute('href', '/c/1/images');
42
+ expect(active).toHaveAttribute('aria-current', 'page');
43
+ expect(active).toHaveTextContent('3');
44
+ expect(within(nav).getByRole('link', { name: 'Article' })).not.toHaveAttribute('aria-current');
45
+ });
46
+ it('moves extra tabs under "More", keeps the active one visible, and dots the menu for a hidden badge', async () => {
47
+ const { container } = render(Tabs, {
48
+ props: {
49
+ tabs: TABS,
50
+ active: 'jobs',
51
+ maxVisible: 4,
52
+ 'aria-label': 'Content sections',
53
+ },
54
+ });
55
+ const row = container.querySelector('.tabs-links-row');
56
+ expect(within(row)
57
+ .getAllByRole('link')
58
+ .map((link) => link.dataset.tabId)).toEqual(['article', 'images', 'videos', 'jobs']);
59
+ const more = container.querySelector('details.tabs-more');
60
+ expect(within(more).getByText('More')).toBeInTheDocument();
61
+ expect(within(more).getByText('(has updates)')).toBeInTheDocument();
62
+ await userEvent.click(within(more).getByText('More'));
63
+ expect(more.open).toBe(true);
64
+ expect(within(more).getByRole('link', { name: /Review/ })).toHaveAttribute('href', '/c/1/review');
65
+ });
66
+ it('a disabled link tab never fires onchange', async () => {
67
+ const onchange = vi.fn();
68
+ render(Tabs, {
69
+ props: {
70
+ tabs: [
71
+ { id: 'article', label: 'Article', href: '/c/1' },
72
+ {
73
+ id: 'locked',
74
+ label: 'Locked',
75
+ href: '/c/1/locked',
76
+ disabled: true,
77
+ },
78
+ ],
79
+ active: 'article',
80
+ onchange,
81
+ 'aria-label': 'Content sections',
82
+ },
83
+ });
84
+ const locked = screen.getByText('Locked').closest('a');
85
+ await userEvent.click(locked);
86
+ expect(onchange).not.toHaveBeenCalled();
87
+ });
88
+ it('does not dot "More" for an empty-string badge, matching the tab itself', () => {
89
+ const { container } = render(Tabs, {
90
+ props: {
91
+ tabs: [
92
+ { id: 'a', label: 'A', href: '/a' },
93
+ { id: 'b', label: 'B', href: '/b' },
94
+ { id: 'c', label: 'C', href: '/c', badge: '' },
95
+ ],
96
+ active: 'a',
97
+ maxVisible: 2,
98
+ 'aria-label': 'Sections',
99
+ },
100
+ });
101
+ const more = container.querySelector('details.tabs-more');
102
+ expect(more).not.toBeNull();
103
+ expect(more.querySelector('.tab-dot')).toBeNull();
104
+ expect(more.querySelector('.tab-badge')).toBeNull();
105
+ });
106
+ it('is axe-clean', async () => {
107
+ const { container } = render(Tabs, {
108
+ props: {
109
+ tabs: TABS,
110
+ active: 'article',
111
+ maxVisible: 4,
112
+ 'aria-label': 'Content sections',
113
+ },
114
+ });
115
+ await expectNoA11yViolations(container);
116
+ });
117
+ });
@@ -3,5 +3,6 @@
3
3
  */
4
4
  export { default as FilterChips } from './FilterChips.svelte';
5
5
  export { default as Tabs } from './Tabs.svelte';
6
+ export { splitTabs } from './tabs-overflow.js';
6
7
  export type { FilterOption, Tab } from './types.js';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/nav/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,eAAe,CAAC;AAEhD,YAAY,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/nav/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE/C,YAAY,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC"}
@@ -4,3 +4,4 @@
4
4
  // Export components
5
5
  export { default as FilterChips } from './FilterChips.svelte';
6
6
  export { default as Tabs } from './Tabs.svelte';
7
+ export { splitTabs } from './tabs-overflow.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Split tabs into the row and a "More" menu, keeping the active tab in the
3
+ * row: with more tabs than `maxVisible`, the first `maxVisible - 1` show, then
4
+ * the active tab (or the next one), and the rest go to the menu.
5
+ */
6
+ export declare function splitTabs<T extends {
7
+ id: string;
8
+ }>(tabs: readonly T[], activeId: string | null | undefined, maxVisible: number | undefined): {
9
+ visible: T[];
10
+ overflow: T[];
11
+ };
12
+ //# sourceMappingURL=tabs-overflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabs-overflow.d.ts","sourceRoot":"","sources":["../../../src/components/nav/tabs-overflow.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAChD,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B;IAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,EAAE,CAAC,EAAE,CAAA;CAAE,CAWjC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Split tabs into the row and a "More" menu, keeping the active tab in the
3
+ * row: with more tabs than `maxVisible`, the first `maxVisible - 1` show, then
4
+ * the active tab (or the next one), and the rest go to the menu.
5
+ */
6
+ export function splitTabs(tabs, activeId, maxVisible) {
7
+ if (!maxVisible || maxVisible < 1 || tabs.length <= maxVisible) {
8
+ return { visible: [...tabs], overflow: [] };
9
+ }
10
+ const visible = tabs.slice(0, maxVisible - 1);
11
+ const rest = tabs.slice(maxVisible - 1);
12
+ const promoted = rest.find((tab) => tab.id === activeId) ?? rest[0];
13
+ return {
14
+ visible: promoted ? [...visible, promoted] : visible,
15
+ overflow: rest.filter((tab) => tab !== promoted),
16
+ };
17
+ }
@@ -20,5 +20,13 @@ export interface Tab {
20
20
  count?: number;
21
21
  /** Disabled state */
22
22
  disabled?: boolean;
23
+ /**
24
+ * Makes this a link tab: the tab row renders as navigation (`<nav>` of
25
+ * links, `aria-current="page"` on the active one) instead of a tablist.
26
+ * Give every tab an `href` or none.
27
+ */
28
+ href?: string;
29
+ /** Short attention badge (e.g. `3` pending, `!`); `null`/absent shows none. */
30
+ badge?: string | number | null;
23
31
  }
24
32
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/nav/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,YAAY;IAC3B,mBAAmB;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,oBAAoB;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,GAAG;IAClB,qBAAqB;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,oBAAoB;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/components/nav/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,YAAY;IAC3B,mBAAmB;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,oBAAoB;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,GAAG;IAClB,qBAAqB;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,oBAAoB;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAChC"}
@@ -295,6 +295,13 @@ function handleClick(event: MouseEvent) {
295
295
  outline-offset: 2px;
296
296
  }
297
297
 
298
+ /* Phones: every size is at least a 44px touch target. */
299
+ @media (max-width: 48rem) {
300
+ .button {
301
+ min-block-size: 2.75rem;
302
+ }
303
+ }
304
+
298
305
  /* Reduced motion */
299
306
  @media (prefers-reduced-motion: reduce) {
300
307
  .spinner {
@@ -52,15 +52,24 @@ const {
52
52
  </div>
53
53
 
54
54
  <style>
55
+ /*
56
+ * Themeable look (app-level custom properties; defaults keep the stock look):
57
+ * --smrt-card-border edge of default and elevated cards
58
+ * --smrt-card-background card fill
59
+ * --smrt-card-shadow shadow of default cards (elevated keeps its elevation)
60
+ * --smrt-card-divider rule between the header/footer and the content
61
+ * The `outlined` variant always draws its outline.
62
+ */
55
63
  .card {
56
- background: var(--smrt-color-surface);
64
+ background: var(--smrt-card-background, var(--smrt-color-surface));
57
65
  border-radius: var(--smrt-radius-medium);
58
66
  transition: all var(--smrt-duration-short2) var(--smrt-easing-standard);
59
67
  }
60
68
 
61
69
  /* Variants */
62
70
  .default {
63
- border: 1px solid var(--smrt-color-outline-variant);
71
+ border: var(--smrt-card-border, 1px solid var(--smrt-color-outline-variant));
72
+ box-shadow: var(--smrt-card-shadow, none);
64
73
  }
65
74
 
66
75
  .outlined {
@@ -68,7 +77,7 @@ const {
68
77
  }
69
78
 
70
79
  .elevated {
71
- border: 1px solid var(--smrt-color-outline-variant);
80
+ border: var(--smrt-card-border, 1px solid var(--smrt-color-outline-variant));
72
81
  box-shadow: var(--smrt-elevation-2);
73
82
  }
74
83
 
@@ -98,7 +107,7 @@ const {
98
107
  /* Header */
99
108
  .card-header {
100
109
  padding: var(--smrt-spacing-6);
101
- border-bottom: 1px solid var(--smrt-color-outline-variant);
110
+ border-bottom: var(--smrt-card-divider, 1px solid var(--smrt-color-outline-variant));
102
111
  }
103
112
 
104
113
  .padding-sm .card-header {
@@ -112,7 +121,7 @@ const {
112
121
  /* Footer */
113
122
  .card-footer {
114
123
  padding: var(--smrt-spacing-6);
115
- border-top: 1px solid var(--smrt-color-outline-variant);
124
+ border-top: var(--smrt-card-divider, 1px solid var(--smrt-color-outline-variant));
116
125
  background: var(--smrt-color-surface-container-low);
117
126
  border-bottom-left-radius: var(--smrt-radius-medium);
118
127
  border-bottom-right-radius: var(--smrt-radius-medium);