@dorsk/tsumikit 0.73.0 → 0.73.2

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
@@ -286,7 +286,8 @@ its icon leading and shows its check trailing, or uses the leading check slot wh
286
286
  no icon; selecting closes the menu unless `closeOnSelect={false}` or the item sets `keepOpen`;
287
287
  rest props land on the trigger, `count`/`countMax` paint its corner badge and `panelStyle`
288
288
  sizes the panel; an item with a `control` snippet renders as a non-button row hosting a native
289
- `<select>` or nested Popover trigger, keeping the icon column, row height and ↑/↓ navigation),
289
+ `<select>` or nested Popover trigger, keeping the icon column, row height and ↑/↓ navigation;
290
+ labels may repeat, and an optional `id` keeps a row's identity when the list reorders),
290
291
  Tabs (omit `panel` for a strip-only tablist of document tabs: `closable` + `onclose(id)`
291
292
  close through the ✕, Delete and middle-click, per-tab `leading` snippet, `title`,
292
293
  trailing `actions` snippet; `--tab-max-width` truncates long labels),
@@ -1033,6 +1034,10 @@ half the gap sideways, which stays inside the group's padding, so neighbours
1033
1034
  never overlap. `Input size="sm"` cannot host a pseudo-element and grows to 40px
1034
1035
  instead, as should any control that clips its own overflow:
1035
1036
  `min-height: var(--touch-target-compact)`.
1037
+ `Checkbox`, `Button variant="link"` and the `DataTable` sort button take the same
1038
+ slab; inside a DataTable it is clipped at the table's top edge. A `SegmentedControl`
1039
+ wider than its container switches to the `scroll` layout until it fits again
1040
+ (`wrap` wraps instead), and `block` segments ellipsise their labels.
1036
1041
 
1037
1042
  `--control-height-*` and `--box-*` are **required tokens**: a consumer that
1038
1043
  forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
@@ -561,7 +561,8 @@
561
561
  overflow counts towards the scrollable overflow of every scroll ancestor, and
562
562
  in a row that half lies under the next sibling anyway. Growing further towards
563
563
  the start would let each slab cover the previous sibling's centre. Text
564
- buttons at size sm take the same shape sized by --touch-slab. */
564
+ buttons at size sm and link buttons take the same shape sized by
565
+ --touch-slab. */
565
566
  @media (pointer: coarse) {
566
567
  .btn-icon:not(.hit-compact),
567
568
  .btn-icon-inline:not(.hit-compact),
@@ -593,6 +594,16 @@
593
594
  inset-inline-start: var(--touch-slab);
594
595
  inset-inline-end: 0;
595
596
  }
597
+ .btn-link:not(.hit-compact) {
598
+ position: relative;
599
+ }
600
+ .btn-link:not(.hit-compact)::before {
601
+ content: '';
602
+ position: absolute;
603
+ inset-block: var(--touch-slab);
604
+ inset-inline-start: var(--touch-slab);
605
+ inset-inline-end: 0;
606
+ }
596
607
  }
597
608
 
598
609
  /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
@@ -141,6 +141,18 @@
141
141
  .checkbox:has(input:disabled) {
142
142
  cursor: not-allowed;
143
143
  }
144
+ @media (pointer: coarse) {
145
+ .checkbox {
146
+ position: relative;
147
+ }
148
+ .checkbox::before {
149
+ content: '';
150
+ position: absolute;
151
+ inset-block: var(--touch-slab);
152
+ inset-inline-start: var(--touch-slab);
153
+ inset-inline-end: 0;
154
+ }
155
+ }
144
156
  .sr-only {
145
157
  position: absolute;
146
158
  width: 1px;
@@ -96,7 +96,7 @@
96
96
  aria-valuenow={indeterminate ? undefined : value}
97
97
  >
98
98
  <div class="bar" style={indeterminate ? undefined : `width: ${pct}%`}></div>
99
- {#each markers as m (m.at)}
99
+ {#each markers as m, i (`${i}:${m.at}`)}
100
100
  <span
101
101
  class="marker tone-{m.tone ? canonicalTone(m.tone) : 'text'}"
102
102
  style="left: {markerPct(m.at)}%"
@@ -112,7 +112,7 @@
112
112
 
113
113
  {#snippet optionList()}
114
114
  {#if options}
115
- {#each sections as section, i (section.group ?? `-${i}`)}
115
+ {#each sections as section, i (`${i}:${section.group ?? ''}`)}
116
116
  {#if section.group === undefined}
117
117
  {#each section.options as o (o.value)}
118
118
  <option value={o.value} disabled={o.disabled}>{optionText(o)}</option>
@@ -89,7 +89,7 @@
89
89
  />
90
90
  {#if tickValues.length > 0}
91
91
  <span class="ticks" aria-hidden="true">
92
- {#each tickValues as t (t)}
92
+ {#each tickValues as t, i (`${i}:${t}`)}
93
93
  <i class="tick" class:reached={t <= Number(value)} style="--f: {frac(t)}"></i>
94
94
  {/each}
95
95
  </span>
@@ -100,7 +100,7 @@
100
100
  {/if}
101
101
  {#if marks.length > 0}
102
102
  <span class="marks" aria-hidden="true">
103
- {#each marks as m (m.value)}
103
+ {#each marks as m, i (`${i}:${m.value}`)}
104
104
  <button type="button" class="mark" class:on={Number(value) === m.value} style="--f: {frac(m.value)}" tabindex="-1" onclick={() => (value = m.value)}>{m.label}</button>
105
105
  {/each}
106
106
  </span>
@@ -207,7 +207,9 @@
207
207
  .tabbar :global(.tabbar-badge) {
208
208
  position: absolute;
209
209
  top: -0.4rem;
210
- left: calc(100% - 0.5rem);
210
+ left: calc(100% - 2px);
211
+ max-width: none;
212
+ padding-inline: 0.3rem;
211
213
  }
212
214
  @media (prefers-reduced-motion: reduce) {
213
215
  .tabbar-item {
@@ -173,7 +173,7 @@
173
173
  onclick={(e) => onGridClick(e, close)}
174
174
  onkeydown={(e) => onGridKeydown(e, close)}
175
175
  >
176
- {#each shown as x, i (x.emoji)}
176
+ {#each shown as x, i (`${i}:${x.emoji}`)}
177
177
  <div
178
178
  role="option"
179
179
  class="cell"
@@ -429,7 +429,7 @@
429
429
  {#if open && menu}
430
430
  <div bind:this={menuEl} id={listboxId} class="fi__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
431
431
  <div class="fi__menu-head">{kindLabel[menu.kind]}</div>
432
- {#each menu.items as item, i (item.insert + i)}
432
+ {#each menu.items as item, i (`${i}:${item.insert}`)}
433
433
  <button
434
434
  type="button"
435
435
  id="{uid}-opt-{i}"
@@ -1,6 +1,8 @@
1
1
  <script lang="ts" module>
2
2
  export interface MenuItem {
3
3
  label: string;
4
+ /** Stable row identity; labels may repeat. */
5
+ id?: string;
4
6
  /** Required for button rows; a `control` row's hosted control acts on its own. */
5
7
  onselect?: () => void;
6
8
  icon?: import('../atoms/Icon.svelte').IconName;
@@ -202,7 +204,7 @@
202
204
  >
203
205
  {#snippet children({ close }: { close: () => void })}
204
206
  <div bind:this={listEl} role="none" class="menu" data-tsu="Menu" tabindex="-1" {onkeydown}>
205
- {#each items as item (item.label)}
207
+ {#each items as item, i (item.id ?? `${i}:${item.label}`)}
206
208
  {#if item.control}
207
209
  <div {...item.attrs as HTMLAttributes<HTMLDivElement>} role="none" class="menu-item menu-row" class:danger={item.danger} inert={item.disabled}>
208
210
  {#if item.icon}<Icon name={item.icon} />{/if}
@@ -1,5 +1,7 @@
1
1
  export interface MenuItem {
2
2
  label: string;
3
+ /** Stable row identity; labels may repeat. */
4
+ id?: string;
3
5
  /** Required for button rows; a `control` row's hosted control acts on its own. */
4
6
  onselect?: () => void;
5
7
  icon?: import('../atoms/Icon.svelte').IconName;
@@ -390,7 +390,7 @@
390
390
  {/if}
391
391
  <div id={menuId} role="menu" aria-label={label} class="radial-menu">
392
392
  {#if open}
393
- {#each petals as item, i (item.label)}
393
+ {#each petals as item, i (item.id ?? `${i}:${item.label}`)}
394
394
  {@const p = points[i]}
395
395
  {#if p}
396
396
  {@const glyphs = menuItemGlyphs(item)}
@@ -24,6 +24,7 @@
24
24
  // browser-free keyboard model matches Tabs: ←/→ and Home/End move selection,
25
25
  // Space/Enter (or click) activate. `value` is bindable.
26
26
  import type { Snippet } from 'svelte';
27
+ import type { Attachment } from 'svelte/attachments';
27
28
  import Icon from '../atoms/Icon.svelte';
28
29
  import { formatSegmentCount } from './segmented-control-count.js';
29
30
  import { nextEnabledSegment } from './segmented-control-keyboard.js';
@@ -95,6 +96,26 @@
95
96
  );
96
97
  const segId = (val: string) => `${baseId}-${val}`;
97
98
 
99
+ // A pill wider than its container falls back to the `scroll` layout; the
100
+ // class is dropped again once the row fits so the touch slab stays unclipped.
101
+ let overflowing = $state(false);
102
+ const watchOverflow: Attachment<HTMLElement> = (node) => {
103
+ if (wrap || typeof ResizeObserver === 'undefined') {
104
+ overflowing = false;
105
+ return;
106
+ }
107
+ const check = () => {
108
+ overflowing = node.scrollWidth - node.clientWidth > 1;
109
+ };
110
+ const ro = new ResizeObserver(check);
111
+ ro.observe(node);
112
+ check();
113
+ return () => {
114
+ ro.disconnect();
115
+ overflowing = false;
116
+ };
117
+ };
118
+
98
119
  function select(val: string, focus = false) {
99
120
  if (options.find((o) => o.value === val)?.disabled) return;
100
121
  value = val;
@@ -127,8 +148,10 @@
127
148
  class:collapse-container={collapseLabels === 'container'}
128
149
  class:seg-scroll={scroll}
129
150
  class:seg-block={block}
151
+ class:seg-overflow={overflowing}
130
152
  data-tsu="SegmentedControl"
131
153
  {onkeydown}
154
+ {@attach watchOverflow}
132
155
  >
133
156
  {#each options as o (o.value)}
134
157
  {@const selected = value === o.value}
@@ -165,6 +188,8 @@
165
188
  background: var(--bg-elevated-2);
166
189
  border: 1px solid var(--border);
167
190
  border-radius: var(--r-pill);
191
+ max-width: 100%;
192
+ min-width: 0;
168
193
  }
169
194
  .seg-icon {
170
195
  border-radius: var(--r-md);
@@ -193,6 +218,19 @@
193
218
  .seg-scroll .seg-item {
194
219
  flex: none;
195
220
  }
221
+ .seg-overflow {
222
+ flex-wrap: nowrap;
223
+ overflow-x: auto;
224
+ overflow-y: hidden;
225
+ scrollbar-width: none;
226
+ -webkit-overflow-scrolling: touch;
227
+ }
228
+ .seg-overflow::-webkit-scrollbar {
229
+ display: none;
230
+ }
231
+ .seg-overflow .seg-item {
232
+ flex: none;
233
+ }
196
234
  .seg-block {
197
235
  display: flex;
198
236
  width: 100%;
@@ -200,6 +238,12 @@
200
238
  .seg-block .seg-item {
201
239
  flex: 1 1 0;
202
240
  justify-content: center;
241
+ min-width: 0;
242
+ }
243
+ .seg-block .seg-label {
244
+ min-width: 0;
245
+ overflow: hidden;
246
+ text-overflow: ellipsis;
203
247
  }
204
248
 
205
249
  .seg-item {
@@ -92,7 +92,7 @@
92
92
  onchange={(e) => onchange((e.currentTarget as HTMLSelectElement).value)}
93
93
  >
94
94
  {#if groups}
95
- {#each groups as g (g.label)}
95
+ {#each groups as g, i (`${i}:${g.label}`)}
96
96
  <optgroup label={g.label}>
97
97
  {#each g.options as o (o.value)}
98
98
  <option value={o.value}>{o.label}</option>
@@ -18,6 +18,10 @@
18
18
  * a drag that is more vertical than horizontal is left to the scroller.
19
19
  * The same actions are reachable as visually hidden buttons for keyboard
20
20
  * and screen-reader users.
21
+ *
22
+ * On touch the browser implicitly captures the pointer on the element under
23
+ * the finger; taking capture on the row makes that child fire a bubbling
24
+ * `lostpointercapture`, which must not be read as a cancel.
21
25
  */
22
26
  import type { Snippet } from 'svelte';
23
27
  import type { HTMLAttributes } from 'svelte/elements';
@@ -162,8 +166,8 @@
162
166
  e.preventDefault();
163
167
  }
164
168
 
165
- function onlostpointercapture() {
166
- if (pointerId === null) return;
169
+ function onlostpointercapture(e: PointerEvent) {
170
+ if (pointerId === null || e.pointerId !== pointerId || e.target !== e.currentTarget) return;
167
171
  pointerId = null;
168
172
  locked = null;
169
173
  dragging = false;
@@ -13,6 +13,10 @@ export interface SwipeAction {
13
13
  * a drag that is more vertical than horizontal is left to the scroller.
14
14
  * The same actions are reachable as visually hidden buttons for keyboard
15
15
  * and screen-reader users.
16
+ *
17
+ * On touch the browser implicitly captures the pointer on the element under
18
+ * the finger; taking capture on the row makes that child fire a bubbling
19
+ * `lostpointercapture`, which must not be read as a cancel.
16
20
  */
17
21
  import type { Snippet } from 'svelte';
18
22
  import type { HTMLAttributes } from 'svelte/elements';
@@ -503,6 +503,18 @@
503
503
  .dt-actions {
504
504
  opacity: 1;
505
505
  }
506
+ /* Sort slab: inline-end stays at 0 so it never reaches the next header
507
+ cell, and the inline-start reach stays inside the cell's own padding. */
508
+ .dt-sort {
509
+ position: relative;
510
+ }
511
+ .dt-sort::before {
512
+ content: '';
513
+ position: absolute;
514
+ inset-block: var(--touch-slab);
515
+ inset-inline-start: var(--touch-slab);
516
+ inset-inline-end: 0;
517
+ }
506
518
  }
507
519
  @container (max-width: 30rem) {
508
520
  [data-hide-below='sm'] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.73.0",
3
+ "version": "0.73.2",
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",