@dorsk/tsumikit 0.67.1 → 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"` =
@@ -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';
@@ -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>
@@ -518,14 +550,16 @@
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));
@@ -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
  };
@@ -1,28 +1,43 @@
1
1
  <script lang="ts">
2
- // Horizontal route navigation: a `<nav>` landmark of `<a>` links, one marked
3
- // `aria-current="page"`. `placement="bar"` pins it to the bottom edge over
4
- // `--z-nav` and pads for the iOS home indicator with `--safe-bottom`;
5
- // `placement="inline"` is a plain row for a header. `orientation` stacks the
6
- // icon over the label (phone bar) or sits it beside (header).
2
+ // Horizontal navigation: a `<nav>` landmark of items, one marked current.
3
+ // An item with `href` is an `<a aria-current="page">`; one without is a
4
+ // `<button>` that sets the bindable `value`. When every item is a button the
5
+ // row is a `tablist` (roving tabindex, ←/→/Home/End, `aria-selected`).
6
+ // `placement="bar"` pins it to the bottom edge over `--z-nav` with safe-area
7
+ // padding; `variant="floating"` makes it an inset rounded card. `center`
8
+ // reserves a notch in the middle of the row, items split around it.
9
+ import type { Snippet } from 'svelte';
7
10
  import Icon from '../atoms/Icon.svelte';
8
11
  import type { IconName } from '../atoms/Icon.svelte';
9
12
 
10
13
  export interface NavBarItem {
11
- href: string;
14
+ /** Renders an `<a>`; without it the item is a `<button>`. */
15
+ href?: string;
16
+ /** Selects the item through the bar's `value`. */
17
+ value?: string;
12
18
  label: string;
13
19
  icon?: IconName;
14
20
  /** Rendered instead of `icon` when set. */
15
21
  emoji?: string;
16
22
  /** Count or short text pinned to the glyph. 0 / '' render nothing. */
17
23
  badge?: number | string;
24
+ /** Overrides the `value` match. */
18
25
  current?: boolean;
26
+ /** `preventDefault()` skips the `value` change. */
27
+ onclick?: (e: MouseEvent) => void;
28
+ /** Id of the panel a tab shows (`aria-controls`). */
29
+ controls?: string;
19
30
  }
20
31
 
21
32
  let {
22
33
  items,
23
34
  label,
35
+ value = $bindable(),
36
+ onchange,
37
+ center,
24
38
  placement = 'inline',
25
39
  orientation = 'stacked',
40
+ variant = 'flush',
26
41
  maxWidth,
27
42
  class: klass = '',
28
43
  style: styleProp = '',
@@ -31,11 +46,18 @@
31
46
  items: NavBarItem[];
32
47
  /** Accessible name of the `<nav>` landmark. */
33
48
  label: string;
49
+ /** `value` of the current item, for in-page views without routes. */
50
+ value?: string;
51
+ onchange?: (value: string) => void;
52
+ /** Fills a notch in the middle of the row (`--navbar-center-w`), may rise above it (`--navbar-center-raise`). */
53
+ center?: Snippet;
34
54
  /** 'bar' = fixed to the bottom edge with safe-area padding. */
35
55
  placement?: 'bar' | 'inline';
36
56
  /** Icon over label vs icon beside label. */
37
57
  orientation?: 'stacked' | 'inline';
38
- /** Caps and centres the row of links. */
58
+ /** 'floating' = rounded elevated card inset from the edges. */
59
+ variant?: 'flush' | 'floating';
60
+ /** Caps and centres the row of items. */
39
61
  maxWidth?: string;
40
62
  class?: string;
41
63
  style?: string;
@@ -44,38 +66,127 @@
44
66
 
45
67
  const hasBadge = (badge: NavBarItem['badge']) =>
46
68
  badge !== undefined && badge !== null && badge !== '' && badge !== 0;
69
+
70
+ const tabs = $derived(items.length > 0 && items.every((item) => !item.href));
71
+ const isCurrent = (item: NavBarItem) =>
72
+ item.current ?? (item.value !== undefined && item.value === value);
73
+ const focusIndex = $derived(Math.max(0, items.findIndex(isCurrent)));
74
+ const split = $derived(Math.ceil(items.length / 2));
75
+
76
+ function select(item: NavBarItem, e: MouseEvent) {
77
+ item.onclick?.(e);
78
+ if (e.defaultPrevented || item.value === undefined || item.value === value) return;
79
+ value = item.value;
80
+ onchange?.(item.value);
81
+ }
82
+
83
+ function onkeydown(e: KeyboardEvent) {
84
+ const list = e.currentTarget as HTMLElement;
85
+ const all = [...list.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
86
+ const i = all.indexOf(e.target as HTMLButtonElement);
87
+ if (i < 0) return;
88
+ let next: number;
89
+ if (e.key === 'ArrowRight') next = (i + 1) % all.length;
90
+ else if (e.key === 'ArrowLeft') next = (i - 1 + all.length) % all.length;
91
+ else if (e.key === 'Home') next = 0;
92
+ else if (e.key === 'End') next = all.length - 1;
93
+ else return;
94
+ e.preventDefault();
95
+ all[next].focus();
96
+ all[next].click();
97
+ }
47
98
  </script>
48
99
 
100
+ {#snippet glyph(item: NavBarItem)}
101
+ <span class="navbar-glyph">
102
+ {#if item.emoji}
103
+ <span class="navbar-emoji" aria-hidden="true">{item.emoji}</span>
104
+ {:else if item.icon}
105
+ <Icon name={item.icon} size={20} />
106
+ {/if}
107
+ {#if hasBadge(item.badge)}
108
+ <span class="navbar-badge">{item.badge}</span>
109
+ {/if}
110
+ </span>
111
+ <span class="navbar-label">{item.label}</span>
112
+ {/snippet}
113
+
114
+ {#snippet entry(item: NavBarItem, i: number)}
115
+ {#if item.href}
116
+ <a
117
+ href={item.href}
118
+ class="navbar-link"
119
+ class:current={isCurrent(item)}
120
+ aria-current={isCurrent(item) ? 'page' : undefined}
121
+ onclick={(e) => select(item, e)}
122
+ >
123
+ {@render glyph(item)}
124
+ </a>
125
+ {:else if tabs}
126
+ <button
127
+ type="button"
128
+ role="tab"
129
+ class="navbar-link"
130
+ class:current={isCurrent(item)}
131
+ aria-selected={isCurrent(item)}
132
+ aria-controls={item.controls}
133
+ tabindex={i === focusIndex ? 0 : -1}
134
+ onclick={(e) => select(item, e)}
135
+ >
136
+ {@render glyph(item)}
137
+ </button>
138
+ {:else}
139
+ <button
140
+ type="button"
141
+ class="navbar-link"
142
+ class:current={isCurrent(item)}
143
+ aria-current={isCurrent(item) ? 'page' : undefined}
144
+ onclick={(e) => select(item, e)}
145
+ >
146
+ {@render glyph(item)}
147
+ </button>
148
+ {/if}
149
+ {/snippet}
150
+
49
151
  <nav
50
152
  data-tsu="NavBar"
51
153
  aria-label={label}
52
154
  class="navbar {klass}"
53
155
  class:bar={placement === 'bar'}
156
+ class:floating={variant === 'floating'}
54
157
  class:stacked={orientation === 'stacked'}
55
158
  style="{maxWidth ? `--navbar-max: ${maxWidth};` : ''}{styleProp}"
56
159
  {...rest}
57
160
  >
58
- <div class="navbar-inner">
59
- {#each items as item, i (`${i}:${item.href}`)}
60
- <a
61
- href={item.href}
62
- class="navbar-link"
63
- class:current={item.current}
64
- aria-current={item.current ? 'page' : undefined}
65
- >
66
- <span class="navbar-glyph">
67
- {#if item.emoji}
68
- <span class="navbar-emoji" aria-hidden="true">{item.emoji}</span>
69
- {:else if item.icon}
70
- <Icon name={item.icon} size={20} />
71
- {/if}
72
- {#if hasBadge(item.badge)}
73
- <span class="navbar-badge">{item.badge}</span>
74
- {/if}
75
- </span>
76
- <span class="navbar-label">{item.label}</span>
77
- </a>
78
- {/each}
161
+ <div class="navbar-row">
162
+ <div
163
+ class="navbar-inner"
164
+ class:split={!!center}
165
+ role={tabs ? 'tablist' : undefined}
166
+ aria-label={tabs ? label : undefined}
167
+ onkeydown={tabs ? onkeydown : undefined}
168
+ >
169
+ {#if center}
170
+ <div class="navbar-side" role="none">
171
+ {#each items.slice(0, split) as item, i (`${i}:${item.href ?? item.value ?? item.label}`)}
172
+ {@render entry(item, i)}
173
+ {/each}
174
+ </div>
175
+ <span class="navbar-notch" aria-hidden="true"></span>
176
+ <div class="navbar-side" role="none">
177
+ {#each items.slice(split) as item, j (`${split + j}:${item.href ?? item.value ?? item.label}`)}
178
+ {@render entry(item, split + j)}
179
+ {/each}
180
+ </div>
181
+ {:else}
182
+ {#each items as item, i (`${i}:${item.href ?? item.value ?? item.label}`)}
183
+ {@render entry(item, i)}
184
+ {/each}
185
+ {/if}
186
+ </div>
187
+ {#if center}
188
+ <div class="navbar-center">{@render center()}</div>
189
+ {/if}
79
190
  </div>
80
191
  </nav>
81
192
 
@@ -84,16 +195,41 @@
84
195
  --navbar-max: none;
85
196
  min-width: 0;
86
197
  }
198
+ .navbar-row {
199
+ position: relative;
200
+ min-width: 0;
201
+ max-width: var(--navbar-max);
202
+ margin-inline: auto;
203
+ }
87
204
  .navbar-inner {
88
205
  display: flex;
89
206
  align-items: stretch;
90
207
  justify-content: space-around;
91
208
  gap: var(--sp-1);
92
209
  min-width: 0;
93
- max-width: var(--navbar-max);
94
- margin-inline: auto;
95
210
  min-height: var(--nav-h);
96
211
  }
212
+ .navbar-inner.split {
213
+ display: grid;
214
+ grid-template-columns: minmax(0, 1fr) var(--navbar-center-w, 4.5rem) minmax(0, 1fr);
215
+ }
216
+ .navbar-side {
217
+ display: flex;
218
+ align-items: stretch;
219
+ gap: var(--sp-1);
220
+ min-width: 0;
221
+ }
222
+ .navbar-center {
223
+ position: absolute;
224
+ top: calc(-1 * var(--navbar-center-raise, 0px));
225
+ bottom: var(--navbar-center-raise, 0px);
226
+ left: 50%;
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ width: var(--navbar-center-w, 4.5rem);
231
+ transform: translateX(-50%);
232
+ }
97
233
  .bar {
98
234
  position: fixed;
99
235
  inset-inline: 0;
@@ -105,21 +241,39 @@
105
241
  backdrop-filter: blur(12px);
106
242
  border-top: 1px solid var(--border);
107
243
  }
244
+ .floating {
245
+ padding: var(--sp-1);
246
+ border: 1px solid var(--border-strong);
247
+ border-radius: var(--r-md);
248
+ background: var(--bg-elevated);
249
+ box-shadow: var(--shadow-md);
250
+ }
251
+ .bar.floating {
252
+ left: calc(var(--sp-2) + var(--safe-left));
253
+ right: calc(var(--sp-2) + var(--safe-right));
254
+ bottom: calc(var(--sp-2) + var(--safe-bottom));
255
+ backdrop-filter: none;
256
+ }
108
257
  .navbar-link {
109
258
  position: relative;
110
259
  display: flex;
111
260
  flex: 1 1 0;
112
261
  min-width: 0;
262
+ min-height: var(--touch-target);
113
263
  flex-direction: row;
114
264
  align-items: center;
115
265
  justify-content: center;
116
266
  gap: var(--sp-2);
117
267
  padding: var(--sp-2);
268
+ border: none;
118
269
  border-radius: var(--r-md);
270
+ background: none;
119
271
  color: var(--text-muted);
272
+ font: inherit;
120
273
  font-size: var(--fs-sm);
121
274
  font-weight: var(--fw-medium);
122
275
  text-decoration: none;
276
+ cursor: pointer;
123
277
  transition:
124
278
  background 0.12s var(--ease),
125
279
  color 0.12s var(--ease);