@dorsk/tsumikit 0.74.0 → 0.76.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 (132) hide show
  1. package/README.md +38 -1
  2. package/dist/autoresize.d.ts +2 -0
  3. package/dist/autoresize.js +8 -1
  4. package/dist/components/atoms/Artwork.svelte +12 -5
  5. package/dist/components/atoms/Avatar.svelte +1 -1
  6. package/dist/components/atoms/Badge.svelte +59 -23
  7. package/dist/components/atoms/Button.svelte +130 -96
  8. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  9. package/dist/components/atoms/Card.svelte +10 -4
  10. package/dist/components/atoms/Checkbox.svelte +36 -9
  11. package/dist/components/atoms/Checkbox.svelte.d.ts +2 -0
  12. package/dist/components/atoms/Gauge.svelte +20 -14
  13. package/dist/components/atoms/Heading.svelte +1 -1
  14. package/dist/components/atoms/Icon.svelte +1 -0
  15. package/dist/components/atoms/Input.svelte +67 -13
  16. package/dist/components/atoms/Kbd.svelte +3 -3
  17. package/dist/components/atoms/Link.svelte +7 -2
  18. package/dist/components/atoms/Progress.svelte +4 -2
  19. package/dist/components/atoms/Scrim.svelte +3 -8
  20. package/dist/components/atoms/SegmentedProgress.svelte +5 -4
  21. package/dist/components/atoms/Select.svelte +58 -27
  22. package/dist/components/atoms/Slider.svelte +79 -22
  23. package/dist/components/atoms/Slider.svelte.d.ts +2 -0
  24. package/dist/components/atoms/Swatch.svelte +19 -8
  25. package/dist/components/atoms/Switch.svelte +34 -21
  26. package/dist/components/atoms/Switch.svelte.d.ts +2 -0
  27. package/dist/components/atoms/Text.svelte +1 -1
  28. package/dist/components/atoms/Textarea.svelte +34 -12
  29. package/dist/components/atoms/TimeDisplay.svelte +4 -2
  30. package/dist/components/layouts/AppShell.svelte +26 -15
  31. package/dist/components/layouts/MasterDetail.svelte +23 -21
  32. package/dist/components/layouts/NavBar.svelte +11 -8
  33. package/dist/components/layouts/NavItem.svelte +15 -7
  34. package/dist/components/layouts/NavSection.svelte +1 -1
  35. package/dist/components/layouts/ResizablePanel.svelte +37 -16
  36. package/dist/components/layouts/TabBar.svelte +24 -14
  37. package/dist/components/layouts/TabBar.svelte.d.ts +2 -0
  38. package/dist/components/molecules/Accordion.svelte +4 -0
  39. package/dist/components/molecules/Accordion.svelte.d.ts +2 -0
  40. package/dist/components/molecules/AttachmentList.svelte +14 -11
  41. package/dist/components/molecules/Breadcrumb.svelte +13 -9
  42. package/dist/components/molecules/Callout.svelte +3 -3
  43. package/dist/components/molecules/CapBar.svelte +23 -22
  44. package/dist/components/molecules/Carousel.svelte +27 -17
  45. package/dist/components/molecules/ChatBubble.svelte +2 -2
  46. package/dist/components/molecules/CodeBlock.svelte +2 -2
  47. package/dist/components/molecules/Combobox.svelte +30 -1
  48. package/dist/components/molecules/Combobox.svelte.d.ts +4 -0
  49. package/dist/components/molecules/Composer.svelte +2 -2
  50. package/dist/components/molecules/ConfirmModal.svelte +4 -0
  51. package/dist/components/molecules/ConfirmModal.svelte.d.ts +2 -0
  52. package/dist/components/molecules/CopyButton.svelte +46 -2
  53. package/dist/components/molecules/CopyButton.svelte.d.ts +2 -0
  54. package/dist/components/molecules/Disclosure.svelte +23 -12
  55. package/dist/components/molecules/Disclosure.svelte.d.ts +2 -0
  56. package/dist/components/molecules/Drawer.svelte +43 -23
  57. package/dist/components/molecules/Drawer.svelte.d.ts +2 -0
  58. package/dist/components/molecules/Dropzone.svelte +24 -8
  59. package/dist/components/molecules/EmojiPicker.svelte +27 -8
  60. package/dist/components/molecules/EmojiPicker.svelte.d.ts +2 -0
  61. package/dist/components/molecules/EmptyState.svelte +5 -5
  62. package/dist/components/molecules/Fieldset.svelte +8 -13
  63. package/dist/components/molecules/FileButton.svelte +57 -31
  64. package/dist/components/molecules/FileButton.svelte.d.ts +2 -0
  65. package/dist/components/molecules/FilterInput.svelte +51 -11
  66. package/dist/components/molecules/FontScalePicker.svelte +20 -9
  67. package/dist/components/molecules/GitRef.svelte +9 -7
  68. package/dist/components/molecules/GlyphStack.svelte +46 -14
  69. package/dist/components/molecules/IconButton.svelte +6 -3
  70. package/dist/components/molecules/IconButton.svelte.d.ts +2 -0
  71. package/dist/components/molecules/InputGroup.svelte +13 -12
  72. package/dist/components/molecules/LoadMore.svelte +6 -1
  73. package/dist/components/molecules/LoadMore.svelte.d.ts +2 -0
  74. package/dist/components/molecules/Menu.svelte +33 -7
  75. package/dist/components/molecules/Menu.svelte.d.ts +2 -0
  76. package/dist/components/molecules/Metric.svelte +11 -9
  77. package/dist/components/molecules/Modal.svelte +76 -26
  78. package/dist/components/molecules/Modal.svelte.d.ts +2 -0
  79. package/dist/components/molecules/OptionButton.svelte +28 -10
  80. package/dist/components/molecules/OptionButton.svelte.d.ts +2 -0
  81. package/dist/components/molecules/Pagination.svelte +26 -9
  82. package/dist/components/molecules/Pagination.svelte.d.ts +2 -0
  83. package/dist/components/molecules/Popover.svelte +107 -51
  84. package/dist/components/molecules/Popover.svelte.d.ts +2 -0
  85. package/dist/components/molecules/Prose.svelte +1 -1
  86. package/dist/components/molecules/PullToRefresh.svelte +14 -10
  87. package/dist/components/molecules/PullToRefresh.svelte.d.ts +2 -0
  88. package/dist/components/molecules/RadialMenu.svelte +52 -19
  89. package/dist/components/molecules/RadialMenu.svelte.d.ts +2 -0
  90. package/dist/components/molecules/RadioGroup.svelte +38 -23
  91. package/dist/components/molecules/RadioGroup.svelte.d.ts +2 -0
  92. package/dist/components/molecules/RichSelect.svelte +583 -0
  93. package/dist/components/molecules/RichSelect.svelte.d.ts +68 -0
  94. package/dist/components/molecules/SectionHeader.svelte +13 -8
  95. package/dist/components/molecules/SeekSlider.svelte +38 -27
  96. package/dist/components/molecules/SeekSlider.svelte.d.ts +2 -0
  97. package/dist/components/molecules/SegmentedControl.svelte +139 -86
  98. package/dist/components/molecules/SegmentedControl.svelte.d.ts +2 -0
  99. package/dist/components/molecules/SelectButton.svelte +21 -6
  100. package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
  101. package/dist/components/molecules/SortableList.svelte +10 -11
  102. package/dist/components/molecules/SoundSettings.svelte +161 -0
  103. package/dist/components/molecules/SoundSettings.svelte.d.ts +17 -0
  104. package/dist/components/molecules/SplitButton.svelte +30 -3
  105. package/dist/components/molecules/SplitButton.svelte.d.ts +2 -0
  106. package/dist/components/molecules/SwipeRow.svelte +18 -15
  107. package/dist/components/molecules/SwipeRow.svelte.d.ts +2 -0
  108. package/dist/components/molecules/Tabs.svelte +92 -10
  109. package/dist/components/molecules/Tabs.svelte.d.ts +2 -0
  110. package/dist/components/molecules/ThemePicker.svelte +25 -10
  111. package/dist/components/molecules/Timestamp.svelte +12 -7
  112. package/dist/components/molecules/Toaster.svelte +65 -14
  113. package/dist/components/molecules/Toaster.svelte.d.ts +2 -0
  114. package/dist/components/molecules/Toggle.svelte +36 -13
  115. package/dist/components/molecules/Toggle.svelte.d.ts +2 -0
  116. package/dist/components/molecules/Tooltip.svelte +41 -10
  117. package/dist/components/molecules/Tooltip.svelte.d.ts +1 -1
  118. package/dist/components/molecules/VolumeSlider.svelte +34 -14
  119. package/dist/components/molecules/VolumeSlider.svelte.d.ts +2 -0
  120. package/dist/components/molecules/WorkingDir.svelte +1 -0
  121. package/dist/components/molecules/rich-select-keyboard.d.ts +82 -0
  122. package/dist/components/molecules/rich-select-keyboard.js +114 -0
  123. package/dist/components/organisms/ConversationFeed.svelte +24 -1
  124. package/dist/components/organisms/DataTable.svelte +37 -14
  125. package/dist/floating.js +26 -0
  126. package/dist/index.d.ts +2 -0
  127. package/dist/index.js +2 -0
  128. package/dist/sound-synth.js +17 -12
  129. package/dist/stores/toast.svelte.d.ts +9 -0
  130. package/dist/stores/toast.svelte.js +45 -9
  131. package/dist/styles/reset.css +3 -3
  132. package/package.json +1 -1
@@ -0,0 +1,161 @@
1
+ <script lang="ts">
2
+ // Opt-in UI sound control: a speaker trigger opens a popover with the
3
+ // on/off switch, a volume slider (disabled while off), a preview button and
4
+ // the reason nothing plays (off, or reduced motion muting decorative sounds).
5
+ import type { ComponentProps } from 'svelte';
6
+ import Button from '../atoms/Button.svelte';
7
+ import Icon from '../atoms/Icon.svelte';
8
+ import Slider from '../atoms/Slider.svelte';
9
+ import Switch from '../atoms/Switch.svelte';
10
+ import Popover from './Popover.svelte';
11
+ import { sound } from '../../stores/sound.svelte';
12
+
13
+ type TriggerChrome = Pick<
14
+ ComponentProps<typeof Popover>,
15
+ 'variant' | 'tone' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'
16
+ >;
17
+
18
+ let {
19
+ size = 'md',
20
+ box = size,
21
+ placement = 'bottom-end',
22
+ variant,
23
+ tone,
24
+ pill,
25
+ control,
26
+ block,
27
+ bare,
28
+ hitArea,
29
+ disabled,
30
+ label = 'Sound',
31
+ switchLabel = 'Sound effects',
32
+ volumeLabel = 'Volume',
33
+ previewLabel = 'Preview',
34
+ offHint = 'Off. Nothing is loaded until you turn it on.',
35
+ reducedMotionHint = 'Decorative sounds are muted because your system asks for reduced motion.',
36
+ class: klass = '',
37
+ style: styleProp = '',
38
+ }: TriggerChrome & {
39
+ size?: 'sm' | 'md';
40
+ label?: string;
41
+ switchLabel?: string;
42
+ volumeLabel?: string;
43
+ previewLabel?: string;
44
+ offHint?: string;
45
+ reducedMotionHint?: string;
46
+ class?: string;
47
+ style?: string;
48
+ } = $props();
49
+
50
+ const icon = $derived(
51
+ !sound.enabled || sound.volume === 0 ? 'volume-off' : sound.volume < 0.5 ? 'volume-low' : 'volume-high'
52
+ );
53
+ const percent = $derived(Math.round(sound.volume * 100));
54
+ const hint = $derived(
55
+ sound.muted === 'off' ? offHint : sound.muted === 'reduced-motion' ? reducedMotionHint : null
56
+ );
57
+ </script>
58
+
59
+ <Popover
60
+ {label}
61
+ {placement}
62
+ {box}
63
+ {variant}
64
+ {tone}
65
+ {pill}
66
+ {control}
67
+ {block}
68
+ {bare}
69
+ {hitArea}
70
+ {disabled}
71
+ triggerClass={klass}
72
+ style={styleProp}
73
+ sound={false}
74
+ >
75
+ {#snippet trigger()}
76
+ <span data-tsu="SoundSettings" class="glyph" title="{label}: {sound.enabled ? `${percent}%` : 'off'}">
77
+ <Icon name={icon} size={size === 'sm' ? 16 : 18} />
78
+ </span>
79
+ {/snippet}
80
+ <div class="panel" class:panel-sm={size === 'sm'}>
81
+ <div class="row">
82
+ <Switch
83
+ label={switchLabel}
84
+ labelVisible
85
+ size={size === 'sm' ? 'sm' : 'md'}
86
+ bind:checked={() => sound.enabled, (on) => sound.setEnabled(on)}
87
+ />
88
+ <Button
89
+ size="sm"
90
+ variant="ghost"
91
+ disabled={!sound.enabled}
92
+ sound={false}
93
+ onclick={() => sound.play('success')}>{previewLabel}</Button
94
+ >
95
+ </div>
96
+ <div class="row">
97
+ <Icon name="volume" size={16} />
98
+ <Slider
99
+ value={sound.volume}
100
+ min={0}
101
+ max={1}
102
+ step={0.05}
103
+ label={volumeLabel}
104
+ aria-valuetext="{percent}%"
105
+ disabled={!sound.enabled}
106
+ oninput={(e) => sound.setVolume(Number((e.currentTarget as HTMLInputElement).value))}
107
+ />
108
+ <span class="value">{percent}%</span>
109
+ </div>
110
+ {#if hint}
111
+ <div class="hint" aria-live="polite">{hint}</div>
112
+ {/if}
113
+ </div>
114
+ </Popover>
115
+
116
+ <style>
117
+ .glyph {
118
+ display: inline-flex;
119
+ line-height: var(--lh-none);
120
+ }
121
+ .panel {
122
+ width: min(17rem, calc(100vw - var(--sp-6)));
123
+ box-sizing: border-box;
124
+ display: grid;
125
+ gap: var(--sp-2);
126
+ padding: var(--sp-3);
127
+ font-size: 0.875rem;
128
+ line-height: var(--lh-snug);
129
+ }
130
+ .panel-sm {
131
+ width: min(15rem, calc(100vw - var(--sp-6)));
132
+ padding: var(--sp-2) var(--sp-3);
133
+ font-size: 0.8125rem;
134
+ }
135
+ .row {
136
+ display: flex;
137
+ align-items: center;
138
+ gap: var(--sp-2);
139
+ min-width: 0;
140
+ color: var(--text-muted);
141
+ }
142
+ .row > :global(.switch-row) {
143
+ flex: 1 1 auto;
144
+ color: var(--text);
145
+ }
146
+ .row > :global(.slider) {
147
+ flex: 1 1 0;
148
+ min-width: 0;
149
+ }
150
+ .value {
151
+ flex: none;
152
+ min-width: 2.5rem;
153
+ text-align: end;
154
+ font-size: 0.75rem;
155
+ font-variant-numeric: tabular-nums;
156
+ }
157
+ .hint {
158
+ font-size: 0.75rem;
159
+ color: var(--text-muted);
160
+ }
161
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { ComponentProps } from 'svelte';
2
+ import Popover from './Popover.svelte';
3
+ type TriggerChrome = Pick<ComponentProps<typeof Popover>, 'variant' | 'tone' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'>;
4
+ type $$ComponentProps = TriggerChrome & {
5
+ size?: 'sm' | 'md';
6
+ label?: string;
7
+ switchLabel?: string;
8
+ volumeLabel?: string;
9
+ previewLabel?: string;
10
+ offHint?: string;
11
+ reducedMotionHint?: string;
12
+ class?: string;
13
+ style?: string;
14
+ };
15
+ declare const SoundSettings: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type SoundSettings = ReturnType<typeof SoundSettings>;
17
+ export default SoundSettings;
@@ -48,6 +48,8 @@
48
48
  loading?: boolean;
49
49
  /** Handler of the primary action. */
50
50
  onclick?: (e: MouseEvent) => void;
51
+ /** Play UI sounds when enabled globally; false silences this instance. */
52
+ sound?: boolean;
51
53
  class?: string;
52
54
  style?: string;
53
55
  /** Content of the primary action. */
@@ -75,6 +77,7 @@
75
77
  type = 'button',
76
78
  loading = false,
77
79
  onclick,
80
+ sound = true,
78
81
  class: klass = '',
79
82
  style: styleProp = '',
80
83
  children,
@@ -102,6 +105,8 @@
102
105
  class="split {klass}"
103
106
  class:split-primary={variant === 'primary'}
104
107
  class:split-half={caret === 'half'}
108
+ class:split-sm={size === 'sm'}
109
+ class:split-lg={size === 'lg'}
105
110
  style={styleProp}
106
111
  style:--split-height={height}
107
112
  >
@@ -114,6 +119,7 @@
114
119
  {type}
115
120
  {loading}
116
121
  {disabled}
122
+ {sound}
117
123
  block
118
124
  {onclick}
119
125
  onkeydown={onPrimaryKeydown}
@@ -139,7 +145,7 @@
139
145
  {onopen}
140
146
  {onclose}
141
147
  >
142
- {#snippet trigger()}<Icon name="chevron-down" size={size === 'sm' ? 14 : 16} />{/snippet}
148
+ {#snippet trigger()}<span class="caret-glyph"><Icon name="chevron-down" /></span>{/snippet}
143
149
  </Menu>
144
150
  </span>
145
151
  </div>
@@ -169,14 +175,35 @@
169
175
  .split-half .split-caret {
170
176
  width: calc(var(--split-height) / 2);
171
177
  }
178
+ .caret-glyph {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ font-size: var(--control-icon-md);
182
+ }
183
+ .split-sm .caret-glyph {
184
+ font-size: var(--control-icon-sm);
185
+ }
186
+ .split-lg .caret-glyph {
187
+ font-size: var(--control-icon-lg);
188
+ }
189
+ /* Coarse pointers: the narrow caret keeps a 24px hit box without changing its drawn width. */
190
+ @media (pointer: coarse) {
191
+ .split-half .split-caret {
192
+ min-width: var(--box-xs);
193
+ }
194
+ }
172
195
  /* The hovered or focused segment paints above its neighbour, so its
173
196
  accent border and focus ring are not cut by the overlap. */
174
- .split-main:hover,
175
197
  .split-main:focus-within,
176
- .split-caret:hover,
177
198
  .split-caret:focus-within {
178
199
  z-index: 1;
179
200
  }
201
+ @media (hover: hover) and (pointer: fine) {
202
+ .split-main:hover,
203
+ .split-caret:hover {
204
+ z-index: 1;
205
+ }
206
+ }
180
207
  /* On a filled primary the borders match the fill; paint the divider in the
181
208
  on-accent foreground instead. */
182
209
  .split-primary .split-caret::before {
@@ -40,6 +40,8 @@ type Own = {
40
40
  loading?: boolean;
41
41
  /** Handler of the primary action. */
42
42
  onclick?: (e: MouseEvent) => void;
43
+ /** Play UI sounds when enabled globally; false silences this instance. */
44
+ sound?: boolean;
43
45
  class?: string;
44
46
  style?: string;
45
47
  /** Content of the primary action. */
@@ -27,6 +27,7 @@
27
27
  import type { HTMLAttributes } from 'svelte/elements';
28
28
  import Icon from '../atoms/Icon.svelte';
29
29
  import { canonicalTone } from '../../tone';
30
+ import { playSound } from '../../sound';
30
31
 
31
32
  type Own = {
32
33
  children: Snippet;
@@ -40,6 +41,8 @@
40
41
  /** Fraction of the row width the content must travel to fire. */
41
42
  threshold?: number;
42
43
  disabled?: boolean;
44
+ /** Play UI sounds when enabled globally; false silences this instance. */
45
+ sound?: boolean;
43
46
  class?: string;
44
47
  style?: string;
45
48
  };
@@ -52,6 +55,7 @@
52
55
  trailing,
53
56
  threshold = 0.35,
54
57
  disabled = false,
58
+ sound = true,
55
59
  class: klass = '',
56
60
  style: styleProp,
57
61
  ...rest
@@ -150,7 +154,12 @@
150
154
  const fire = locked === 'x' && armed ? active : undefined;
151
155
  release(el);
152
156
  settle();
153
- fire?.onaction();
157
+ if (fire) run(fire);
158
+ }
159
+
160
+ function run(action: SwipeAction) {
161
+ playSound('click', sound);
162
+ action.onaction();
154
163
  }
155
164
 
156
165
  function onpointercancel(e: PointerEvent) {
@@ -223,12 +232,12 @@
223
232
  {#if leadingAction || trailingAction}
224
233
  <div class="swiperow-actions" data-part="actions">
225
234
  {#if leadingAction}
226
- <button type="button" class="swiperow-sr" data-part="leading-button" {disabled} onclick={() => leadingAction?.onaction()}>
235
+ <button type="button" class="swiperow-sr" data-part="leading-button" {disabled} onclick={() => leadingAction && run(leadingAction)}>
227
236
  {leadingAction.label}
228
237
  </button>
229
238
  {/if}
230
239
  {#if trailingAction}
231
- <button type="button" class="swiperow-sr" data-part="trailing-button" {disabled} onclick={() => trailingAction?.onaction()}>
240
+ <button type="button" class="swiperow-sr" data-part="trailing-button" {disabled} onclick={() => trailingAction && run(trailingAction)}>
232
241
  {trailingAction.label}
233
242
  </button>
234
243
  {/if}
@@ -276,7 +285,7 @@
276
285
  background: var(--swiperow-tone);
277
286
  color: var(--swiperow-ink);
278
287
  font-size: var(--fs-sm);
279
- font-weight: 600;
288
+ font-weight: var(--fw-semibold);
280
289
  visibility: hidden;
281
290
  pointer-events: none;
282
291
  }
@@ -291,10 +300,10 @@
291
300
  visibility: visible;
292
301
  }
293
302
  .swiperow-pane :global(svg) {
294
- transition: transform 120ms var(--ease);
303
+ transition: scale var(--dur-spring) var(--ease-spring);
295
304
  }
296
305
  .swiperow--armed .swiperow-pane :global(svg) {
297
- transform: scale(1.2);
306
+ scale: 1.2;
298
307
  }
299
308
  .swiperow-content {
300
309
  position: relative;
@@ -303,7 +312,7 @@
303
312
  will-change: transform;
304
313
  }
305
314
  .swiperow--settling .swiperow-content {
306
- transition: transform 200ms var(--ease);
315
+ transition: transform var(--dur-exit-slow) var(--ease-out);
307
316
  }
308
317
  .swiperow--dragging .swiperow-content {
309
318
  cursor: grabbing;
@@ -341,13 +350,7 @@
341
350
  color: var(--swiperow-ink);
342
351
  background: var(--swiperow-tone);
343
352
  border-radius: var(--r-sm);
344
- outline: 2px solid var(--accent);
345
- outline-offset: 1px;
346
- }
347
- @media (prefers-reduced-motion: reduce) {
348
- .swiperow--settling .swiperow-content,
349
- .swiperow-pane :global(svg) {
350
- transition: none;
351
- }
353
+ outline: var(--focus-ring);
354
+ outline-offset: var(--sp-px);
352
355
  }
353
356
  </style>
@@ -32,6 +32,8 @@ type Own = {
32
32
  /** Fraction of the row width the content must travel to fire. */
33
33
  threshold?: number;
34
34
  disabled?: boolean;
35
+ /** Play UI sounds when enabled globally; false silences this instance. */
36
+ sound?: boolean;
35
37
  class?: string;
36
38
  style?: string;
37
39
  };
@@ -31,10 +31,12 @@
31
31
  // Without `panel` the tablist stands alone as a document strip: `closable`
32
32
  // tabs gain a close control, Delete and middle-click close the focused tab,
33
33
  // `leading` decorates each tab and `actions` trails the strip.
34
+ import { tick } from 'svelte';
34
35
  import type { Snippet } from 'svelte';
35
36
  import type { HTMLAttributes } from 'svelte/elements';
36
37
  import Icon from '../atoms/Icon.svelte';
37
38
  import IconButton from './IconButton.svelte';
39
+ import { playSound } from '../../sound';
38
40
 
39
41
  let {
40
42
  tabs,
@@ -50,6 +52,7 @@
50
52
  style: styleProp = '',
51
53
  panelClass = '',
52
54
  panelPadding = 'md',
55
+ sound = true,
53
56
  ...rest
54
57
  }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
55
58
 
@@ -73,6 +76,8 @@
73
76
  style?: string;
74
77
  panelClass?: string;
75
78
  panelPadding?: 'none' | 'sm' | 'md';
79
+ /** Play UI sounds when enabled globally; false silences this instance. */
80
+ sound?: boolean;
76
81
  };
77
82
 
78
83
  // Default to the first selectable tab when no value is supplied.
@@ -97,8 +102,59 @@
97
102
  return t.closable ?? closable;
98
103
  }
99
104
 
105
+ // One indicator slides under the selected tab on translate/scale (never
106
+ // width/left). It snaps on mount and on reflow, and only animates on a
107
+ // selection change; until it is placed the selected tab draws its own line.
108
+ let indicator = $state<HTMLSpanElement | null>(null);
109
+ let placed = $state(false);
110
+ let indLeft = 0;
111
+ let indWidth = 0;
112
+ function placeIndicator(animate: boolean) {
113
+ const el = listEl?.querySelector<HTMLElement>('.tab.selected');
114
+ const ind = indicator;
115
+ if (!el || !ind) {
116
+ placed = false;
117
+ return;
118
+ }
119
+ const left = el.offsetLeft;
120
+ const width = el.offsetWidth;
121
+ const prevLeft = indLeft;
122
+ const prevWidth = indWidth;
123
+ indLeft = left;
124
+ indWidth = width;
125
+ ind.style.width = `${width}px`;
126
+ if (!animate || !placed || width === 0) {
127
+ ind.style.transition = 'none';
128
+ ind.style.translate = `${left}px 0`;
129
+ ind.style.scale = '1 1';
130
+ ind.getBoundingClientRect();
131
+ ind.style.transition = '';
132
+ placed = true;
133
+ return;
134
+ }
135
+ ind.style.transition = 'none';
136
+ ind.style.translate = `${prevLeft}px 0`;
137
+ ind.style.scale = `${prevWidth / width} 1`;
138
+ ind.getBoundingClientRect();
139
+ ind.style.transition = '';
140
+ ind.style.translate = `${left}px 0`;
141
+ ind.style.scale = '1 1';
142
+ }
143
+ $effect(() => {
144
+ value;
145
+ tabs;
146
+ tick().then(() => placeIndicator(true));
147
+ });
148
+ $effect(() => {
149
+ if (!listEl || typeof ResizeObserver === 'undefined') return;
150
+ const ro = new ResizeObserver(() => placeIndicator(false));
151
+ ro.observe(listEl);
152
+ return () => ro.disconnect();
153
+ });
154
+
100
155
  function select(id: string, focus = false) {
101
156
  if (tabs.find((t) => t.id === id)?.disabled) return;
157
+ if (value !== id) playSound('click', sound);
102
158
  value = id;
103
159
  queueMicrotask(() => {
104
160
  const el = listEl?.ownerDocument.getElementById(tabId(id));
@@ -163,7 +219,8 @@
163
219
 
164
220
  <div {...rest} class="tabs {klass}" style={styleProp} data-tsu="Tabs">
165
221
  <div class="strip">
166
- <div bind:this={listEl} role="tablist" aria-label={label} tabindex="-1" class="tablist" {onkeydown}>
222
+ <div bind:this={listEl} role="tablist" aria-label={label} tabindex="-1" class="tablist" class:placed {onkeydown}>
223
+ <span bind:this={indicator} class="indicator" aria-hidden="true"></span>
167
224
  {#each tabs as t (t.id)}
168
225
  {#if canClose(t)}
169
226
  <!-- A close control cannot nest inside a <button>, so a closable tab is a div. -->
@@ -235,6 +292,7 @@
235
292
  align-items: stretch;
236
293
  }
237
294
  .tablist {
295
+ position: relative;
238
296
  display: flex;
239
297
  flex: 1 1 auto;
240
298
  min-width: 0;
@@ -244,42 +302,66 @@
244
302
  overflow-y: hidden;
245
303
  scrollbar-width: thin;
246
304
  }
305
+ .indicator {
306
+ position: absolute;
307
+ left: 0;
308
+ bottom: 0;
309
+ width: 0;
310
+ height: 2px;
311
+ background: var(--accent);
312
+ transform-origin: left center;
313
+ pointer-events: none;
314
+ transition:
315
+ translate var(--dur-spring) var(--ease-spring),
316
+ scale var(--dur-spring) var(--ease-spring);
317
+ }
247
318
  .tab {
248
319
  display: inline-flex;
249
320
  flex: 0 0 auto;
250
321
  white-space: nowrap;
251
322
  align-items: center;
252
323
  gap: var(--sp-2);
253
- padding: var(--sp-2) var(--sp-3);
324
+ min-height: var(--control-md);
325
+ padding: 0 var(--control-px-md);
254
326
  border: none;
255
327
  background: none;
256
328
  color: var(--text-muted);
257
- font-size: var(--fs-sm);
329
+ font-size: var(--control-fs-md);
258
330
  font-weight: var(--fw-medium);
331
+ --icon-size: var(--control-icon-md);
259
332
  border-bottom: 2px solid transparent;
260
333
  margin-bottom: -1px;
261
334
  max-width: var(--tab-max-width, none);
262
335
  transition:
263
- color 0.12s var(--ease),
264
- border-color 0.12s var(--ease);
336
+ color var(--dur-fast) var(--ease),
337
+ border-color var(--dur-fast) var(--ease);
265
338
  }
266
339
  .tab.closable {
267
340
  cursor: pointer;
268
341
  padding-inline-end: var(--sp-2);
269
342
  user-select: none;
270
343
  }
271
- .tab:hover:not(:disabled, .disabled) {
272
- color: var(--text);
344
+ @media (hover: hover) and (pointer: fine) {
345
+ .tab:hover:not(:disabled, .disabled) {
346
+ color: var(--text);
347
+ }
273
348
  }
274
349
  .tab:disabled,
275
350
  .tab.disabled {
276
- opacity: 0.45;
351
+ opacity: var(--disabled-opacity);
277
352
  cursor: not-allowed;
278
353
  }
279
354
  .tab.selected {
280
355
  color: var(--accent);
356
+ }
357
+ .tablist:not(.placed) .tab.selected {
281
358
  border-bottom-color: var(--accent);
282
359
  }
360
+ /* The list clips its overflow, so the ring sits inside the tab. */
361
+ .tab:focus-visible {
362
+ outline: var(--focus-ring);
363
+ outline-offset: var(--focus-ring-offset-inset);
364
+ }
283
365
  .label {
284
366
  min-width: 0;
285
367
  overflow: hidden;
@@ -303,8 +385,8 @@
303
385
  padding-top: 0;
304
386
  }
305
387
  .tabpanel:focus-visible {
306
- outline: 2px solid var(--accent);
307
- outline-offset: 2px;
388
+ outline: var(--focus-ring);
389
+ outline-offset: var(--focus-ring-offset);
308
390
  border-radius: var(--r-sm);
309
391
  }
310
392
  </style>
@@ -39,6 +39,8 @@ type Own = {
39
39
  style?: string;
40
40
  panelClass?: string;
41
41
  panelPadding?: 'none' | 'sm' | 'md';
42
+ /** Play UI sounds when enabled globally; false silences this instance. */
43
+ sound?: boolean;
42
44
  };
43
45
  type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
44
46
  declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "value">;
@@ -157,11 +157,11 @@
157
157
  position: absolute;
158
158
  right: -0.35rem;
159
159
  bottom: -0.35rem;
160
- border-radius: 50%;
160
+ border-radius: var(--r-pill);
161
161
  background: var(--bg);
162
162
  color: var(--text-muted);
163
163
  font-size: var(--fs-xs);
164
- line-height: 1;
164
+ line-height: var(--lh-none);
165
165
  }
166
166
  .swatch {
167
167
  display: grid;
@@ -215,19 +215,27 @@
215
215
  gap: var(--sp-1);
216
216
  }
217
217
  .cell {
218
+ --cell-scale-dur: var(--dur-fast);
218
219
  display: inline-flex;
219
- padding: 3px;
220
+ padding: var(--sp-0-5);
220
221
  border: 2px solid transparent;
221
222
  border-radius: var(--r-md);
222
223
  background: none;
223
224
  cursor: pointer;
225
+ transition:
226
+ background-color var(--dur-fast) ease,
227
+ border-color var(--dur-fast) ease,
228
+ scale var(--cell-scale-dur) var(--ease-out);
224
229
  }
230
+ /* Concentric with the cell: outer radius minus border and padding. */
225
231
  .cell .swatch {
226
232
  width: 1.6rem;
227
233
  height: 1.6rem;
234
+ border-radius: calc(var(--r-md) - var(--sp-1));
228
235
  }
229
- .cell:hover {
230
- background: var(--bg-elevated-2);
236
+ .cell:active {
237
+ --cell-scale-dur: var(--dur-press);
238
+ scale: var(--press-scale-sm);
231
239
  }
232
240
  .cell.current {
233
241
  border-color: var(--accent);
@@ -238,8 +246,8 @@
238
246
  }
239
247
  .cell:focus-visible,
240
248
  .auto:focus-visible {
241
- outline: 2px solid var(--accent);
242
- outline-offset: 1px;
249
+ outline: var(--focus-ring);
250
+ outline-offset: var(--focus-ring-offset-inset);
243
251
  }
244
252
  .auto {
245
253
  display: flex;
@@ -255,16 +263,23 @@
255
263
  color: inherit;
256
264
  text-align: left;
257
265
  cursor: pointer;
266
+ transition:
267
+ background-color var(--dur-fast) ease,
268
+ border-color var(--dur-fast) ease;
258
269
  }
259
- .auto:hover {
260
- background: var(--bg-elevated-2);
270
+ @media (hover: hover) and (pointer: fine) {
271
+ .cell:hover,
272
+ .auto:hover {
273
+ background: var(--bg-elevated-2);
274
+ transition-duration: var(--dur-instant);
275
+ }
261
276
  }
262
277
  .auto.current {
263
278
  border-color: var(--accent);
264
279
  }
265
280
  .auto-glyph {
266
281
  font-size: var(--fs-md);
267
- line-height: 1;
282
+ line-height: var(--lh-none);
268
283
  }
269
284
  .auto-text {
270
285
  display: flex;
@@ -199,10 +199,19 @@
199
199
  text-underline-offset: 2px;
200
200
  text-decoration-color: var(--border-strong);
201
201
  }
202
- .ts-trigger:hover,
203
202
  .ts-trigger:focus-visible {
204
203
  text-decoration-color: currentColor;
205
204
  }
205
+ @media (hover: hover) and (pointer: fine) {
206
+ .ts-trigger:hover {
207
+ text-decoration-color: currentColor;
208
+ }
209
+ .ts-mode:hover {
210
+ background: var(--bg-elevated-2);
211
+ color: var(--text);
212
+ transition-duration: var(--dur-instant);
213
+ }
214
+ }
206
215
 
207
216
  .ts-panel {
208
217
  display: flex;
@@ -224,12 +233,8 @@
224
233
  font-size: var(--fs-xs);
225
234
  cursor: pointer;
226
235
  transition:
227
- background 0.12s var(--ease),
228
- color 0.12s var(--ease);
229
- }
230
- .ts-mode:hover {
231
- background: var(--bg-elevated-2);
232
- color: var(--text);
236
+ background var(--dur-fast) var(--ease),
237
+ color var(--dur-fast) var(--ease);
233
238
  }
234
239
  .ts-mode.active {
235
240
  background: var(--bg-elevated-2);