@dorsk/tsumikit 0.57.0 → 0.58.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 (50) hide show
  1. package/README.md +125 -10
  2. package/dist/avatar.d.ts +6 -0
  3. package/dist/avatar.js +17 -0
  4. package/dist/components/atoms/Avatar.svelte +140 -0
  5. package/dist/components/atoms/Avatar.svelte.d.ts +25 -0
  6. package/dist/components/atoms/Badge.svelte +30 -8
  7. package/dist/components/atoms/Badge.svelte.d.ts +7 -0
  8. package/dist/components/atoms/Button.svelte +72 -1
  9. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  10. package/dist/components/atoms/Input.svelte +22 -4
  11. package/dist/components/atoms/Swatch.svelte +114 -0
  12. package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
  13. package/dist/components/atoms/Textarea.svelte +33 -6
  14. package/dist/components/molecules/Accordion.svelte +80 -62
  15. package/dist/components/molecules/Accordion.svelte.d.ts +15 -2
  16. package/dist/components/molecules/Combobox.svelte +299 -0
  17. package/dist/components/molecules/Combobox.svelte.d.ts +108 -0
  18. package/dist/components/molecules/Composer.svelte +32 -29
  19. package/dist/components/molecules/Composer.svelte.d.ts +2 -0
  20. package/dist/components/molecules/Disclosure.svelte +155 -0
  21. package/dist/components/molecules/Disclosure.svelte.d.ts +26 -0
  22. package/dist/components/molecules/IconButton.svelte +8 -0
  23. package/dist/components/molecules/IconButton.svelte.d.ts +4 -0
  24. package/dist/components/molecules/InputGroup.svelte +159 -0
  25. package/dist/components/molecules/InputGroup.svelte.d.ts +23 -0
  26. package/dist/components/molecules/Menu.svelte +26 -4
  27. package/dist/components/molecules/Menu.svelte.d.ts +9 -1
  28. package/dist/components/molecules/OptionButton.svelte +42 -1
  29. package/dist/components/molecules/OptionButton.svelte.d.ts +8 -1
  30. package/dist/components/molecules/Popover.svelte +49 -1
  31. package/dist/components/molecules/Popover.svelte.d.ts +4 -0
  32. package/dist/components/molecules/RadioGroup.svelte +59 -7
  33. package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
  34. package/dist/components/molecules/SplitButton.svelte +181 -0
  35. package/dist/components/molecules/SplitButton.svelte.d.ts +41 -0
  36. package/dist/components/molecules/Tabs.svelte +155 -27
  37. package/dist/components/molecules/Tabs.svelte.d.ts +16 -1
  38. package/dist/components/molecules/combobox-keyboard.d.ts +100 -0
  39. package/dist/components/molecules/combobox-keyboard.js +170 -0
  40. package/dist/components/molecules/menu-item.d.ts +11 -0
  41. package/dist/components/molecules/menu-item.js +9 -0
  42. package/dist/count.d.ts +2 -0
  43. package/dist/count.js +7 -0
  44. package/dist/floating.d.ts +4 -0
  45. package/dist/floating.js +4 -1
  46. package/dist/index.d.ts +9 -0
  47. package/dist/index.js +9 -0
  48. package/dist/input-group-context.d.ts +10 -0
  49. package/dist/input-group-context.js +8 -0
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -189,9 +189,10 @@ sanctioned escape hatch for everything else.
189
189
 
190
190
  | component | published properties |
191
191
  | --- | --- |
192
- | `Button` | `--btn-bg`, `--btn-fg`, `--btn-border`, `--btn-size`, `--btn-radius`, `--btn-tone`, `--btn-on`, `--btn-box` |
192
+ | `Button` | `--btn-bg`, `--btn-fg`, `--btn-border`, `--btn-size`, `--btn-radius`, `--btn-tone`, `--btn-on`, `--btn-box`, `--btn-count-bg`, `--btn-count-fg`, `--btn-count-size` |
193
193
  | `Badge` | `--badge-bg`, `--badge-fg`, `--badge-border`, `--badge-radius`, `--badge-tone`, `--badge-max-width`, `--badge-fs`, `--badge-fw`, `--badge-min-size` |
194
194
  | `Text` | `--txt-fg`, `--txt-size` |
195
+ | `Tabs` | `--tab-max-width` |
195
196
  | `Select` | `--select-bg`, `--select-fg`, `--select-border`, `--select-size`, `--select-radius` |
196
197
  | `Input` | `--input-bg`, `--input-fg`, `--input-border`, `--input-size`, `--input-radius` |
197
198
  | `Textarea` | `--textarea-bg`, `--textarea-fg`, `--textarea-border`, `--textarea-size`, `--textarea-radius` |
@@ -204,7 +205,8 @@ sanctioned escape hatch for everything else.
204
205
  | `Fieldset` | `--fieldset-pad`, `--fieldset-border` |
205
206
  | `GitRef` | `--git-ref-tone`, `--git-ref-max-width` |
206
207
  | `EmptyState` | `--empty-tone` |
207
- | `Popover` | `--pop-trigger-bg`, `--pop-trigger-fg`, `--pop-trigger-border`, `--pop-trigger-radius`, `--pop-trigger-size`, `--pop-trigger-pad`, `--pop-box` |
208
+ | `Combobox` | `--cb-bg`, `--cb-border`, `--cb-radius`, `--cb-active-bg`, `--cb-min-width`, `--cb-max-height` |
209
+ | `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` |
208
210
 
209
211
  `tests/css-custom-property-contract.test.js` reads this table and fails if a
210
212
  listed property is not actually read by its component, so the docs cannot drift.
@@ -248,21 +250,40 @@ is used), Textarea, Select (`options` array with per-option `icon`/`emoji`/`hint
248
250
  the trigger overlays the selected option's glyph + muted hint on the native control), Switch, Checkbox,
249
251
  Slider (`ticks` draws a dot per step for a segmented slider), Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
250
252
  threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
251
- fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
253
+ fallback, `aspect`, `status` overlay), Avatar (grapheme-safe initial or `glyph`, hue hashed from
254
+ `seed`/`name` or a fixed `tone`, `size` xs–lg or px, `shape` circle/square, `decorative`, corner `status`), Card (`tone` tints the surface for inline banners), Badge
252
255
  (`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
253
- `icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
254
- `--badge-tone`), Dot (`ring` dark halo over artwork), Divider (standalone
256
+ `icon`, `numeric`, `truncate`, `variant="text"`, `removable`/`onremove` or any
257
+ trailing action via `actionIcon`/`actionLabel`/`onaction`, `removed` mutes and
258
+ strikes the pill for a restorable entry; all tints derive from
259
+ `--badge-tone`), Dot (`ring` dark halo over artwork), Swatch (colour chip:
260
+ `color` any CSS colour, `size` sm/md/lg, `shape` circle/square, `label` → role=img
261
+ else aria-hidden, `interactive` renders a button with `onclick` + `aria-pressed`
262
+ from `selected`), Divider (standalone
255
263
  horizontal/vertical hairline, `tone`, `spacing`, optional centred label,
256
264
  `decorative` to drop the `separator` role), Link (`tone`, `underline`
257
265
  always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
258
266
  any custom SVG).
259
267
 
260
- **Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
268
+ **Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton (`value` +
269
+ `description` secondary text wired via `aria-describedby`, `onfocuschange(value)` on
270
+ focus/hover to drive a preview pane), Modal,
261
271
  Popover, Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet,
262
- and an `attrs` object for `data-*`/test ids on the row),
263
- Tabs, RadioGroup (`variant="rows"`: bordered rows, per-option `note`/`description`,
272
+ and an `attrs` object for `data-*`/test ids on the row; a checkable `pressed` item keeps
273
+ its icon leading and shows its check trailing, or uses the leading check slot when it has
274
+ no icon; selecting closes the menu unless `closeOnSelect={false}` or the item sets `keepOpen`),
275
+ Tabs (omit `panel` for a strip-only tablist of document tabs: `closable` + `onclose(id)`
276
+ close through the ✕, Delete and middle-click, per-tab `leading` snippet, `title`,
277
+ trailing `actions` snippet; `--tab-max-width` truncates long labels),
278
+ RadioGroup (per-option `description` in every variant, announced via
279
+ `aria-describedby`; `onfocuschange(value)` fires once per newly focused/hovered option;
280
+ `variant="rows"`: bordered rows, per-option `note`,
264
281
  `action(option)` trailing control that never toggles, `below(option)` inline panel),
265
- Tooltip, Accordion, CopyButton, FileButton,
282
+ Tooltip, Disclosure (single collapsible: `header` snippet, bindable `open` or `open` + `onchange` for
283
+ controlled mode, `chevron` start/end/false, native button with `aria-expanded`/`aria-controls` over a
284
+ `region` panel), Accordion (a stack of Disclosures; items take a `title` string or a `summary` snippet,
285
+ per-item `open`/`onchange`, `multiple=false` keeps one open), CopyButton, FileButton, InputGroup (a field with inset
286
+ leading/trailing controls fused into one bordered unit, see below),
266
287
  Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
267
288
  ThemePicker (popover grid of 2×2 palette swatches: bg · surface · text · accent per theme),
268
289
  EmojiPicker (popover with a searchable EN+FR glyph catalogue, grouped tabs, roving-tabindex grid; pass `groups` to swap the catalogue), FontScalePicker (popover with a stepped slider across the five text sizes), SectionHeader, KeyValue, LoadMore,
@@ -282,7 +303,14 @@ Timestamp (`mode` date/time/datetime/relative/iso/`short-iso` for a locale-indep
282
303
  then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
283
304
  a copy-on-click button; candidates via the `workingDirCandidates` helper).
284
305
  Fieldset (bordered zone, legend rides the border; `droppable` makes it an HTML5
285
- drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job).
306
+ drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job),
307
+ Combobox (suggestion listbox for a field you render as `children` — an Input for
308
+ plain autocomplete or a Textarea for `@`/`#` mentions; the kit sets the field's
309
+ combobox ARIA and `aria-activedescendant`, floats the `role="listbox"` in the top
310
+ layer under the field or under the caret via `anchor="caret"`, and handles
311
+ ArrowUp/Down, Enter/Tab (`selectOn`), Escape and blur; `open`/`index` are
312
+ bindable, `onselect`/`onclose(reason)` report picks; `findTrigger`/`applyTrigger`
313
+ are exported for trigger-char detection and token replacement).
286
314
 
287
315
  **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
288
316
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
@@ -473,6 +501,39 @@ error | done, `onload`, `label`, `loadingLabel`, `errorLabel`, `retryLabel`,
473
501
  </SectionHeader>
474
502
  ```
475
503
 
504
+ ### InputGroup
505
+
506
+ One visual unit: `[leading] field [trailing]` with a single border, radius,
507
+ background and focus ring. The field (`Input` or `Textarea`, optionally wrapped
508
+ by the consumer) keeps its own border and spans the whole group width; the
509
+ `leading`/`trailing` snippets are overlaid inside it and the field pads its
510
+ text past them. This is a hard constraint, not a look: Firefox Android zooms to
511
+ the focused element's rect, so a field laid out beside its buttons zooms the
512
+ page. Adornment widths are measured with a `ResizeObserver` into
513
+ `--ig-leading-w`/`--ig-trailing-w`, so a trailing label that changes
514
+ (`Send (42s)`) never runs under the text.
515
+
516
+ The adornments sit flush inside the field's padding as ghost/primary controls,
517
+ no dividers: pick a control one step below the group (`size="sm"`/`box="sm"` in
518
+ an `md` group, `box="xs"` in `sm`, `box="lg"` in `lg`). `align="end"` (default)
519
+ pins them to the bottom edge as a textarea grows; `align="center"` is for a
520
+ single-line `Input`. `size` sm/md/lg, `disabled` and `error` flow to the field
521
+ through context (a `size`/`invalid`/`disabled` set on the field still wins).
522
+ `Textarea` keeps `autoresize`, `maxHeight` and `resize="top"` (the grip is
523
+ clipped to the space between the adornments). Tab order is leading → field →
524
+ trailing; the group shows the focus ring around the whole unit.
525
+
526
+ ```svelte
527
+ <InputGroup>
528
+ {#snippet leading()}<FileButton iconOnly label="Attach" variant="ghost" box="sm" {onfiles} />{/snippet}
529
+ <Textarea bind:value autoresize rows={1} resize="top" maxHeight="40vh" aria-label="Message" />
530
+ {#snippet trailing()}<Button variant="primary" size="sm" onclick={send}>Send</Button>{/snippet}
531
+ </InputGroup>
532
+ ```
533
+
534
+ `Composer` is built on it: attachments above, `FileButton` leading, the send
535
+ button trailing, `resize="top"` opt-in.
536
+
476
537
  ### Artwork
477
538
 
478
539
  `Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
@@ -489,6 +550,45 @@ sm | md | lg | pill, `fit` cover | contain, `hover` for tappable tiles,
489
550
  </Artwork>
490
551
  ```
491
552
 
553
+ ### Avatar
554
+
555
+ `Avatar` is the initials/glyph identity mark for people and accounts. `name`
556
+ gives the initial (first grapheme, so emoji and flags stay whole) and the
557
+ `aria-label`; `glyph` shows an emoji or character instead. The fill hue is
558
+ hashed from `seed` (default `name`) — the same hash as Artwork — or set with
559
+ `hue`; `tone` accent | neutral | none swaps the hash for a fixed palette (`none`
560
+ is for a bare emoji). Hashed fills mix the theme's `--mach-*-sl` pairs with the
561
+ surface and ink tokens at ratios that clear 4.5:1 at every hue on every bundled
562
+ theme. `size` xs | sm | md | lg (1.25–2.5rem) or a pixel number, `shape` circle |
563
+ square, `decorative` drops it from the accessibility tree when the row already
564
+ names the person (otherwise `role="img"`), `status` overlays a Dot on the
565
+ top-right corner, ringed with `--avatar-status-ring` (default `--bg-elevated`).
566
+ `--avatar-bg` / `--avatar-fg` override the colours from outside;
567
+ `avatarInitial(name)` and `avatarHue(seed)` are exported.
568
+
569
+ ```svelte
570
+ <Avatar name={user.name} size={26} decorative />
571
+ <Avatar name="work" glyph="🐙" shape="square" size={16} tone="none" />
572
+ <Avatar name={me} tone="accent" size="sm">
573
+ {#snippet status()}<Dot status="dead" />{/snippet}
574
+ </Avatar>
575
+ ```
576
+
577
+ ### SplitButton
578
+
579
+ `SplitButton` fuses a primary action with a narrow caret that opens a `Menu`:
580
+ one outer radius, square inner corners, a 1px divider, two tab stops. The
581
+ primary takes `onclick`, `type` (submit inside a form) and `loading`; the caret
582
+ takes `label` (its accessible name), `items`, `placement` and the `Menu`
583
+ panel props (`tag`, `panelClass`, `gap`, `open`, `onopen`, `onclose`).
584
+ `variant` (default | primary | danger), `size` and `control` apply to both
585
+ segments and match `Button` heights; `disabled` turns off the whole control,
586
+ `menuDisabled` only the caret. ArrowDown on the primary opens the menu.
587
+
588
+ ```svelte
589
+ <SplitButton variant="primary" label="Send options" {items} onclick={send}>Send</SplitButton>
590
+ ```
591
+
492
592
  ## Container queries
493
593
 
494
594
  AppShell's `main` and `sidebar` are query containers (`container-name: main` /
@@ -553,6 +653,21 @@ Button and Popover share the same semantic tones. For a confirmed positive
553
653
  action, `tone="success"` gives neutral controls a success tint; combine it with
554
654
  `variant="primary"` for a filled success action without consumer CSS.
555
655
 
656
+ `count` on `Button`, `IconButton` and `Popover` (its trigger) paints a corner
657
+ count pill (unread, selected, queued) on any size, variant or `box`. It hides at
658
+ `0`/`undefined`, displays `99+` past `countMax`, and the exact number joins the
659
+ accessible name (`"Inbox, 120"`) whether that comes from `aria-label`, `label`
660
+ or the button's text. The pill is accent on accent-ink, inverted on `primary`;
661
+ retint one with `--btn-count-bg` / `--btn-count-fg` (`--pop-count-*` on a
662
+ Popover). `formatCount(n, max)` / `hasCount(n)` are exported for a matching
663
+ counter elsewhere.
664
+
665
+ ```svelte
666
+ <IconButton icon="bell" label="Notifications" count={unread} />
667
+ <Button variant="primary" count={selected.length} countMax={999}>Apply</Button>
668
+ <Popover label="Filters" count={active}>{#snippet trigger()}<Icon name="filter" />{/snippet}…</Popover>
669
+ ```
670
+
556
671
  ### Square boxes & touch targets
557
672
 
558
673
  Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
@@ -0,0 +1,6 @@
1
+ /** First grapheme of `name` (so emoji, flags and combining marks stay whole),
2
+ * upper-cased. Blank input yields ''. */
3
+ export declare function avatarInitial(name: string): string;
4
+ /** Hue (0–359) for `seed`; the same hash as `artworkHue`, so an Artwork
5
+ * fallback and an Avatar for one name agree on their tint. */
6
+ export declare function avatarHue(seed: string): number;
package/dist/avatar.js ADDED
@@ -0,0 +1,17 @@
1
+ import { artworkHue } from './artwork';
2
+ /** First grapheme of `name` (so emoji, flags and combining marks stay whole),
3
+ * upper-cased. Blank input yields ''. */
4
+ export function avatarInitial(name) {
5
+ const trimmed = name.trim();
6
+ if (!trimmed)
7
+ return '';
8
+ const first = typeof Intl !== 'undefined' && 'Segmenter' in Intl
9
+ ? [...new Intl.Segmenter().segment(trimmed)][0]?.segment
10
+ : [...trimmed][0];
11
+ return (first ?? '').toUpperCase();
12
+ }
13
+ /** Hue (0–359) for `seed`; the same hash as `artworkHue`, so an Artwork
14
+ * fallback and an Avatar for one name agree on their tint. */
15
+ export function avatarHue(seed) {
16
+ return artworkHue(seed);
17
+ }
@@ -0,0 +1,140 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { avatarHue, avatarInitial } from '../../avatar';
5
+
6
+ type Own = {
7
+ name?: string;
8
+ /** Emoji or character shown instead of the initial. */
9
+ glyph?: string;
10
+ /** Seeds the hashed hue. Defaults to `name`. */
11
+ seed?: string;
12
+ /** Explicit hue (0–359); overrides `seed`. */
13
+ hue?: number;
14
+ /** Fixed palette instead of the hashed hue; `none` drops the fill. */
15
+ tone?: 'accent' | 'neutral' | 'none';
16
+ /** Preset box, or a pixel size. */
17
+ size?: 'xs' | 'sm' | 'md' | 'lg' | number;
18
+ shape?: 'circle' | 'square';
19
+ /** `aria-hidden` when the surrounding element already names the person. */
20
+ decorative?: boolean;
21
+ /** Overlay pinned to the top-right corner (a Dot). */
22
+ status?: Snippet;
23
+ class?: string;
24
+ };
25
+ let {
26
+ name = '',
27
+ glyph,
28
+ seed,
29
+ hue,
30
+ tone,
31
+ size = 'md',
32
+ shape = 'circle',
33
+ decorative = false,
34
+ status,
35
+ class: klass = '',
36
+ ...rest
37
+ }: Omit<HTMLAttributes<HTMLSpanElement>, keyof Own> & Own = $props();
38
+
39
+ const text = $derived(glyph?.trim() || avatarInitial(name) || '?');
40
+ const hashed = $derived(tone === undefined);
41
+ const resolvedHue = $derived(hashed ? (hue ?? avatarHue(seed ?? name)) : undefined);
42
+ </script>
43
+
44
+ <span
45
+ data-tsu="Avatar"
46
+ class="avatar {klass}"
47
+ class:hued={hashed}
48
+ class:accent={tone === 'accent'}
49
+ class:neutral={tone === 'neutral'}
50
+ class:bare={tone === 'none'}
51
+ class:square={shape === 'square'}
52
+ class:glyph={glyph !== undefined}
53
+ class:size-xs={size === 'xs'}
54
+ class:size-sm={size === 'sm'}
55
+ class:size-lg={size === 'lg'}
56
+ style:--avatar-size={typeof size === 'number' ? `${size}px` : undefined}
57
+ style:--avatar-hue={resolvedHue}
58
+ role={decorative ? undefined : 'img'}
59
+ aria-label={decorative ? undefined : name}
60
+ aria-hidden={decorative ? 'true' : undefined}
61
+ {...rest}
62
+ >
63
+ <span class="text" aria-hidden="true">{text}</span>
64
+ {#if status}
65
+ <span class="status">{@render status()}</span>
66
+ {/if}
67
+ </span>
68
+
69
+ <style>
70
+ .avatar {
71
+ --avatar-size: 2rem;
72
+ position: relative;
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ flex: none;
77
+ width: var(--avatar-size);
78
+ height: var(--avatar-size);
79
+ border-radius: var(--r-pill);
80
+ font-weight: var(--fw-semibold);
81
+ font-size: calc(var(--avatar-size) * 0.46);
82
+ line-height: 1;
83
+ background: var(--avatar-bg, var(--bg-elevated-2));
84
+ color: var(--avatar-fg, var(--text-muted));
85
+ user-select: none;
86
+ vertical-align: middle;
87
+ }
88
+ .size-xs {
89
+ --avatar-size: 1.25rem;
90
+ }
91
+ .size-sm {
92
+ --avatar-size: 1.5rem;
93
+ }
94
+ .size-lg {
95
+ --avatar-size: 2.5rem;
96
+ }
97
+ .square {
98
+ border-radius: calc(var(--avatar-size) / 4);
99
+ }
100
+ /* The fill carries the identity; the initial is theme ink with a hue tint.
101
+ These ratios are the ones that clear 4.5:1 at every hue on every bundled
102
+ theme (see tests/avatar-contract.test.js). */
103
+ .hued {
104
+ background: var(
105
+ --avatar-bg,
106
+ color-mix(in srgb, hsl(var(--avatar-hue) var(--mach-bg-sl)) 70%, var(--bg))
107
+ );
108
+ color: var(
109
+ --avatar-fg,
110
+ color-mix(in srgb, hsl(var(--avatar-hue) var(--mach-fg-sl)) 25%, var(--text))
111
+ );
112
+ }
113
+ .accent {
114
+ background: var(--avatar-bg, color-mix(in srgb, var(--accent) 18%, transparent));
115
+ color: var(--avatar-fg, var(--accent));
116
+ }
117
+ .bare {
118
+ background: var(--avatar-bg, none);
119
+ color: var(--avatar-fg, inherit);
120
+ }
121
+ .glyph {
122
+ font-size: calc(var(--avatar-size) * 0.72);
123
+ font-weight: var(--fw-normal);
124
+ }
125
+ .text {
126
+ display: block;
127
+ overflow: hidden;
128
+ white-space: nowrap;
129
+ }
130
+ .status {
131
+ position: absolute;
132
+ top: 0;
133
+ right: 0;
134
+ display: inline-flex;
135
+ line-height: 0;
136
+ border-radius: var(--r-pill);
137
+ transform: translate(25%, -25%);
138
+ box-shadow: 0 0 0 2px var(--avatar-status-ring, var(--bg-elevated));
139
+ }
140
+ </style>
@@ -0,0 +1,25 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Own = {
4
+ name?: string;
5
+ /** Emoji or character shown instead of the initial. */
6
+ glyph?: string;
7
+ /** Seeds the hashed hue. Defaults to `name`. */
8
+ seed?: string;
9
+ /** Explicit hue (0–359); overrides `seed`. */
10
+ hue?: number;
11
+ /** Fixed palette instead of the hashed hue; `none` drops the fill. */
12
+ tone?: 'accent' | 'neutral' | 'none';
13
+ /** Preset box, or a pixel size. */
14
+ size?: 'xs' | 'sm' | 'md' | 'lg' | number;
15
+ shape?: 'circle' | 'square';
16
+ /** `aria-hidden` when the surrounding element already names the person. */
17
+ decorative?: boolean;
18
+ /** Overlay pinned to the top-right corner (a Dot). */
19
+ status?: Snippet;
20
+ class?: string;
21
+ };
22
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLSpanElement>, keyof Own> & Own;
23
+ declare const Avatar: import("svelte").Component<$$ComponentProps, {}, "">;
24
+ type Avatar = ReturnType<typeof Avatar>;
25
+ export default Avatar;
@@ -4,7 +4,9 @@
4
4
  // props rather than separate components:
5
5
  // • state → `tone` semantic palette, or `color` for any CSS colour
6
6
  // • info → `mono` for paths/ids/code-ish metadata
7
- // • tag → `removable` renders a dismiss button + fires `onremove`
7
+ // • tag → `removable` renders a dismiss button + fires `onremove`;
8
+ // `actionIcon`/`onaction` swap in any other trailing action and
9
+ // `removed` mutes + strikes the pill for a "restorable" state
8
10
  // Polymorphic via `as` so it can be a static <span> or an interactive
9
11
  // <button>. Every tinted style derives from the `--badge-tone` custom
10
12
  // property, which is the public hook for consumers who theme by CSS.
@@ -63,6 +65,13 @@
63
65
  icon?: IconName;
64
66
  removable?: boolean;
65
67
  onremove?: (e: MouseEvent) => void;
68
+ /** Trailing action glyph; renders the action button even without `removable`. */
69
+ actionIcon?: IconName;
70
+ /** Accessible name for the action button (defaults to "Remove"). */
71
+ actionLabel?: string;
72
+ onaction?: (e: MouseEvent) => void;
73
+ /** Muted, struck-through look for an entry pending removal. */
74
+ removed?: boolean;
66
75
  class?: string;
67
76
  children?: Snippet;
68
77
  };
@@ -85,6 +94,10 @@
85
94
  icon,
86
95
  removable = false,
87
96
  onremove,
97
+ actionIcon,
98
+ actionLabel,
99
+ onaction,
100
+ removed = false,
88
101
  class: klass = '',
89
102
  grow = false,
90
103
  shrink = true,
@@ -94,6 +107,7 @@
94
107
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
95
108
 
96
109
  const toneColor = $derived(color ?? (tone === 'neutral' ? undefined : TONE_COLOR[tone]));
110
+ const hasAction = $derived(removable || actionIcon !== undefined);
97
111
  </script>
98
112
 
99
113
  <svelte:element
@@ -120,6 +134,7 @@
120
134
  class:truncate
121
135
  class:borderless={!border}
122
136
  class:active
137
+ class:removed
123
138
  class:interactive={as === 'button' || !!href}
124
139
  style:--badge-tone={toneColor}
125
140
  style:--badge-max-width={maxWidth}
@@ -137,14 +152,14 @@
137
152
  {@render children?.()}
138
153
  {/if}
139
154
  {#if href && external}<Icon name="external" />{/if}
140
- {#if removable}
155
+ {#if hasAction}
141
156
  <button
142
157
  type="button"
143
- class="remove"
144
- aria-label="Remove"
145
- onclick={(e) => onremove?.(e)}
158
+ class="action"
159
+ aria-label={actionLabel ?? 'Remove'}
160
+ onclick={(e) => (onaction ?? onremove)?.(e)}
146
161
  >
147
- ×
162
+ {#if actionIcon}<Icon name={actionIcon} />{:else}×{/if}
148
163
  </button>
149
164
  {/if}
150
165
  </svelte:element>
@@ -253,7 +268,14 @@
253
268
  outline: 2px solid var(--badge-tone, var(--accent));
254
269
  outline-offset: 2px;
255
270
  }
256
- .remove {
271
+ .removed {
272
+ opacity: 0.55;
273
+ text-decoration: line-through;
274
+ }
275
+ .removed .action {
276
+ text-decoration: none;
277
+ }
278
+ .action {
257
279
  display: inline-flex;
258
280
  align-items: center;
259
281
  justify-content: center;
@@ -268,7 +290,7 @@
268
290
  opacity: 0.6;
269
291
  transition: opacity 0.12s var(--ease);
270
292
  }
271
- .remove:hover {
293
+ .action:hover {
272
294
  opacity: 1;
273
295
  }
274
296
  </style>
@@ -29,6 +29,13 @@ type Own = {
29
29
  icon?: IconName;
30
30
  removable?: boolean;
31
31
  onremove?: (e: MouseEvent) => void;
32
+ /** Trailing action glyph; renders the action button even without `removable`. */
33
+ actionIcon?: IconName;
34
+ /** Accessible name for the action button (defaults to "Remove"). */
35
+ actionLabel?: string;
36
+ onaction?: (e: MouseEvent) => void;
37
+ /** Muted, struck-through look for an entry pending removal. */
38
+ removed?: boolean;
32
39
  class?: string;
33
40
  children?: Snippet;
34
41
  };
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { formatCount, hasCount } from '../../count';
2
3
  import type { ControlSize } from '../../size';
3
4
  import { canonicalTone, type Tone } from '../../tone';
4
5
  import type { Snippet } from 'svelte';
@@ -56,6 +57,11 @@
56
57
  // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
57
58
  // disabled-equivalent while true (so a double-submit can't fire).
58
59
  loading?: boolean;
60
+ // Corner count indicator (unread, selected, pending). Hidden at 0/undefined;
61
+ // shows `${countMax}+` past the cap while the accessible name carries the
62
+ // exact number. Tint via `--btn-count-bg` / `--btn-count-fg`.
63
+ count?: number;
64
+ countMax?: number;
59
65
  class?: string;
60
66
  children?: Snippet;
61
67
  };
@@ -80,6 +86,8 @@
80
86
  collapseLabel = 'never',
81
87
  hitArea = 'auto',
82
88
  loading = false,
89
+ count,
90
+ countMax = 99,
83
91
  type = 'button',
84
92
  disabled = false,
85
93
  title,
@@ -100,13 +108,21 @@
100
108
  : { type, disabled: inactive }
101
109
  );
102
110
 
111
+ const counted = $derived(hasCount(count));
112
+ const countText = $derived(hasCount(count) ? formatCount(count, countMax) : '');
113
+ // aria-label replaces the content as the accessible name, so the count is
114
+ // folded into it; without one the sr-only span below is read after the label.
115
+ const withCount = (name: string) => (counted ? `${name}, ${count}` : name);
116
+
103
117
  let el = $state<HTMLElement | null>(null);
104
118
  const explicitName = $derived(rest['aria-label'] ?? rest['aria-labelledby']);
119
+ const ariaLabel = $derived(rest['aria-label'] ? withCount(rest['aria-label']) : undefined);
105
120
  $effect(() => {
106
121
  if (!el || collapseLabel === 'never' || explicitName) return;
107
- const text = Array.from(el.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
122
+ const labels = Array.from(el.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
108
123
  .filter(Boolean)
109
124
  .join(' ');
125
+ const text = labels && withCount(labels);
110
126
  if (text) el.setAttribute('aria-label', text);
111
127
  else el.removeAttribute('aria-label');
112
128
  });
@@ -118,6 +134,7 @@
118
134
  data-tsu="Button"
119
135
  {...rest}
120
136
  {...elementAttrs}
137
+ aria-label={ariaLabel}
121
138
  aria-busy={loading || undefined}
122
139
  {title}
123
140
  class="btn {klass}"
@@ -148,10 +165,15 @@
148
165
  class:btn-icon-inline={iconInline}
149
166
  class:hover-danger={hoverDanger}
150
167
  class:loading
168
+ class:btn-counted={counted}
151
169
  onclick={onclick}
152
170
  >
153
171
  {#if loading}<span class="btn-spinner" aria-hidden="true"></span>{/if}
154
172
  {@render children?.()}
173
+ {#if counted}
174
+ <span class="btn-count" aria-hidden="true">{countText}</span>
175
+ <span class="btn-count-sr">, {count}</span>
176
+ {/if}
155
177
  </svelte:element>
156
178
 
157
179
  <style>
@@ -535,4 +557,53 @@
535
557
  transform: rotate(360deg);
536
558
  }
537
559
  }
560
+
561
+ /* Corner count: anchored to the top-right corner and pulled half outside it,
562
+ so the button keeps its own box and any size/variant/box carries one. */
563
+ .btn-counted {
564
+ position: relative;
565
+ }
566
+ .btn-count {
567
+ position: absolute;
568
+ top: 0;
569
+ right: 0;
570
+ transform: translate(45%, -45%);
571
+ display: inline-flex;
572
+ align-items: center;
573
+ justify-content: center;
574
+ box-sizing: border-box;
575
+ min-width: var(--btn-count-size, 1.125rem);
576
+ height: var(--btn-count-size, 1.125rem);
577
+ padding: 0 var(--sp-1);
578
+ border-radius: var(--r-pill);
579
+ background: var(--btn-count-bg, var(--accent));
580
+ color: var(--btn-count-fg, var(--text-on-accent));
581
+ font-size: var(--fs-xs);
582
+ font-weight: var(--fw-semibold);
583
+ font-variant-numeric: tabular-nums;
584
+ line-height: 1;
585
+ white-space: nowrap;
586
+ pointer-events: none;
587
+ }
588
+ .btn-sm .btn-count,
589
+ .btn-link .btn-count,
590
+ .btn-icon-inline .btn-count {
591
+ --btn-count-size: 1rem;
592
+ font-size: calc(var(--fs-xs) * 0.85);
593
+ }
594
+ /* The accent fill would swallow an accent pill: invert on primary. */
595
+ .btn-primary .btn-count {
596
+ background: var(--btn-count-bg, var(--text-on-accent));
597
+ color: var(--btn-count-fg, var(--accent));
598
+ }
599
+ .btn-count-sr {
600
+ position: absolute;
601
+ width: 1px;
602
+ height: 1px;
603
+ margin: -1px;
604
+ padding: 0;
605
+ overflow: hidden;
606
+ clip-path: inset(50%);
607
+ white-space: nowrap;
608
+ }
538
609
  </style>
@@ -24,6 +24,8 @@ type ButtonProps = HTMLButtonAttributes & {
24
24
  collapseLabel?: 'never' | 'mobile' | 'container';
25
25
  hitArea?: 'auto' | 'compact';
26
26
  loading?: boolean;
27
+ count?: number;
28
+ countMax?: number;
27
29
  class?: string;
28
30
  children?: Snippet;
29
31
  };