@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
@@ -7,11 +7,21 @@
7
7
  // everything outside it is inert, top layer included, so the stack moves
8
8
  // into the topmost modal dialog and back to its host when it closes.
9
9
  import { toasts } from '../../stores/toast.svelte';
10
+ import { playSound } from '../../sound';
10
11
  import Button from '../atoms/Button.svelte';
11
12
  import Card from '../atoms/Card.svelte';
12
13
  import Icon from '../atoms/Icon.svelte';
13
14
 
14
- let { class: klass = '', style: styleProp = '' }: { class?: string; style?: string } = $props();
15
+ let {
16
+ class: klass = '',
17
+ style: styleProp = '',
18
+ sound = true
19
+ }: {
20
+ class?: string;
21
+ style?: string;
22
+ /** Play UI sounds when enabled globally; false silences this instance. */
23
+ sound?: boolean;
24
+ } = $props();
15
25
 
16
26
  let host: HTMLDivElement | undefined = $state();
17
27
  let el: HTMLDivElement | undefined = $state();
@@ -69,12 +79,45 @@
69
79
  $effect(() => () => {
70
80
  if (el && host && el.parentNode !== host) host.appendChild(el);
71
81
  });
82
+
83
+ // Feedback tones sound once, when the toast appears; the queue present at
84
+ // mount is a sync, not an arrival.
85
+ let seen: Set<number> | null = null;
86
+ $effect(() => {
87
+ const items = toasts.items;
88
+ if (!seen) {
89
+ seen = new Set(items.map((t) => t.id));
90
+ return;
91
+ }
92
+ for (const t of items) {
93
+ if (seen.has(t.id)) continue;
94
+ seen.add(t.id);
95
+ if (t.tone === 'ok') playSound('success', sound);
96
+ else if (t.tone === 'error') playSound('error', sound);
97
+ }
98
+ });
99
+
100
+ // Hovering a toast holds its timer; only a real pointer counts.
101
+ function hold(e: PointerEvent, id: number) {
102
+ if (e.pointerType === 'mouse') toasts.pause(id);
103
+ }
104
+ function release(e: PointerEvent, id: number) {
105
+ if (e.pointerType === 'mouse') toasts.resume(id);
106
+ }
72
107
  </script>
73
108
 
74
109
  {#snippet toast(t: (typeof toasts.items)[number])}
75
110
  {@const tone = t.tone === 'ok' ? 'ok' : t.tone === 'error' ? 'danger' : t.tone === 'info' ? 'info' : undefined}
76
111
  {#if t.action}
77
- <Card as="div" surface="raised" class="toast" {tone}>
112
+ <Card
113
+ as="div"
114
+ surface="raised"
115
+ class="toast"
116
+ {tone}
117
+ data-leaving={t.leaving || undefined}
118
+ onpointerenter={(e: PointerEvent) => hold(e, t.id)}
119
+ onpointerleave={(e: PointerEvent) => release(e, t.id)}
120
+ >
78
121
  <button
79
122
  type="button"
80
123
  class="toast-text"
@@ -98,6 +141,9 @@
98
141
  {tone}
99
142
  type="button"
100
143
  aria-label="Dismiss: {t.message}"
144
+ data-leaving={t.leaving || undefined}
145
+ onpointerenter={(e: PointerEvent) => hold(e, t.id)}
146
+ onpointerleave={(e: PointerEvent) => release(e, t.id)}
101
147
  onclick={() => toasts.dismiss(t.id)}
102
148
  >
103
149
  <span aria-hidden="true" class="toast-icon">
@@ -160,7 +206,23 @@
160
206
  width: 100%;
161
207
  text-align: left;
162
208
  font-size: var(--fs-sm);
163
- animation: toast-in 0.18s var(--ease);
209
+ transition:
210
+ opacity var(--dur-sheet) var(--ease-out),
211
+ translate var(--dur-sheet) var(--ease-out),
212
+ scale var(--dur-sheet) var(--ease-out);
213
+ }
214
+ @starting-style {
215
+ .toaster :global(.toast) {
216
+ opacity: 0;
217
+ translate: 0 var(--shift-md);
218
+ scale: 0.98;
219
+ }
220
+ }
221
+ .toaster :global(.toast[data-leaving]) {
222
+ opacity: 0;
223
+ scale: 0.96;
224
+ pointer-events: none;
225
+ transition-duration: var(--dur-exit-slow);
164
226
  }
165
227
  .toaster :global(.toast.card-ok) {
166
228
  color: var(--ok);
@@ -188,15 +250,4 @@
188
250
  .msg {
189
251
  flex: 1;
190
252
  }
191
- @keyframes toast-in {
192
- from {
193
- transform: translateY(8px);
194
- opacity: 0;
195
- }
196
- }
197
- @media (prefers-reduced-motion: reduce) {
198
- .toaster :global(.toast) {
199
- animation: none;
200
- }
201
- }
202
253
  </style>
@@ -1,6 +1,8 @@
1
1
  type $$ComponentProps = {
2
2
  class?: string;
3
3
  style?: string;
4
+ /** Play UI sounds when enabled globally; false silences this instance. */
5
+ sound?: boolean;
4
6
  };
5
7
  declare const Toaster: import("svelte").Component<$$ComponentProps, {}, "">;
6
8
  type Toaster = ReturnType<typeof Toaster>;
@@ -10,6 +10,7 @@
10
10
  // <button> + scoped styles rather than specializing the Button atom.
11
11
  import type { Snippet } from 'svelte';
12
12
  import type { HTMLButtonAttributes } from 'svelte/elements';
13
+ import { playSound } from '../../sound';
13
14
 
14
15
  let {
15
16
  pressed = false,
@@ -20,6 +21,8 @@
20
21
  shrink = true,
21
22
  type = 'button',
22
23
  disabled = false,
24
+ sound = true,
25
+ onclick,
23
26
  class: klass = '',
24
27
  children,
25
28
  ...rest
@@ -32,8 +35,17 @@
32
35
  grow?: boolean;
33
36
  // `shrink={false}` keeps the chip at its intrinsic width (flex: none).
34
37
  shrink?: boolean;
38
+ /** Play UI sounds when enabled globally; false silences this instance. */
39
+ sound?: boolean;
35
40
  children?: Snippet;
36
41
  } = $props();
42
+
43
+ function click(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
44
+ const next = !pressed;
45
+ onclick?.(e);
46
+ if (e.defaultPrevented) return;
47
+ playSound(next ? 'toggle-on' : 'toggle-off', sound);
48
+ }
37
49
  </script>
38
50
 
39
51
  <button
@@ -41,6 +53,7 @@
41
53
  {...rest}
42
54
  {type}
43
55
  {disabled}
56
+ onclick={click}
44
57
  class="toggle {klass}"
45
58
  class:pill
46
59
  class:struck
@@ -60,34 +73,38 @@
60
73
  display: inline-flex;
61
74
  align-items: center;
62
75
  justify-content: center;
63
- gap: 4px;
76
+ gap: var(--sp-1);
64
77
  box-sizing: border-box;
65
- height: var(--toggle-size, var(--box-xs));
66
- padding: 0 var(--sp-2);
78
+ height: var(--toggle-size, var(--control-sm));
79
+ padding: 0 var(--control-px-sm);
67
80
  border-radius: var(--r-sm);
68
- font-size: var(--fs-xs);
81
+ font-size: var(--control-fs-sm);
69
82
  font-weight: var(--fw-medium);
70
- line-height: 1;
83
+ line-height: var(--lh-none);
84
+ --icon-size: var(--control-icon-sm);
71
85
  background: var(--bg-elevated-2);
72
86
  color: var(--text-muted);
73
87
  border: 1px solid var(--border);
74
88
  white-space: nowrap;
75
89
  user-select: none;
76
90
  transition:
77
- background 0.12s var(--ease),
78
- border-color 0.12s var(--ease),
79
- color 0.12s var(--ease);
91
+ background var(--dur-fast) var(--ease),
92
+ border-color var(--dur-fast) var(--ease),
93
+ color var(--dur-fast) var(--ease),
94
+ scale var(--dur-fast) var(--ease-out);
80
95
  }
81
96
  .toggle:disabled {
82
- opacity: 0.45;
97
+ opacity: var(--disabled-opacity);
83
98
  cursor: not-allowed;
84
99
  }
85
100
  .toggle.pill {
86
101
  border-radius: var(--r-pill);
87
102
  }
88
103
  .toggle.md {
89
- height: var(--toggle-size, var(--control-height-compact));
90
- font-size: var(--fs-sm);
104
+ height: var(--toggle-size, var(--control-md));
105
+ padding: 0 var(--control-px-md);
106
+ font-size: var(--control-fs-md);
107
+ --icon-size: var(--control-icon-md);
91
108
  }
92
109
  .toggle.grow {
93
110
  flex: 1 1 0;
@@ -96,8 +113,14 @@
96
113
  .toggle.no-shrink {
97
114
  flex: none;
98
115
  }
99
- .toggle:hover:not(:disabled) {
100
- border-color: var(--border-strong);
116
+ @media (hover: hover) and (pointer: fine) {
117
+ .toggle:hover:not(:disabled) {
118
+ border-color: var(--border-strong);
119
+ }
120
+ }
121
+ .toggle:active:not(:disabled) {
122
+ scale: var(--press-scale);
123
+ transition-duration: var(--dur-press);
101
124
  }
102
125
  .toggle.on {
103
126
  --tc: var(--toggle-accent, var(--accent));
@@ -8,6 +8,8 @@ type $$ComponentProps = HTMLButtonAttributes & {
8
8
  size?: ControlSize;
9
9
  grow?: boolean;
10
10
  shrink?: boolean;
11
+ /** Play UI sounds when enabled globally; false silences this instance. */
12
+ sound?: boolean;
11
13
  children?: Snippet;
12
14
  };
13
15
  declare const Toggle: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -1,3 +1,7 @@
1
+ <script lang="ts" module>
2
+ let lastClosed = -Infinity;
3
+ </script>
4
+
1
5
  <script lang="ts">
2
6
  // Lightweight tooltip. Shows on hover (after a short delay) and on keyboard
3
7
  // focus of the trigger; hides on blur / mouseleave / Escape (anywhere). The trigger is
@@ -15,7 +19,7 @@
15
19
  // by `closeDelay` and cancelled when the pointer (or focus) enters the panel,
16
20
  // so users can move in to select/copy text or click a button. Note the
17
21
  // role="tooltip" caveat: `aria-describedby` flattens the panel to its text.
18
- import type { Snippet } from 'svelte';
22
+ import { flushSync, type Snippet } from 'svelte';
19
23
  import { place } from '../../floating';
20
24
 
21
25
  let {
@@ -53,6 +57,8 @@
53
57
  style?: string;
54
58
  } = $props();
55
59
 
60
+ // A tooltip closed moments ago makes the next one open at once, unanimated.
61
+ const WARM_MS = 300;
56
62
  const id = `tip-${Math.random().toString(36).slice(2, 8)}`;
57
63
  let wrapEl = $state<HTMLElement | null>(null);
58
64
  let tipEl = $state<HTMLElement | null>(null);
@@ -68,23 +74,32 @@
68
74
  if (e.key === 'Escape') hideNow();
69
75
  }
70
76
 
77
+ let instant = $state(false);
71
78
  function show() {
72
79
  clearTimeout(hideTimer); // pointer came back within the grace period
73
80
  clearTimeout(showTimer);
74
- showTimer = setTimeout(() => {
81
+ const warm = performance.now() - lastClosed < WARM_MS;
82
+ const reveal = () => {
75
83
  if (!tipEl || tipEl.matches(':popover-open')) return; // re-entry guard
84
+ instant = warm;
85
+ flushSync();
76
86
  tipEl.showPopover(); // top layer — displayed before we measure it
77
87
  reposition();
78
88
  addEventListener('scroll', reposition, true);
79
89
  addEventListener('resize', reposition);
80
90
  document.addEventListener('keydown', onEscape);
81
- }, delay);
91
+ };
92
+ if (warm) reveal();
93
+ else showTimer = setTimeout(reveal, delay);
82
94
  }
83
95
  function hideNow() {
84
96
  clearTimeout(showTimer);
85
97
  clearTimeout(hideTimer);
86
98
  try {
87
- if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
99
+ if (tipEl?.matches(':popover-open')) {
100
+ tipEl.hidePopover();
101
+ lastClosed = performance.now();
102
+ }
88
103
  } catch {}
89
104
  removeEventListener('scroll', reposition, true);
90
105
  removeEventListener('resize', reposition);
@@ -164,6 +179,7 @@
164
179
  popover="manual"
165
180
  class="tip"
166
181
  class:rich={!!content}
182
+ data-instant={instant ? '' : undefined}
167
183
  style:width
168
184
  style:max-width={maxWidth}
169
185
  use:panel
@@ -194,9 +210,16 @@
194
210
  border-radius: var(--r-sm);
195
211
  box-shadow: var(--shadow-md);
196
212
  font-size: var(--fs-xs);
197
- line-height: 1.4;
213
+ line-height: var(--lh-snug);
198
214
  white-space: normal;
199
215
  pointer-events: none; /* non-interactive: never steals hover/clicks */
216
+ transform-origin: var(--tsu-origin, bottom center);
217
+ opacity: 0;
218
+ scale: var(--tip-scale);
219
+ transition:
220
+ opacity var(--dur-exit-fast) var(--ease-out),
221
+ display var(--dur-exit-fast) allow-discrete,
222
+ overlay var(--dur-exit-fast) allow-discrete;
200
223
  }
201
224
  /* Rich panel: interactive + selectable so the user can move the pointer in
202
225
  to copy an id or click a button (the close grace period allows the trip). */
@@ -207,14 +230,22 @@
207
230
  padding: var(--sp-2);
208
231
  border-radius: var(--r-md);
209
232
  }
210
- /* Fade/scale in when shown (skipped under reduced-motion via the global rule). */
211
233
  .tip:popover-open {
212
- animation: tip-in 0.12s var(--ease);
234
+ opacity: 1;
235
+ scale: 1;
236
+ transition:
237
+ opacity var(--dur-fast) var(--ease-out),
238
+ scale var(--dur-fast) var(--ease-out),
239
+ display var(--dur-fast) allow-discrete,
240
+ overlay var(--dur-fast) allow-discrete;
213
241
  }
214
- @keyframes tip-in {
215
- from {
242
+ @starting-style {
243
+ .tip:popover-open {
216
244
  opacity: 0;
217
- transform: scale(0.96);
245
+ scale: var(--tip-scale);
218
246
  }
219
247
  }
248
+ .tip[data-instant] {
249
+ transition-duration: var(--dur-instant);
250
+ }
220
251
  </style>
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  /** Plain-text bubble. Ignored when `content` is provided. */
4
4
  text?: string;
@@ -8,6 +8,7 @@
8
8
  import type { HTMLAttributes } from 'svelte/elements';
9
9
  import type { IconName } from '../atoms/Icon.svelte';
10
10
  import IconButton from './IconButton.svelte';
11
+ import { playSound } from '../../sound';
11
12
 
12
13
  type Own = {
13
14
  /** Level 0–1. */
@@ -22,6 +23,8 @@
22
23
  label?: string;
23
24
  muteLabel?: string;
24
25
  unmuteLabel?: string;
26
+ /** Play UI sounds when enabled globally; false silences this instance. */
27
+ sound?: boolean;
25
28
  oninput?: (value: number) => void;
26
29
  onchange?: (value: number) => void;
27
30
  onmutechange?: (muted: boolean) => void;
@@ -40,6 +43,7 @@
40
43
  label = 'Volume',
41
44
  muteLabel = 'Mute',
42
45
  unmuteLabel = 'Unmute',
46
+ sound = true,
43
47
  oninput,
44
48
  onchange,
45
49
  onmutechange,
@@ -59,6 +63,7 @@
59
63
  muted ? 'volume-mute' : level === 0 ? 'volume' : level < 0.5 ? 'volume-low' : 'volume-high'
60
64
  );
61
65
  const valueText = $derived(muted ? 'Muted' : `${pct}%`);
66
+ const discrete = $derived(1 / step <= 20);
62
67
 
63
68
  function setMuted(m: boolean) {
64
69
  if (m === muted) return;
@@ -75,7 +80,8 @@
75
80
  }
76
81
 
77
82
  function commit(v: number) {
78
- onchange?.(apply(v));
83
+ const next = apply(v);
84
+ onchange?.(next);
79
85
  }
80
86
 
81
87
  function fracAt(el: HTMLElement, e: PointerEvent) {
@@ -117,6 +123,7 @@
117
123
 
118
124
  function onpointermove(e: PointerEvent) {
119
125
  if (pointerId !== e.pointerId) return;
126
+ if (discrete) playSound('tick', sound);
120
127
  apply(fracAt(e.currentTarget as HTMLElement, e));
121
128
  }
122
129
 
@@ -167,6 +174,7 @@
167
174
  }
168
175
  if (next === null) return;
169
176
  e.preventDefault();
177
+ playSound('tick', sound);
170
178
  commit(next);
171
179
  }
172
180
 
@@ -176,6 +184,7 @@
176
184
  const d = e.deltaY || e.deltaX;
177
185
  if (!d) return;
178
186
  e.preventDefault();
187
+ playSound('tick', sound);
179
188
  commit(level + (d < 0 ? step : -step));
180
189
  };
181
190
  el.addEventListener('wheel', onwheel, { passive: false });
@@ -199,6 +208,7 @@
199
208
  box="sm"
200
209
  size={size === 'sm' ? 16 : 18}
201
210
  {disabled}
211
+ {sound}
202
212
  onclick={() => setMuted(!muted)}
203
213
  data-part="mute"
204
214
  />
@@ -237,6 +247,7 @@
237
247
  --vol-thumb: 10px;
238
248
  --vol-length: 5rem;
239
249
  --vol-accent: var(--slider-accent, var(--accent));
250
+ --vol-halo: 0 0 0 4px color-mix(in srgb, var(--vol-accent) 20%, transparent);
240
251
  display: inline-flex;
241
252
  align-items: center;
242
253
  gap: var(--sp-1);
@@ -281,8 +292,8 @@
281
292
  }
282
293
  }
283
294
  .vol-slab[aria-disabled='true'] {
284
- cursor: default;
285
- opacity: 0.45;
295
+ cursor: not-allowed;
296
+ opacity: var(--disabled-opacity);
286
297
  }
287
298
  .vol-slab:focus-visible {
288
299
  outline: var(--focus-ring);
@@ -340,28 +351,37 @@
340
351
  top: 0;
341
352
  width: var(--vol-thumb);
342
353
  height: var(--vol-thumb);
343
- border-radius: 50%;
354
+ border-radius: var(--r-pill);
344
355
  background: var(--vol-accent);
345
356
  border: 2px solid var(--bg);
346
357
  box-shadow: var(--shadow-sm);
347
- transform: translate(-50%, -50%) scale(0.6);
358
+ transform: translate(-50%, -50%);
359
+ scale: 0.6;
348
360
  opacity: 0;
349
361
  transition:
350
- opacity 120ms var(--ease),
351
- transform 120ms var(--ease);
362
+ opacity var(--dur-fast) var(--ease),
363
+ scale var(--dur-press) var(--ease-out),
364
+ box-shadow var(--dur-fast) var(--ease);
352
365
  }
353
- .vol-slab:hover .vol-thumb,
354
366
  .vol-slab:focus-visible .vol-thumb,
355
367
  .vol--dragging .vol-thumb {
356
368
  opacity: 1;
357
- transform: translate(-50%, -50%) scale(1);
369
+ scale: 1;
358
370
  }
359
- .vol--muted .vol-fill {
360
- background: var(--text-faint);
371
+ .vol--dragging .vol-thumb {
372
+ scale: 1.15;
361
373
  }
362
- @media (prefers-reduced-motion: reduce) {
363
- .vol-thumb {
364
- transition: none;
374
+ @media (hover: hover) and (pointer: fine) {
375
+ .vol-slab:not([aria-disabled='true']):hover .vol-thumb {
376
+ opacity: 1;
377
+ scale: 1;
378
+ box-shadow: var(--shadow-sm), var(--vol-halo);
365
379
  }
380
+ .vol--dragging .vol-thumb {
381
+ scale: 1.15;
382
+ }
383
+ }
384
+ .vol--muted .vol-fill {
385
+ background: var(--text-faint);
366
386
  }
367
387
  </style>
@@ -18,6 +18,8 @@ type Own = {
18
18
  label?: string;
19
19
  muteLabel?: string;
20
20
  unmuteLabel?: string;
21
+ /** Play UI sounds when enabled globally; false silences this instance. */
22
+ sound?: boolean;
21
23
  oninput?: (value: number) => void;
22
24
  onchange?: (value: number) => void;
23
25
  onmutechange?: (muted: boolean) => void;
@@ -122,6 +122,7 @@
122
122
  min-width: 0;
123
123
  min-height: max-content;
124
124
  overflow: hidden;
125
+ --focus-ring-offset: var(--focus-ring-offset-inset);
125
126
  }
126
127
  .rail.full {
127
128
  flex: none;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @typedef {{ type: 'open', index?: number }
3
+ * | { type: 'close' }
4
+ * | { type: 'select' }
5
+ * | { type: 'move', index: number }
6
+ * | { type: 'change', index: number }
7
+ * | { type: 'reorder', from: number, to: number }
8
+ * | { type: 'trailing' }
9
+ * | { type: 'type', char: string }} RichSelectAction
10
+ */
11
+ /**
12
+ * Map a keydown on the trigger to what the select does with it. Closed, the
13
+ * arrows step the value like a native select and Enter/Space/Alt+ArrowDown open.
14
+ * Open, the arrows and Home/End move the active option, Enter/Space select,
15
+ * Escape closes, Alt+arrows reorder (when `reorder` is set), and Tab or
16
+ * ArrowRight reach the active option's trailing actions (when it has any).
17
+ * Printable characters feed the typeahead in both states. `undefined` leaves
18
+ * the key alone.
19
+ *
20
+ * @param {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} e
21
+ * @param {{ open: boolean, count: number, index: number, reorder?: boolean, trailing?: boolean }} state
22
+ * @returns {RichSelectAction | undefined}
23
+ */
24
+ export function richSelectAction(e: {
25
+ key: string;
26
+ altKey?: boolean;
27
+ ctrlKey?: boolean;
28
+ metaKey?: boolean;
29
+ shiftKey?: boolean;
30
+ }, { open, count, index, reorder, trailing }: {
31
+ open: boolean;
32
+ count: number;
33
+ index: number;
34
+ reorder?: boolean;
35
+ trailing?: boolean;
36
+ }): RichSelectAction | undefined;
37
+ /**
38
+ * Index of the first label, searching forward from `from` and wrapping, that
39
+ * starts with `query` (case- and accent-insensitive). A query of one repeated
40
+ * character cycles through the labels starting with it, as native selects do.
41
+ *
42
+ * @param {readonly string[]} labels
43
+ * @param {string} query
44
+ * @param {number} from index of the current option, or -1
45
+ * @returns {number | undefined}
46
+ */
47
+ export function typeaheadIndex(labels: readonly string[], query: string, from: number): number | undefined;
48
+ /**
49
+ * Accumulates typed characters into a query that resets after `timeout` ms of
50
+ * silence.
51
+ *
52
+ * @param {number} [timeout]
53
+ * @param {() => number} [now]
54
+ */
55
+ export function createTypeahead(timeout?: number, now?: () => number): {
56
+ /** @param {string} char */
57
+ push(char: string): string;
58
+ reset(): void;
59
+ };
60
+ export type RichSelectAction = {
61
+ type: "open";
62
+ index?: number;
63
+ } | {
64
+ type: "close";
65
+ } | {
66
+ type: "select";
67
+ } | {
68
+ type: "move";
69
+ index: number;
70
+ } | {
71
+ type: "change";
72
+ index: number;
73
+ } | {
74
+ type: "reorder";
75
+ from: number;
76
+ to: number;
77
+ } | {
78
+ type: "trailing";
79
+ } | {
80
+ type: "type";
81
+ char: string;
82
+ };