@dorsk/tsumikit 0.67.0 → 0.68.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
@@ -207,7 +207,7 @@ sanctioned escape hatch for everything else.
207
207
  | `Divider` | `--divider-color`, `--divider-spacing` |
208
208
  | `Gauge` | `--gauge-w`, `--gauge-h`, `--gauge-fill` |
209
209
  | `MasterDetail` | `--md-list-w`, `--md-gap`, `--md-divider` |
210
- | `NavBar` | `--navbar-max` |
210
+ | `NavBar` | `--navbar-max`, `--navbar-center-w`, `--navbar-center-raise` |
211
211
  | `Callout` | `--callout-tone` |
212
212
  | `Fieldset` | `--fieldset-pad`, `--fieldset-border` |
213
213
  | `GitRef` | `--git-ref-tone`, `--git-ref-max-width` |
@@ -215,6 +215,8 @@ sanctioned escape hatch for everything else.
215
215
  | `Combobox` | `--cb-bg`, `--cb-border`, `--cb-radius`, `--cb-active-bg`, `--cb-min-width`, `--cb-max-height` |
216
216
  | `Toggle` | `--toggle-accent`, `--toggle-size` |
217
217
  | `Popover` | `--pop-trigger-bg`, `--pop-trigger-fg`, `--pop-trigger-border`, `--pop-trigger-radius`, `--pop-trigger-size`, `--pop-trigger-pad`, `--pop-box`, `--pop-count-bg`, `--pop-count-fg`, `--pop-count-size` |
218
+ | `SortableList` | `--sortable-row-h` |
219
+ | `RadialMenu` | `--radial-radius`, `--radial-petal-size`, `--radial-fab-size` |
218
220
 
219
221
  `tests/css-custom-property-contract.test.js` reads this table and fails if a
220
222
  listed property is not actually read by its component, so the docs cannot drift.
@@ -278,7 +280,7 @@ any custom SVG).
278
280
  filled pin/star toggle), SelectButton, Toggle, OptionButton (`value` +
279
281
  `description` secondary text wired via `aria-describedby`, `onfocuschange(value)` on
280
282
  focus/hover to drive a preview pane), Modal,
281
- Popover, Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet,
283
+ Popover, RadialMenu (a FAB that fans `Menu` items out as petals on an arc, see below), Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet,
282
284
  and an `attrs` object for `data-*`/test ids on the row; a checkable `pressed` item keeps
283
285
  its icon leading and shows its check trailing, or uses the leading check slot when it has
284
286
  no icon; selecting closes the menu unless `closeOnSelect={false}` or the item sets `keepOpen`;
@@ -322,6 +324,7 @@ then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes i
322
324
  a copy-on-click button; candidates via the `workingDirCandidates` helper).
323
325
  Fieldset (bordered zone, legend rides the border; `droppable` makes it an HTML5
324
326
  drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job),
327
+ SortableList (reorderable list: grip drag on Pointer Events, keyboard grab, ↑/↓ arrows, see below),
325
328
  Combobox (suggestion listbox for a field you render as `children` — an Input for
326
329
  plain autocomplete or a Textarea for `@`/`#` mentions; the kit sets the field's
327
330
  combobox ARIA and `aria-activedescendant`, floats the `role="listbox"` in the top
@@ -360,7 +363,10 @@ children are `min-width: 0` so a wide title/actions row can't widen the grid on
360
363
  mobile), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
361
364
  icon/emoji + label + count badge; `placement="bar"` pins it to the bottom edge
362
365
  over `--z-nav` with `--safe-bottom` padding, `orientation` stacks or inlines the
363
- glyph, `maxWidth` centres the row), NavItem
366
+ glyph, `maxWidth` centres the row; items without `href` are buttons that set a
367
+ bindable `value` / `onchange`, in a `tablist` when none are links; `center`
368
+ reserves a notch the items split around, for a raised FAB; `variant="floating"`
369
+ insets it as an elevated rounded card), NavItem
364
370
  (collapses to an icon rail when the sidebar is narrow; `icon` from the
365
371
  registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
366
372
  for the inset-bar active look), MasterDetail (list + detail columns that
@@ -721,6 +727,41 @@ panel.
721
727
  ]} />
722
728
  ```
723
729
 
730
+ ### RadialMenu
731
+
732
+ `RadialMenu` is a round FAB that opens its `items` (the same `MenuItem` type
733
+ `Menu` takes: `label`, `icon`, `onselect`, `danger`, `disabled`, `pressed`,
734
+ `keepOpen`, `attrs`) as round icon-over-label petals on an arc around it, over
735
+ a `Scrim` (`scrim={false}` drops it; a click outside still closes). `arc`
736
+ `{ from, to }` is in degrees, 0 = right, counter-clockwise; the default
737
+ `{ from: 200, to: -20 }` is an upward fan. Petals sit at equal angles at
738
+ `radius` (default `--radial-radius`, else `8.5rem`) and are `petalSize` wide
739
+ (default `--radial-petal-size`, else `3.5rem`, never under `--touch-target`).
740
+ The panel lives in the top layer, so it escapes `overflow: hidden` parents and
741
+ a fixed bottom bar; the radius shrinks, and then the fan shifts, to keep every
742
+ petal inside the viewport and its safe-area insets. `status` renders a line
743
+ centred above the arc.
744
+
745
+ The default trigger is a `--radial-fab-size` (3.75rem) circle with a 2px ring
746
+ in `tone` (default `accent`) that swaps `icon` for `closeIcon` while open and
747
+ shows an optional `caption` (string or snippet); `trigger` replaces its content
748
+ and receives `{ open }`. `open` is bindable, with `onopen` / `onclose`; rest
749
+ props land on the trigger. Tap toggles; press-and-slide (press the FAB, drag
750
+ over a petal, release) fires the petal under the finger, sharing the gesture
751
+ helper with `GlyphStack expand="slide"`. The trigger carries `aria-haspopup`,
752
+ `aria-expanded` and `aria-controls`; petals are `menuitem`s with a roving
753
+ tabindex. Opening from the keyboard focuses the first enabled petal; ←/→ walk
754
+ the arc in screen order, ↓/↑ in item order, Home/End jump to the ends,
755
+ Enter/Space fire and close, Escape or Tab close and return focus to the
756
+ trigger. Under `prefers-reduced-motion` petals appear without the staggered
757
+ scale-in.
758
+
759
+ ```svelte
760
+ <RadialMenu label="Bot" {items} caption="● bot" tone="ok">
761
+ {#snippet status()}● Armed · explore · in world{/snippet}
762
+ </RadialMenu>
763
+ ```
764
+
724
765
  ### SplitButton
725
766
 
726
767
  `SplitButton` fuses a primary action with a narrow caret that opens a `Menu`:
@@ -741,6 +782,45 @@ control height so it reads as secondary. ArrowDown on the primary opens the menu
741
782
  <SplitButton variant="primary" label="Send options" {items} onclick={send}>Send</SplitButton>
742
783
  ```
743
784
 
785
+ ### SortableList
786
+
787
+ `SortableList` reorders a list the consumer owns. Pass `items` and a `key`, render
788
+ each row's content with the `row` snippet; the kit draws the grip, the ordinal
789
+ (`numbered`) and the ↑/↓ arrows (`arrows`, on by default) around it. `onreorder(keys,
790
+ { key, from, to })` fires on a drop or an arrow press with the full new key order.
791
+ The list shows that order at once and keeps it until `items` changes; if the
792
+ consumer never adopts it, it falls back to `items` after `revertAfter` ms
793
+ (3000). `canMove(key, to)` vetoes a target; `locked(item)` removes the grip and
794
+ disables the arrows. `selected` (bindable) + `onselect` turn the row body into a
795
+ button with an accent tint and a 2px inset bar on the selected row; put row
796
+ actions in the `trailing` snippet so they stay outside that button.
797
+
798
+ Drag runs on Pointer Events, so it works on touch. The grip has `touch-action:
799
+ none`; `handle="row"` drags from anywhere on the row, and touch then starts
800
+ after a 250 ms long-press so the list still scrolls. The lifted row casts
801
+ `--shadow-md` over a dashed slot, and the nearest scroll container scrolls at
802
+ its edges. On the keyboard the grip is a "Reorder {name}" button: Space/Enter
803
+ grabs, ↑/↓ moves, Home/End jumps, Space/Enter drops, Escape restores.
804
+ Alt+↑/↓ moves the focused row in one step. A polite live region announces each
805
+ step; override any string through `messages` (defaults in
806
+ `SORTABLE_LIST_MESSAGES`). `size` is `sm` (1.875rem rows) or `md` (2.25rem);
807
+ `--sortable-row-h` overrides both.
808
+
809
+ ```svelte
810
+ <SortableList
811
+ items={rares}
812
+ key={(r) => r.id}
813
+ name={(r) => r.name}
814
+ label="Rare hunt order"
815
+ numbered
816
+ size="sm"
817
+ bind:selected
818
+ onreorder={(keys) => send({ rare_order: keys })}
819
+ >
820
+ {#snippet row(r)}<span>{r.name}</span><span>{r.dist}</span>{/snippet}
821
+ </SortableList>
822
+ ```
823
+
744
824
  ## Container queries
745
825
 
746
826
  AppShell's `main` and `sidebar` are query containers (`container-name: main` /
@@ -835,6 +915,19 @@ counter elsewhere.
835
915
  <Popover label="Filters" count={active}>{#snippet trigger()}<Icon name="filter" />{/snippet}…</Popover>
836
916
  ```
837
917
 
918
+ `layout="stacked"` turns a `Button` into a hotbar slot: `glyph` (a registry
919
+ icon name, any text such as `■`, or a snippet) over the label, with heights
920
+ following `size` (sm 2.5rem, md 3rem, lg 3.625rem). `glyph` also leads an
921
+ inline button. `shortcut` paints a faint mono key hint in the top-left corner,
922
+ opposite `count`, and sets `aria-keyshortcuts`; the app still binds the key.
923
+ `tone="danger"` gives a stacked slot a danger border and ink, `pressed` sets
924
+ `aria-pressed` and an accent ring, and coarse pointers get the touch hit slab.
925
+
926
+ ```svelte
927
+ <Button layout="stacked" size="sm" glyph="■" shortcut="1" tone="danger" onclick={disarm}>Disarm</Button>
928
+ <Button layout="stacked" size="sm" glyph="search" shortcut="2">Go zone</Button>
929
+ ```
930
+
838
931
  ### Square boxes & touch targets
839
932
 
840
933
  Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
@@ -852,10 +945,14 @@ glyphs (`size` stays the SVG px, emoji ×1.35).
852
945
  On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
853
946
  trigger) carries an invisible `::after` slab that extends its hit area to
854
947
  `--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
855
- untouched. The slab grows on both block sides but only towards the inline
856
- *start*: inline-end overflow would count towards the scrollable overflow of
857
- every scroll ancestor, so a button near the right edge of an `overflow-y: auto`
858
- container would make it scroll sideways by a pixel or two.
948
+ untouched. The slab is the centred target with its inline-*end* half cut off:
949
+ 44px tall, and the box plus half the deficit wide (34px for a 24px icon).
950
+ Inline-end overflow would count towards the scrollable overflow of every scroll
951
+ ancestor, so a button near the right edge of an `overflow-y: auto` container
952
+ would make it scroll sideways; in a row that half lies under the next sibling's
953
+ slab anyway. Growing the whole deficit towards the start instead would let each
954
+ slab cover the previous sibling's centre, so taps would land one icon to the
955
+ right.
859
956
  `hitArea="compact"` opts a dense-table row out. `Button
860
957
  collapseLabel="mobile" | "container"` hides `<span data-label>` children below
861
958
  40rem viewport / 30rem container, drops to icon padding, and names the button
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import Icon, { isIconName } from './Icon.svelte';
2
3
  import { formatCount, hasCount } from '../../count';
3
4
  import type { ControlSize } from '../../size';
4
5
  import { canonicalTone, type Tone } from '../../tone';
@@ -51,7 +52,7 @@
51
52
  // container (`container`) so the button becomes icon-only; the hidden text
52
53
  // becomes the aria-label unless one is set.
53
54
  collapseLabel?: 'never' | 'mobile' | 'container';
54
- // Icon-only/square buttons grow a 44px hit slab on coarse pointers without
55
+ // Icon-only/square buttons grow a touch hit slab on coarse pointers without
55
56
  // moving layout; `compact` opts out in dense tables.
56
57
  hitArea?: 'auto' | 'compact';
57
58
  // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
@@ -62,6 +63,16 @@
62
63
  // exact number. Tint via `--btn-count-bg` / `--btn-count-fg`.
63
64
  count?: number;
64
65
  countMax?: number;
66
+ // `stacked`: a hotbar slot, `glyph` over the label, height following `size`.
67
+ layout?: 'inline' | 'stacked';
68
+ // Leading glyph (above the label when stacked): a registry icon name, any
69
+ // other text (emoji, ■), or a snippet.
70
+ glyph?: Snippet | string;
71
+ // Key hint in the top-left corner, also exposed as `aria-keyshortcuts`.
72
+ // Display only: the app binds the key.
73
+ shortcut?: string;
74
+ // Two-state toggle: sets `aria-pressed`.
75
+ pressed?: boolean;
65
76
  class?: string;
66
77
  children?: Snippet;
67
78
  };
@@ -88,6 +99,10 @@
88
99
  loading = false,
89
100
  count,
90
101
  countMax = 99,
102
+ layout = 'inline',
103
+ glyph,
104
+ shortcut,
105
+ pressed,
91
106
  type = 'button',
92
107
  disabled = false,
93
108
  title,
@@ -114,6 +129,9 @@
114
129
  // folded into it; without one the sr-only span below is read after the label.
115
130
  const withCount = (name: string) => (counted ? `${name}, ${count}` : name);
116
131
 
132
+ const stacked = $derived(layout === 'stacked');
133
+ const keyHint = $derived(shortcut?.trim() || undefined);
134
+
117
135
  let el = $state<HTMLElement | null>(null);
118
136
  const explicitName = $derived(rest['aria-label'] ?? rest['aria-labelledby']);
119
137
  const ariaLabel = $derived(rest['aria-label'] ? withCount(rest['aria-label']) : undefined);
@@ -136,6 +154,8 @@
136
154
  {...elementAttrs}
137
155
  aria-label={ariaLabel}
138
156
  aria-busy={loading || undefined}
157
+ aria-pressed={pressed ?? rest['aria-pressed']}
158
+ aria-keyshortcuts={rest['aria-keyshortcuts'] ?? keyHint}
139
159
  {title}
140
160
  class="btn {klass}"
141
161
  class:btn-grow={grow}
@@ -166,10 +186,22 @@
166
186
  class:hover-danger={hoverDanger}
167
187
  class:loading
168
188
  class:btn-counted={counted}
189
+ class:btn-stacked={stacked}
190
+ class:btn-hinted={keyHint !== undefined}
169
191
  onclick={onclick}
170
192
  >
171
193
  {#if loading}<span class="btn-spinner" aria-hidden="true"></span>{/if}
172
- {@render children?.()}
194
+ {#if glyph !== undefined}
195
+ <span class="btn-glyph" aria-hidden="true">
196
+ {#if typeof glyph !== 'string'}{@render glyph()}{:else if isIconName(glyph)}<Icon name={glyph} />{:else}{glyph}{/if}
197
+ </span>
198
+ {/if}
199
+ {#if stacked}
200
+ <span class="btn-stack-label">{@render children?.()}</span>
201
+ {:else}
202
+ {@render children?.()}
203
+ {/if}
204
+ {#if keyHint}<span class="btn-shortcut" aria-hidden="true">{keyHint}</span>{/if}
173
205
  {#if counted}
174
206
  <span class="btn-count" aria-hidden="true">{countText}</span>
175
207
  <span class="btn-count-sr">, {count}</span>
@@ -507,29 +539,31 @@
507
539
  }
508
540
 
509
541
  /* Coarse pointers: icon-only and square buttons carry an invisible slab that
510
- extends the hit area to --touch-target without changing layout. Buttons
542
+ extends the hit area towards --touch-target without changing layout. Buttons
511
543
  already at or above the target keep their own box (inset never positive).
512
- The slab grows towards the inline start only: inline-end overflow counts
513
- towards the scrollable overflow of every scroll ancestor, so a button near
514
- the right edge of an `overflow-y: auto` container would make it scroll
515
- sideways. Start-side overflow is unscrollable, so the full target survives. */
544
+ The slab is the centred target with its inline-end half cut off: inline-end
545
+ overflow counts towards the scrollable overflow of every scroll ancestor, and
546
+ in a row that half lies under the next sibling anyway. Growing further towards
547
+ the start would let each slab cover the previous sibling's centre. */
516
548
  @media (pointer: coarse) {
517
549
  .btn-icon:not(.hit-compact),
518
550
  .btn-icon-inline:not(.hit-compact),
519
551
  .btn-box:not(.hit-compact),
520
552
  .btn-square:not(.hit-compact),
521
- .btn-chip:not(.hit-compact) {
553
+ .btn-chip:not(.hit-compact),
554
+ .btn-stacked:not(.hit-compact) {
522
555
  position: relative;
523
556
  }
524
557
  .btn-icon:not(.hit-compact)::after,
525
558
  .btn-icon-inline:not(.hit-compact)::after,
526
559
  .btn-box:not(.hit-compact)::after,
527
560
  .btn-square:not(.hit-compact)::after,
528
- .btn-chip:not(.hit-compact)::after {
561
+ .btn-chip:not(.hit-compact)::after,
562
+ .btn-stacked:not(.hit-compact)::after {
529
563
  content: '';
530
564
  position: absolute;
531
565
  inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
532
- inset-inline-start: min(0px, calc(100% - var(--touch-target)));
566
+ inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
533
567
  inset-inline-end: 0;
534
568
  }
535
569
  }
@@ -541,6 +575,80 @@
541
575
  color: var(--btn-on, var(--accent));
542
576
  }
543
577
 
578
+ /* Stacked hotbar slot: glyph over a short label. Follows the size and
579
+ control rules so its own heights win at equal specificity. */
580
+ .btn-stacked,
581
+ .btn-stacked.btn-sm,
582
+ .btn-stacked.btn-lg,
583
+ .btn-stacked.btn-control {
584
+ flex-direction: column;
585
+ gap: var(--sp-1);
586
+ height: auto;
587
+ min-height: var(--btn-size, var(--control-height-large));
588
+ min-width: var(--btn-size, var(--control-height-large));
589
+ padding: var(--sp-1) var(--sp-2);
590
+ font-size: var(--fs-xs);
591
+ }
592
+ .btn-stacked.btn-sm {
593
+ min-height: var(--btn-size, var(--control-height-default));
594
+ min-width: var(--btn-size, calc(var(--control-height-default) + var(--sp-1) + 0.125rem));
595
+ gap: 0.1875rem;
596
+ padding: var(--sp-1);
597
+ font-size: calc(var(--fs-xs) * 0.85);
598
+ }
599
+ .btn-stacked.btn-lg {
600
+ min-height: var(--btn-size, calc(var(--control-height-large) + var(--sp-2) + 0.125rem));
601
+ min-width: var(--btn-size, calc(var(--control-height-large) + var(--sp-2) + 0.125rem));
602
+ padding: var(--sp-2) var(--sp-3);
603
+ font-size: var(--fs-sm);
604
+ }
605
+ .btn-glyph {
606
+ display: inline-flex;
607
+ align-items: center;
608
+ justify-content: center;
609
+ flex: none;
610
+ line-height: 1;
611
+ }
612
+ .btn-stacked .btn-glyph {
613
+ font-size: var(--fs-lg);
614
+ }
615
+ .btn-stacked.btn-sm .btn-glyph {
616
+ font-size: var(--fs-md);
617
+ }
618
+ .btn-stacked.btn-lg .btn-glyph {
619
+ font-size: var(--fs-xl);
620
+ }
621
+ .btn-stack-label {
622
+ max-width: 100%;
623
+ overflow: hidden;
624
+ text-overflow: ellipsis;
625
+ }
626
+ .btn-stacked.btn-danger,
627
+ .btn-stacked.btn-tone-danger {
628
+ color: var(--danger);
629
+ border-color: var(--danger);
630
+ }
631
+ .btn-stacked[aria-pressed='true'] {
632
+ border-color: var(--btn-on, var(--accent));
633
+ box-shadow: inset 0 0 0 1px var(--btn-on, var(--accent));
634
+ }
635
+
636
+ /* Corner key hint: mirrors the count pill in the opposite corner, inside the box. */
637
+ .btn-hinted {
638
+ position: relative;
639
+ }
640
+ .btn-shortcut {
641
+ position: absolute;
642
+ top: 0.125rem;
643
+ left: 0.25rem;
644
+ font-family: var(--font-mono);
645
+ font-size: calc(var(--fs-xs) * 0.75);
646
+ font-weight: var(--fw-medium);
647
+ color: var(--text-faint);
648
+ line-height: 1;
649
+ pointer-events: none;
650
+ }
651
+
544
652
  /* Loading: keep full opacity (it's busy, not disabled-looking) + a wait cursor,
545
653
  and show a spinner in the current text color. */
546
654
  .btn.loading {
@@ -26,6 +26,10 @@ type ButtonProps = HTMLButtonAttributes & {
26
26
  loading?: boolean;
27
27
  count?: number;
28
28
  countMax?: number;
29
+ layout?: 'inline' | 'stacked';
30
+ glyph?: Snippet | string;
31
+ shortcut?: string;
32
+ pressed?: boolean;
29
33
  class?: string;
30
34
  children?: Snippet;
31
35
  };