@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.
- package/README.md +125 -10
- package/dist/avatar.d.ts +6 -0
- package/dist/avatar.js +17 -0
- package/dist/components/atoms/Avatar.svelte +140 -0
- package/dist/components/atoms/Avatar.svelte.d.ts +25 -0
- package/dist/components/atoms/Badge.svelte +30 -8
- package/dist/components/atoms/Badge.svelte.d.ts +7 -0
- package/dist/components/atoms/Button.svelte +72 -1
- package/dist/components/atoms/Button.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +22 -4
- package/dist/components/atoms/Swatch.svelte +114 -0
- package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
- package/dist/components/atoms/Textarea.svelte +33 -6
- package/dist/components/molecules/Accordion.svelte +80 -62
- package/dist/components/molecules/Accordion.svelte.d.ts +15 -2
- package/dist/components/molecules/Combobox.svelte +299 -0
- package/dist/components/molecules/Combobox.svelte.d.ts +108 -0
- package/dist/components/molecules/Composer.svelte +32 -29
- package/dist/components/molecules/Composer.svelte.d.ts +2 -0
- package/dist/components/molecules/Disclosure.svelte +155 -0
- package/dist/components/molecules/Disclosure.svelte.d.ts +26 -0
- package/dist/components/molecules/IconButton.svelte +8 -0
- package/dist/components/molecules/IconButton.svelte.d.ts +4 -0
- package/dist/components/molecules/InputGroup.svelte +159 -0
- package/dist/components/molecules/InputGroup.svelte.d.ts +23 -0
- package/dist/components/molecules/Menu.svelte +26 -4
- package/dist/components/molecules/Menu.svelte.d.ts +9 -1
- package/dist/components/molecules/OptionButton.svelte +42 -1
- package/dist/components/molecules/OptionButton.svelte.d.ts +8 -1
- package/dist/components/molecules/Popover.svelte +49 -1
- package/dist/components/molecules/Popover.svelte.d.ts +4 -0
- package/dist/components/molecules/RadioGroup.svelte +59 -7
- package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
- package/dist/components/molecules/SplitButton.svelte +181 -0
- package/dist/components/molecules/SplitButton.svelte.d.ts +41 -0
- package/dist/components/molecules/Tabs.svelte +155 -27
- package/dist/components/molecules/Tabs.svelte.d.ts +16 -1
- package/dist/components/molecules/combobox-keyboard.d.ts +100 -0
- package/dist/components/molecules/combobox-keyboard.js +170 -0
- package/dist/components/molecules/menu-item.d.ts +11 -0
- package/dist/components/molecules/menu-item.js +9 -0
- package/dist/count.d.ts +2 -0
- package/dist/count.js +7 -0
- package/dist/floating.d.ts +4 -0
- package/dist/floating.js +4 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/input-group-context.d.ts +10 -0
- package/dist/input-group-context.js +8 -0
- 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
|
-
| `
|
|
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),
|
|
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"
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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"` =
|
package/dist/avatar.d.ts
ADDED
|
@@ -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
|
|
155
|
+
{#if hasAction}
|
|
141
156
|
<button
|
|
142
157
|
type="button"
|
|
143
|
-
class="
|
|
144
|
-
aria-label=
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
|
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>
|