@dorsk/tsumikit 0.13.0 → 0.14.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.
package/README.md CHANGED
@@ -108,6 +108,27 @@ proportionally with no code. That's the recommended path for magnification.
108
108
  text tokens only) for reading-dense apps that want larger body text while
109
109
  keeping chrome compact. It isn't wired into AppShell or any default.
110
110
 
111
+ ### Toolbar control heights
112
+
113
+ `size="sm"` is the shared compact-toolbar contract for `Button`, `Popover`
114
+ triggers, and `SegmentedControl`. Each renders an exact
115
+ `--control-height-compact` outer box, so mixed controls share a vertical center:
116
+
117
+ ```svelte
118
+ <SegmentedControl size="sm" {options} bind:value />
119
+ <Popover size="sm" variant="default" label="More actions">
120
+ {#snippet trigger()}More{/snippet}
121
+ <!-- panel content -->
122
+ </Popover>
123
+ <Button size="sm">Apply</Button>
124
+ ```
125
+
126
+ `Popover` owns that single semantic trigger button; pass `variant`, `tone`,
127
+ `size`, `control`, `block`, and `disabled` directly instead of rendering a
128
+ `Button` inside its `trigger` snippet. Omitting these props preserves the
129
+ original ghost icon-button default. Use `control` on `Button` or `Popover` when
130
+ the roomier shared `--control-height` composer contract is required.
131
+
111
132
  ## Built on the platform
112
133
 
113
134
  Interactive components lean on modern web features rather than reimplementing
@@ -89,7 +89,7 @@
89
89
  justify-content: center;
90
90
  gap: var(--sp-2);
91
91
  padding: var(--sp-2) var(--sp-4);
92
- min-height: 2.5rem;
92
+ min-height: var(--control-height-default);
93
93
  border: 1px solid var(--border-strong);
94
94
  border-radius: var(--r-md);
95
95
  background: var(--surface);
@@ -137,12 +137,13 @@
137
137
  border-color: transparent;
138
138
  }
139
139
  .btn-sm {
140
- min-height: 2rem;
140
+ height: var(--control-height-compact);
141
+ min-height: var(--control-height-compact);
141
142
  padding: var(--sp-1) var(--sp-3);
142
143
  font-size: var(--fs-xs);
143
144
  }
144
145
  .btn-lg {
145
- min-height: 3rem;
146
+ min-height: var(--control-height-large);
146
147
  padding: var(--sp-3) var(--sp-5);
147
148
  font-size: var(--fs-base);
148
149
  }
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
2
+ import { onDestroy, type Snippet } from 'svelte';
3
3
  import { browser } from '../../env';
4
4
  import Icon from '../atoms/Icon.svelte';
5
+ import { createFrameBatcher } from './resizable-panel-frame.js';
6
+ import { parseStoredCollapsed, parseStoredWidth } from './resizable-panel-persistence';
5
7
 
6
8
  let {
7
9
  panel,
@@ -64,12 +66,18 @@
64
66
  restored = true;
65
67
  if (!widthKey) return;
66
68
 
67
- const savedWidth = Number(localStorage.getItem(widthKey));
68
- if (Number.isFinite(savedWidth)) panelWidth = savedWidth;
69
+ const savedWidth = parseStoredWidth(
70
+ localStorage.getItem(widthKey),
71
+ boundedMin,
72
+ boundedMax
73
+ );
74
+ if (savedWidth !== undefined) panelWidth = savedWidth;
69
75
 
70
76
  if (persistCollapsed) {
71
- const savedCollapsed = localStorage.getItem(`${widthKey}:collapsed`);
72
- if (savedCollapsed !== null) collapsed = savedCollapsed === 'true';
77
+ const savedCollapsed = parseStoredCollapsed(
78
+ localStorage.getItem(`${widthKey}:collapsed`)
79
+ );
80
+ if (savedCollapsed !== undefined) collapsed = savedCollapsed;
73
81
  }
74
82
  });
75
83
 
@@ -82,6 +90,14 @@
82
90
  if (persist) persistWidth();
83
91
  }
84
92
 
93
+ const pointerWidths = createFrameBatcher<number>(
94
+ (callback) => requestAnimationFrame(callback),
95
+ (handle) => cancelAnimationFrame(handle),
96
+ (nextWidth) => setWidth(nextWidth, false)
97
+ );
98
+
99
+ onDestroy(() => pointerWidths.discard());
100
+
85
101
  function toggle() {
86
102
  collapsed = !collapsed;
87
103
  if (browser && widthKey && persistCollapsed) {
@@ -102,18 +118,22 @@
102
118
 
103
119
  function resize(event: PointerEvent) {
104
120
  if (!resizing) return;
105
- setWidth(widthFromPointer(event.clientX), false);
121
+ pointerWidths.schedule(widthFromPointer(event.clientX));
106
122
  }
107
123
 
108
124
  function finishResize(event: PointerEvent) {
109
125
  if (!resizing) return;
126
+ const finalWidth = Math.round(
127
+ Math.max(boundedMin, Math.min(widthFromPointer(event.clientX), boundedMax))
128
+ );
129
+ pointerWidths.flush(finalWidth);
110
130
  resizing = false;
111
131
  try {
112
132
  (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
113
133
  } catch {
114
134
  // Pointer capture may already have been released by the browser.
115
135
  }
116
- persistWidth();
136
+ if (browser && widthKey) localStorage.setItem(widthKey, String(finalWidth));
117
137
  }
118
138
 
119
139
  function resizeWithKeyboard(event: KeyboardEvent) {
@@ -184,7 +204,6 @@
184
204
  onclick={toggle}
185
205
  >
186
206
  <Icon name={toggleIcon} size={20} />
187
- {#if !collapsed}<span>{toggleLabel}</span>{/if}
188
207
  </button>
189
208
  </aside>
190
209
 
@@ -193,7 +212,6 @@
193
212
 
194
213
  <style>
195
214
  .panel-layout {
196
- --panel-collapsed-width: var(--control-height);
197
215
  position: relative;
198
216
  display: grid;
199
217
  grid-template-columns: var(--panel-current-width) minmax(0, 1fr);
@@ -209,7 +227,7 @@
209
227
  --panel-current-width: var(--panel-width);
210
228
  }
211
229
  .panel-layout.collapsed {
212
- --panel-current-width: var(--panel-collapsed-width);
230
+ --panel-current-width: 0px;
213
231
  }
214
232
  .panel {
215
233
  position: relative;
@@ -243,23 +261,28 @@
243
261
  overflow: auto;
244
262
  }
245
263
  .collapse-control {
264
+ position: absolute;
265
+ right: calc(-1 * var(--control-height));
266
+ bottom: var(--sp-3);
267
+ z-index: 3;
246
268
  display: flex;
247
269
  align-items: center;
248
270
  justify-content: center;
249
- gap: var(--sp-2);
250
- width: 100%;
251
- min-height: var(--control-height);
252
- margin-top: auto;
253
- padding: var(--sp-2) var(--sp-3);
271
+ width: var(--control-height);
272
+ height: var(--control-height);
273
+ padding: 0;
254
274
  color: var(--text-muted);
255
275
  font: inherit;
256
- font-size: var(--fs-sm);
257
- font-weight: var(--fw-medium);
258
276
  background: var(--bg-elevated-2);
259
- border: 0;
260
- border-top: 1px solid var(--border);
277
+ border: 1px solid var(--border);
278
+ border-radius: var(--r-sm);
279
+ box-shadow: var(--shadow-sm);
261
280
  cursor: pointer;
262
281
  }
282
+ .right .collapse-control {
283
+ right: auto;
284
+ left: calc(-1 * var(--control-height));
285
+ }
263
286
  .collapse-control:hover {
264
287
  color: var(--text);
265
288
  background: var(--surface);
@@ -273,7 +296,7 @@
273
296
  position: absolute;
274
297
  top: 0;
275
298
  right: -6px;
276
- bottom: var(--control-height);
299
+ bottom: 0;
277
300
  z-index: 2;
278
301
  width: 12px;
279
302
  padding: 0;
@@ -310,7 +333,7 @@
310
333
  }
311
334
 
312
335
  /* In a narrow host, the expanded panel overlays the main area instead of
313
- squeezing it. The bottom control remains visible as a persistent rail. */
336
+ squeezing it. */
314
337
  @container resizable-panel (max-width: 40rem) {
315
338
  .panel-layout {
316
339
  display: block;
@@ -332,11 +355,6 @@
332
355
  }
333
356
  .main {
334
357
  height: 100%;
335
- padding-left: var(--panel-collapsed-width);
336
- }
337
- .right .main {
338
- padding-right: var(--panel-collapsed-width);
339
- padding-left: 0;
340
358
  }
341
359
  }
342
360
 
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  /** Content shown while the panel is expanded. */
4
4
  panel: Snippet;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Coalesce high-frequency values into one update per animation frame while
3
+ * still allowing pointer release to synchronously apply the final value.
4
+ *
5
+ * @template T
6
+ * @param {(callback: FrameRequestCallback) => number} requestFrame
7
+ * @param {(handle: number) => void} cancelFrame
8
+ * @param {(value: T) => void} apply
9
+ */
10
+ export function createFrameBatcher<T>(requestFrame: (callback: FrameRequestCallback) => number, cancelFrame: (handle: number) => void, apply: (value: T) => void): {
11
+ /** @param {T} value */
12
+ schedule(value: T): void;
13
+ /** @param {T} value */
14
+ flush(value: T): void;
15
+ discard(): void;
16
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Coalesce high-frequency values into one update per animation frame while
3
+ * still allowing pointer release to synchronously apply the final value.
4
+ *
5
+ * @template T
6
+ * @param {(callback: FrameRequestCallback) => number} requestFrame
7
+ * @param {(handle: number) => void} cancelFrame
8
+ * @param {(value: T) => void} apply
9
+ */
10
+ export function createFrameBatcher(requestFrame, cancelFrame, apply) {
11
+ /** @type {number | undefined} */
12
+ let frame;
13
+ /** @type {T | undefined} */
14
+ let pending;
15
+ let scheduled = false;
16
+ let generation = 0;
17
+
18
+ /** Apply the latest queued value, invalidating any stale frame callback. */
19
+ function applyPending() {
20
+ if (pending === undefined) return;
21
+ const value = pending;
22
+ pending = undefined;
23
+ apply(value);
24
+ }
25
+
26
+ return {
27
+ /** @param {T} value */
28
+ schedule(value) {
29
+ pending = value;
30
+ if (scheduled) return;
31
+
32
+ scheduled = true;
33
+ const token = ++generation;
34
+ frame = requestFrame(() => {
35
+ if (!scheduled || token !== generation) return;
36
+ scheduled = false;
37
+ frame = undefined;
38
+ applyPending();
39
+ });
40
+ },
41
+
42
+ /** @param {T} value */
43
+ flush(value) {
44
+ pending = value;
45
+ if (scheduled) {
46
+ scheduled = false;
47
+ generation += 1;
48
+ if (frame !== undefined) cancelFrame(frame);
49
+ frame = undefined;
50
+ }
51
+ applyPending();
52
+ },
53
+
54
+ discard() {
55
+ pending = undefined;
56
+ if (!scheduled) return;
57
+ scheduled = false;
58
+ generation += 1;
59
+ if (frame !== undefined) cancelFrame(frame);
60
+ frame = undefined;
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parse a persisted panel width without treating a missing or blank value as zero.
3
+ * @param {string | null} value
4
+ * @param {number} minWidth
5
+ * @param {number} maxWidth
6
+ * @returns {number | undefined}
7
+ */
8
+ export function parseStoredWidth(value: string | null, minWidth: number, maxWidth: number): number | undefined;
9
+ /**
10
+ * Only values written by the panel itself may restore its collapsed state.
11
+ * @param {string | null} value
12
+ * @returns {boolean | undefined}
13
+ */
14
+ export function parseStoredCollapsed(value: string | null): boolean | undefined;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Parse a persisted panel width without treating a missing or blank value as zero.
3
+ * @param {string | null} value
4
+ * @param {number} minWidth
5
+ * @param {number} maxWidth
6
+ * @returns {number | undefined}
7
+ */
8
+ export function parseStoredWidth(value, minWidth, maxWidth) {
9
+ if (value === null || value.trim() === '') return undefined;
10
+
11
+ const width = Number(value);
12
+ return Number.isFinite(width) ? Math.max(minWidth, Math.min(width, maxWidth)) : undefined;
13
+ }
14
+
15
+ /**
16
+ * Only values written by the panel itself may restore its collapsed state.
17
+ * @param {string | null} value
18
+ * @returns {boolean | undefined}
19
+ */
20
+ export function parseStoredCollapsed(value) {
21
+ if (value === 'true') return true;
22
+ if (value === 'false') return false;
23
+ return undefined;
24
+ }
@@ -12,6 +12,9 @@
12
12
  import { place } from '../../floating';
13
13
 
14
14
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
15
+ type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
16
+ type TriggerTone = 'none' | 'accent' | 'info' | 'warn' | 'danger';
17
+ type TriggerSize = 'sm' | 'md' | 'lg';
15
18
 
16
19
  let {
17
20
  placement = 'bottom-start',
@@ -21,6 +24,12 @@
21
24
  children,
22
25
  triggerClass = '',
23
26
  bare = false,
27
+ variant,
28
+ tone = 'none',
29
+ size,
30
+ control = false,
31
+ block = false,
32
+ disabled = false,
24
33
  onopen,
25
34
  onclose
26
35
  }: {
@@ -38,10 +47,23 @@
38
47
  /** Drop the default ghost-icon chrome so the trigger is an unstyled button
39
48
  * you fully own (pair with `triggerClass`). */
40
49
  bare?: boolean;
50
+ /** Button-compatible trigger chrome. Omitting every chrome prop preserves
51
+ * the original ghost icon-button trigger. */
52
+ variant?: TriggerVariant;
53
+ tone?: TriggerTone;
54
+ size?: TriggerSize;
55
+ /** Use the shared `--control-height` toolbar/composer contract. */
56
+ control?: boolean;
57
+ block?: boolean;
58
+ disabled?: boolean;
41
59
  onopen?: () => void;
42
60
  onclose?: () => void;
43
61
  } = $props();
44
62
 
63
+ const canonicalChrome = $derived(
64
+ variant !== undefined || tone !== 'none' || size !== undefined || control || block
65
+ );
66
+
45
67
  const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
46
68
  let triggerEl = $state<HTMLButtonElement | null>(null);
47
69
  let panelEl = $state<HTMLDivElement | null>(null);
@@ -70,8 +92,21 @@
70
92
  type="button"
71
93
  class="pop-trigger {triggerClass}"
72
94
  class:bare
95
+ class:canonical={canonicalChrome}
96
+ class:trigger-primary={variant === 'primary'}
97
+ class:trigger-ghost={variant === 'ghost'}
98
+ class:trigger-danger={variant === 'danger'}
99
+ class:trigger-sm={size === 'sm'}
100
+ class:trigger-lg={size === 'lg'}
101
+ class:trigger-control={control}
102
+ class:trigger-block={block}
103
+ class:trigger-tone-accent={tone === 'accent'}
104
+ class:trigger-tone-info={tone === 'info'}
105
+ class:trigger-tone-warn={tone === 'warn'}
106
+ class:trigger-tone-danger={tone === 'danger'}
73
107
  popovertarget={id}
74
108
  aria-label={label}
109
+ {disabled}
75
110
  >
76
111
  {@render trigger()}
77
112
  </button>
@@ -107,18 +142,114 @@
107
142
  background 0.12s var(--ease),
108
143
  border-color 0.12s var(--ease);
109
144
  }
110
- .pop-trigger:hover {
145
+ .pop-trigger:hover:not(:disabled) {
146
+ background: var(--bg-elevated-2);
147
+ }
148
+ /* Supplying canonical chrome props opts into the same dimensions, variants
149
+ and state tones as Button while keeping this one native trigger element. */
150
+ .pop-trigger.canonical {
151
+ min-width: 0;
152
+ min-height: var(--control-height-default);
153
+ padding: var(--sp-2) var(--sp-4);
154
+ border-color: var(--border-strong);
155
+ background: var(--surface);
156
+ font-weight: var(--fw-medium);
157
+ font-size: var(--fs-sm);
158
+ line-height: 1;
159
+ user-select: none;
160
+ white-space: nowrap;
161
+ }
162
+ .pop-trigger.canonical:hover:not(:disabled) {
163
+ border-color: var(--accent);
164
+ background: var(--surface);
165
+ }
166
+ .pop-trigger.trigger-primary {
167
+ background: var(--accent);
168
+ border-color: var(--accent);
169
+ color: var(--text-on-accent);
170
+ font-weight: var(--fw-semibold);
171
+ }
172
+ .pop-trigger.trigger-primary:hover:not(:disabled) {
173
+ background: var(--accent);
174
+ border-color: var(--accent);
175
+ filter: brightness(1.08);
176
+ }
177
+ .pop-trigger.trigger-ghost {
178
+ background: transparent;
179
+ border-color: transparent;
180
+ }
181
+ .pop-trigger.trigger-ghost:hover:not(:disabled) {
111
182
  background: var(--bg-elevated-2);
183
+ border-color: transparent;
184
+ }
185
+ .pop-trigger.trigger-danger {
186
+ color: var(--danger);
187
+ border-color: color-mix(in srgb, var(--danger) 50%, var(--border));
188
+ }
189
+ .pop-trigger.trigger-danger:hover:not(:disabled) {
190
+ background: color-mix(in srgb, var(--danger) 14%, transparent);
191
+ border-color: var(--danger);
192
+ }
193
+ .pop-trigger.trigger-sm {
194
+ height: var(--control-height-compact);
195
+ min-height: var(--control-height-compact);
196
+ padding: var(--sp-1) var(--sp-3);
197
+ font-size: var(--fs-xs);
198
+ }
199
+ .pop-trigger.trigger-lg {
200
+ min-height: var(--control-height-large);
201
+ padding: var(--sp-3) var(--sp-5);
202
+ font-size: var(--fs-base);
203
+ }
204
+ .pop-trigger.trigger-control {
205
+ height: var(--control-height);
206
+ min-height: var(--control-height);
207
+ padding: 0 var(--sp-3);
208
+ }
209
+ .pop-trigger.trigger-block {
210
+ width: 100%;
211
+ }
212
+ .pop-trigger.trigger-tone-accent {
213
+ --pop-trigger-tone: var(--accent);
214
+ }
215
+ .pop-trigger.trigger-tone-info {
216
+ --pop-trigger-tone: var(--info);
217
+ }
218
+ .pop-trigger.trigger-tone-warn {
219
+ --pop-trigger-tone: var(--warn);
220
+ }
221
+ .pop-trigger.trigger-tone-danger {
222
+ --pop-trigger-tone: var(--danger);
223
+ }
224
+ .pop-trigger.trigger-tone-accent,
225
+ .pop-trigger.trigger-tone-info,
226
+ .pop-trigger.trigger-tone-warn,
227
+ .pop-trigger.trigger-tone-danger {
228
+ color: var(--pop-trigger-tone);
229
+ border-color: color-mix(in srgb, var(--pop-trigger-tone) 50%, var(--border));
230
+ }
231
+ .pop-trigger.trigger-tone-accent:hover:not(:disabled),
232
+ .pop-trigger.trigger-tone-info:hover:not(:disabled),
233
+ .pop-trigger.trigger-tone-warn:hover:not(:disabled),
234
+ .pop-trigger.trigger-tone-danger:hover:not(:disabled) {
235
+ background: color-mix(in srgb, var(--pop-trigger-tone) 14%, transparent);
236
+ border-color: var(--pop-trigger-tone);
237
+ }
238
+ .pop-trigger:disabled {
239
+ opacity: 0.45;
240
+ cursor: not-allowed;
112
241
  }
113
242
  /* `bare`: strip the chrome down to a plain button the consumer styles. */
114
243
  .pop-trigger.bare {
244
+ height: auto;
115
245
  min-height: 0;
116
246
  min-width: 0;
247
+ width: auto;
117
248
  padding: 0;
118
249
  border: 0;
119
250
  background: none;
120
251
  }
121
- .pop-trigger.bare:hover {
252
+ .pop-trigger.bare:hover:not(:disabled) {
122
253
  background: none;
123
254
  }
124
255
  .pop-panel {
@@ -1,5 +1,8 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
3
+ type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
4
+ type TriggerTone = 'none' | 'accent' | 'info' | 'warn' | 'danger';
5
+ type TriggerSize = 'sm' | 'md' | 'lg';
3
6
  type $$ComponentProps = {
4
7
  placement?: Placement;
5
8
  gap?: number;
@@ -15,6 +18,15 @@ type $$ComponentProps = {
15
18
  /** Drop the default ghost-icon chrome so the trigger is an unstyled button
16
19
  * you fully own (pair with `triggerClass`). */
17
20
  bare?: boolean;
21
+ /** Button-compatible trigger chrome. Omitting every chrome prop preserves
22
+ * the original ghost icon-button trigger. */
23
+ variant?: TriggerVariant;
24
+ tone?: TriggerTone;
25
+ size?: TriggerSize;
26
+ /** Use the shared `--control-height` toolbar/composer contract. */
27
+ control?: boolean;
28
+ block?: boolean;
29
+ disabled?: boolean;
18
30
  onopen?: () => void;
19
31
  onclose?: () => void;
20
32
  };
@@ -22,6 +22,7 @@
22
22
  // Space/Enter (or click) activate. `value` is bindable.
23
23
  import type { Snippet } from 'svelte';
24
24
  import Icon from '../atoms/Icon.svelte';
25
+ import { nextEnabledSegment } from './segmented-control-keyboard.js';
25
26
 
26
27
  let {
27
28
  options,
@@ -68,25 +69,10 @@
68
69
  );
69
70
  }
70
71
  }
71
- // Step to the next non-disabled option in a direction, wrapping around.
72
- function step(from: number, dir: 1 | -1): number {
73
- const n = options.length;
74
- for (let k = 1; k <= n; k++) {
75
- const j = (((from + dir * k) % n) + n) % n;
76
- if (!options[j].disabled) return j;
77
- }
78
- return from;
79
- }
80
72
  function onkeydown(e: KeyboardEvent) {
81
73
  const i = options.findIndex((o) => o.value === value);
82
- if (i < 0) return;
83
- let next = i;
84
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = step(i, 1);
85
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = step(i, -1);
86
- else if (e.key === 'Home') next = options.findIndex((o) => !o.disabled);
87
- else if (e.key === 'End')
88
- next = options.length - 1 - [...options].reverse().findIndex((o) => !o.disabled);
89
- else return;
74
+ const next = nextEnabledSegment(options, i, e.key);
75
+ if (next === undefined) return;
90
76
  e.preventDefault();
91
77
  select(options[next].value, true);
92
78
  }
@@ -165,6 +151,15 @@
165
151
  padding: 0.2rem var(--sp-2);
166
152
  font-size: var(--fs-xs);
167
153
  }
154
+ /* Compact toolbar contract: the outer interactive box exactly matches
155
+ Button size="sm" and Popover size="sm". */
156
+ .seg-sm {
157
+ height: var(--control-height-compact);
158
+ min-height: var(--control-height-compact);
159
+ }
160
+ .seg-sm .seg-item {
161
+ height: 100%;
162
+ }
168
163
  .seg-icon .seg-item {
169
164
  gap: 0;
170
165
  padding: 0.35rem;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Resolve a SegmentedControl navigation key to the next selectable option.
3
+ * Arrow navigation wraps and skips disabled options; Home/End jump to the
4
+ * first/last selectable option. Unhandled keys do not change selection.
5
+ *
6
+ * @param {{ disabled?: boolean }[]} options
7
+ * @param {number} current
8
+ * @param {string} key
9
+ * @returns {number | undefined}
10
+ */
11
+ export function nextEnabledSegment(options: {
12
+ disabled?: boolean;
13
+ }[], current: number, key: string): number | undefined;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve a SegmentedControl navigation key to the next selectable option.
3
+ * Arrow navigation wraps and skips disabled options; Home/End jump to the
4
+ * first/last selectable option. Unhandled keys do not change selection.
5
+ *
6
+ * @param {{ disabled?: boolean }[]} options
7
+ * @param {number} current
8
+ * @param {string} key
9
+ * @returns {number | undefined}
10
+ */
11
+ export function nextEnabledSegment(options, current, key) {
12
+ if (current < 0 || current >= options.length) return undefined;
13
+ if (key === 'Home') {
14
+ const first = options.findIndex((option) => !option.disabled);
15
+ return first >= 0 ? first : current;
16
+ }
17
+ if (key === 'End') {
18
+ const fromEnd = [...options].reverse().findIndex((option) => !option.disabled);
19
+ return fromEnd >= 0 ? options.length - 1 - fromEnd : current;
20
+ }
21
+
22
+ let direction;
23
+ if (key === 'ArrowRight' || key === 'ArrowDown') direction = 1;
24
+ else if (key === 'ArrowLeft' || key === 'ArrowUp') direction = -1;
25
+ else return undefined;
26
+
27
+ for (let offset = 1; offset <= options.length; offset += 1) {
28
+ const next =
29
+ (((current + direction * offset) % options.length) + options.length) % options.length;
30
+ if (!options[next].disabled) return next;
31
+ }
32
+ return current;
33
+ }
@@ -161,8 +161,13 @@
161
161
  --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.45);
162
162
  --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.55);
163
163
 
164
- /* ---- Controls (CCT-250 item 1) — shared height for icon buttons + inputs
165
- so toolbars/composers line up. Button's `control` variant consumes it. */
164
+ /* ---- Controls ----
165
+ `size="sm"` is the compact toolbar contract shared by Button, Popover and
166
+ SegmentedControl. `control` is the roomier uniform-height contract used by
167
+ Button and other composer controls. Both remain rem-based for zoom. */
168
+ --control-height-compact: 2rem;
169
+ --control-height-default: 2.5rem;
170
+ --control-height-large: 3rem;
166
171
  --control-height: 2.75rem;
167
172
 
168
173
  /* ---- Layout ---- */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,7 @@
45
45
  "dev": "vite dev",
46
46
  "build": "vite build",
47
47
  "preview": "vite preview",
48
+ "test": "node --test tests/*.test.js",
48
49
  "prepare": "svelte-kit sync && (lefthook install || true)",
49
50
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
50
51
  "lint": "biome check .",