@dorsk/tsumikit 0.56.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 (51) 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 +30 -5
  11. package/dist/components/atoms/Select.svelte +11 -1
  12. package/dist/components/atoms/Swatch.svelte +114 -0
  13. package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
  14. package/dist/components/atoms/Textarea.svelte +41 -7
  15. package/dist/components/molecules/Accordion.svelte +80 -62
  16. package/dist/components/molecules/Accordion.svelte.d.ts +15 -2
  17. package/dist/components/molecules/Combobox.svelte +299 -0
  18. package/dist/components/molecules/Combobox.svelte.d.ts +108 -0
  19. package/dist/components/molecules/Composer.svelte +32 -29
  20. package/dist/components/molecules/Composer.svelte.d.ts +2 -0
  21. package/dist/components/molecules/Disclosure.svelte +155 -0
  22. package/dist/components/molecules/Disclosure.svelte.d.ts +26 -0
  23. package/dist/components/molecules/IconButton.svelte +8 -0
  24. package/dist/components/molecules/IconButton.svelte.d.ts +4 -0
  25. package/dist/components/molecules/InputGroup.svelte +159 -0
  26. package/dist/components/molecules/InputGroup.svelte.d.ts +23 -0
  27. package/dist/components/molecules/Menu.svelte +26 -4
  28. package/dist/components/molecules/Menu.svelte.d.ts +9 -1
  29. package/dist/components/molecules/OptionButton.svelte +42 -1
  30. package/dist/components/molecules/OptionButton.svelte.d.ts +8 -1
  31. package/dist/components/molecules/Popover.svelte +49 -1
  32. package/dist/components/molecules/Popover.svelte.d.ts +4 -0
  33. package/dist/components/molecules/RadioGroup.svelte +59 -7
  34. package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
  35. package/dist/components/molecules/SplitButton.svelte +181 -0
  36. package/dist/components/molecules/SplitButton.svelte.d.ts +41 -0
  37. package/dist/components/molecules/Tabs.svelte +155 -27
  38. package/dist/components/molecules/Tabs.svelte.d.ts +16 -1
  39. package/dist/components/molecules/combobox-keyboard.d.ts +100 -0
  40. package/dist/components/molecules/combobox-keyboard.js +170 -0
  41. package/dist/components/molecules/menu-item.d.ts +11 -0
  42. package/dist/components/molecules/menu-item.js +9 -0
  43. package/dist/count.d.ts +2 -0
  44. package/dist/count.js +7 -0
  45. package/dist/floating.d.ts +4 -0
  46. package/dist/floating.js +4 -1
  47. package/dist/index.d.ts +9 -0
  48. package/dist/index.js +9 -0
  49. package/dist/input-group-context.d.ts +10 -0
  50. package/dist/input-group-context.js +8 -0
  51. package/package.json +1 -1
@@ -0,0 +1,26 @@
1
+ export type DisclosureChevron = 'start' | 'end' | false;
2
+ export interface DisclosureHeaderContext {
3
+ open: boolean;
4
+ }
5
+ import type { Snippet } from 'svelte';
6
+ import type { HTMLAttributes } from 'svelte/elements';
7
+ type Own = {
8
+ open?: boolean;
9
+ /** Rich header content; receives the live open state. */
10
+ header: Snippet<[DisclosureHeaderContext]>;
11
+ children: Snippet;
12
+ /** Chevron placement; `false` hides it. */
13
+ chevron?: DisclosureChevron;
14
+ /** Base for the button/panel ids the ARIA wiring uses. */
15
+ id?: string;
16
+ onchange?: (open: boolean) => void;
17
+ disabled?: boolean;
18
+ class?: string;
19
+ style?: string;
20
+ buttonClass?: string;
21
+ panelClass?: string;
22
+ };
23
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
24
+ declare const Disclosure: import("svelte").Component<$$ComponentProps, {}, "open">;
25
+ type Disclosure = ReturnType<typeof Disclosure>;
26
+ export default Disclosure;
@@ -56,6 +56,10 @@
56
56
  spin?: boolean;
57
57
  /** Forwarded to Button: spinner, blocks clicks, aria-busy. */
58
58
  loading?: boolean;
59
+ /** Corner count indicator, forwarded to Button; the count joins `label`
60
+ * in the accessible name. */
61
+ count?: number;
62
+ countMax?: number;
59
63
  class?: string;
60
64
  };
61
65
 
@@ -80,6 +84,8 @@
80
84
  showLabel = false,
81
85
  spin = false,
82
86
  loading = false,
87
+ count,
88
+ countMax,
83
89
  disabled = false,
84
90
  onclick,
85
91
  class: klass = '',
@@ -106,6 +112,8 @@
106
112
  {hitArea}
107
113
  {disabled}
108
114
  {loading}
115
+ {count}
116
+ {countMax}
109
117
  {title}
110
118
  {onclick}
111
119
  icon={!inline && !chip && !showLabel}
@@ -33,6 +33,10 @@ type IconButtonProps = HTMLButtonAttributes & {
33
33
  spin?: boolean;
34
34
  /** Forwarded to Button: spinner, blocks clicks, aria-busy. */
35
35
  loading?: boolean;
36
+ /** Corner count indicator, forwarded to Button; the count joins `label`
37
+ * in the accessible name. */
38
+ count?: number;
39
+ countMax?: number;
36
40
  class?: string;
37
41
  };
38
42
  declare const IconButton: import("svelte").Component<IconButtonProps, {}, "">;
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { Attachment } from 'svelte/attachments';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ import { setInputGroupContext } from '../../input-group-context';
6
+ import type { ControlSize } from '../../size';
7
+
8
+ type Own = {
9
+ /** Overlaid at the inline start of the field, e.g. an icon-only FileButton. */
10
+ leading?: Snippet;
11
+ /** The field: an Input or Textarea, optionally wrapped by the consumer. */
12
+ children: Snippet;
13
+ /** Overlaid at the inline end of the field, e.g. a Button or SplitButton. */
14
+ trailing?: Snippet;
15
+ /** `end` pins the adornments to the bottom edge as a textarea grows;
16
+ * `center` is for a single-line Input. */
17
+ align?: 'end' | 'center';
18
+ size?: ControlSize;
19
+ disabled?: boolean;
20
+ error?: boolean;
21
+ class?: string;
22
+ style?: string;
23
+ };
24
+
25
+ let {
26
+ leading,
27
+ children,
28
+ trailing,
29
+ align = 'end',
30
+ size = 'md',
31
+ disabled = false,
32
+ error = false,
33
+ class: klass = '',
34
+ style: styleProp = '',
35
+ ...rest
36
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
37
+
38
+ setInputGroupContext({
39
+ get size() {
40
+ return size;
41
+ },
42
+ get disabled() {
43
+ return disabled;
44
+ },
45
+ get invalid() {
46
+ return error;
47
+ }
48
+ });
49
+
50
+ let leadingW = $state(0);
51
+ let trailingW = $state(0);
52
+
53
+ function measure(set: (width: number) => void): Attachment {
54
+ return (node) => {
55
+ if (typeof ResizeObserver === 'undefined') return;
56
+ const ro = new ResizeObserver((entries) => {
57
+ for (const entry of entries)
58
+ set(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
59
+ });
60
+ ro.observe(node);
61
+ return () => {
62
+ ro.disconnect();
63
+ set(0);
64
+ };
65
+ };
66
+ }
67
+ const measureLeading = measure((w) => (leadingW = w));
68
+ const measureTrailing = measure((w) => (trailingW = w));
69
+ </script>
70
+
71
+ <div
72
+ {...rest}
73
+ data-tsu="InputGroup"
74
+ class="ig {klass}"
75
+ class:ig-center={align === 'center'}
76
+ class:ig-sm={size === 'sm'}
77
+ class:ig-lg={size === 'lg'}
78
+ class:disabled
79
+ style:--ig-leading-w="{leadingW}px"
80
+ style:--ig-trailing-w="{trailingW}px"
81
+ style={styleProp}
82
+ >
83
+ {#if leading}
84
+ <div class="ig-adorn ig-leading" {@attach measureLeading}>{@render leading()}</div>
85
+ {/if}
86
+ <div class="ig-field">{@render children()}</div>
87
+ {#if trailing}
88
+ <div class="ig-adorn ig-trailing" {@attach measureTrailing}>{@render trailing()}</div>
89
+ {/if}
90
+ </div>
91
+
92
+ <style>
93
+ /* The field's own border box spans the whole group: Gecko zooms to the
94
+ focused element's rect on mobile, so the adornments must overlay it, never
95
+ sit beside a narrower field. The field reads --ig-leading-w /
96
+ --ig-trailing-w to keep its text clear of them. */
97
+ .ig {
98
+ --ig-h: var(--control-height-default);
99
+ --ig-pad-x: var(--sp-3);
100
+ --ig-gap: var(--sp-2);
101
+ --ig-inset: 2px;
102
+ --ig-radius: var(--r-md);
103
+ position: relative;
104
+ display: flex;
105
+ width: 100%;
106
+ }
107
+ .ig-sm {
108
+ --ig-h: var(--control-height-compact);
109
+ --ig-pad-x: var(--sp-2);
110
+ --ig-gap: var(--sp-1);
111
+ }
112
+ .ig-lg {
113
+ --ig-h: var(--control-height-large);
114
+ --ig-pad-x: var(--sp-4);
115
+ }
116
+ .ig-field {
117
+ width: 100%;
118
+ min-width: 0;
119
+ }
120
+ .ig-adorn {
121
+ position: absolute;
122
+ bottom: 0;
123
+ z-index: 1;
124
+ display: flex;
125
+ align-items: center;
126
+ gap: var(--ig-inset);
127
+ height: var(--ig-h);
128
+ padding-inline: var(--ig-inset);
129
+ pointer-events: none;
130
+ }
131
+ .ig-adorn > :global(*) {
132
+ pointer-events: auto;
133
+ }
134
+ .ig-leading {
135
+ inset-inline-start: 0;
136
+ }
137
+ .ig-trailing {
138
+ inset-inline-end: 0;
139
+ }
140
+ .ig-center .ig-adorn {
141
+ top: 0;
142
+ height: auto;
143
+ }
144
+ .ig:has(:focus-visible)::after {
145
+ content: '';
146
+ position: absolute;
147
+ inset: 0;
148
+ border-radius: var(--ig-radius);
149
+ outline: var(--focus-ring);
150
+ outline-offset: var(--focus-ring-offset);
151
+ pointer-events: none;
152
+ }
153
+ .ig.disabled {
154
+ opacity: 0.45;
155
+ }
156
+ .ig.disabled .ig-adorn > :global(:disabled) {
157
+ opacity: 1;
158
+ }
159
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { ControlSize } from '../../size';
4
+ type Own = {
5
+ /** Overlaid at the inline start of the field, e.g. an icon-only FileButton. */
6
+ leading?: Snippet;
7
+ /** The field: an Input or Textarea, optionally wrapped by the consumer. */
8
+ children: Snippet;
9
+ /** Overlaid at the inline end of the field, e.g. a Button or SplitButton. */
10
+ trailing?: Snippet;
11
+ /** `end` pins the adornments to the bottom edge as a textarea grows;
12
+ * `center` is for a single-line Input. */
13
+ align?: 'end' | 'center';
14
+ size?: ControlSize;
15
+ disabled?: boolean;
16
+ error?: boolean;
17
+ class?: string;
18
+ style?: string;
19
+ };
20
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
21
+ declare const InputGroup: import("svelte").Component<$$ComponentProps, {}, "">;
22
+ type InputGroup = ReturnType<typeof InputGroup>;
23
+ export default InputGroup;
@@ -8,8 +8,14 @@
8
8
  /** Free-form trailing pill ("admin", "pro", "beta"…), rendered as a Badge after the label. */
9
9
  tag?: string;
10
10
  tagTone?: import('svelte').ComponentProps<typeof import('../atoms/Badge.svelte').default>['tone'];
11
- /** Two-state item: rendered as `menuitemcheckbox` with a check glyph when on. */
11
+ /**
12
+ * Two-state item: rendered as `menuitemcheckbox` with `aria-checked`. Its icon stays in
13
+ * the leading column and the check glyph moves to the trailing edge; without an icon
14
+ * the check glyph takes the leading column.
15
+ */
12
16
  pressed?: boolean;
17
+ /** Selecting this item leaves the menu open (overrides the menu's `closeOnSelect`). */
18
+ keepOpen?: boolean;
13
19
  /** Custom row content (a rename Input, a slider…) replacing icon/label/tag. */
14
20
  content?: import('svelte').Snippet<[MenuItem]>;
15
21
  /**
@@ -24,7 +30,8 @@
24
30
  <script lang="ts">
25
31
  // Dropdown menu: a Popover whose panel carries the WAI-ARIA `menu` role. Items are
26
32
  // `menuitem`s navigable with ↑/↓/Home/End, activated with Enter/Space (and
27
- // click). Selecting an item runs its action and closes the menu. Focus moves
33
+ // click). Selecting an item runs its action and closes the menu unless the menu
34
+ // sets `closeOnSelect={false}` or the item sets `keepOpen`. Focus moves
28
35
  // to the first item when the menu opens. Escape / click-outside close it
29
36
  // (inherited from the native popover). An item's `tag` renders as a soft
30
37
  // Badge pill after the label; the `tag` snippet replaces that pill.
@@ -32,6 +39,7 @@
32
39
  import Popover from './Popover.svelte';
33
40
  import Icon from '../atoms/Icon.svelte';
34
41
  import Badge from '../atoms/Badge.svelte';
42
+ import { menuItemCloses, menuItemGlyphs } from './menu-item';
35
43
 
36
44
  type PopoverProps = ComponentProps<typeof Popover>;
37
45
  type TriggerChrome = Pick<
@@ -73,6 +81,7 @@
73
81
  onopen,
74
82
  onclose,
75
83
  open = $bindable(false),
84
+ closeOnSelect = true,
76
85
  class: klass = '',
77
86
  style: styleProp = '',
78
87
  panelClass = '',
@@ -85,6 +94,8 @@
85
94
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
86
95
  /** Bindable open state; set it to open/close programmatically. */
87
96
  open?: boolean;
97
+ /** Close the menu after an item is selected; a per-item `keepOpen` wins over it. */
98
+ closeOnSelect?: boolean;
88
99
  class?: string;
89
100
  style?: string;
90
101
  panelClass?: string;
@@ -130,7 +141,7 @@
130
141
  }
131
142
  function select(item: MenuItem, close: () => void) {
132
143
  if (item.disabled) return;
133
- close();
144
+ if (menuItemCloses(item, closeOnSelect)) close();
134
145
  item.onselect();
135
146
  }
136
147
  </script>
@@ -184,7 +195,8 @@
184
195
  {#if item.content}
185
196
  {@render item.content(item)}
186
197
  {:else}
187
- {#if item.pressed !== undefined}<span class="menu-check"><Icon name="check" /></span>{:else if item.icon}<Icon name={item.icon} />{/if}
198
+ {@const glyphs = menuItemGlyphs(item)}
199
+ {#if glyphs.leading === 'check'}<span class="menu-check"><Icon name="check" /></span>{:else if glyphs.leading === 'icon' && item.icon}<Icon name={item.icon} />{/if}
188
200
  <span>{item.label}</span>
189
201
  {#if item.tag !== undefined}
190
202
  {#if tag}
@@ -195,6 +207,7 @@
195
207
  </span>
196
208
  {/if}
197
209
  {/if}
210
+ {#if glyphs.trailingCheck}<span class="menu-check menu-check-trailing"><Icon name="check" /></span>{/if}
198
211
  {/if}
199
212
  </button>
200
213
  {/each}
@@ -239,12 +252,21 @@
239
252
  visibility: visible;
240
253
  color: var(--accent);
241
254
  }
255
+ .menu-check-trailing {
256
+ flex: none;
257
+ margin-inline-start: auto;
258
+ padding-inline-start: var(--sp-3);
259
+ }
242
260
  .menu-tag {
243
261
  display: inline-flex;
244
262
  flex: none;
245
263
  margin-inline-start: auto;
246
264
  padding-inline-start: var(--sp-3);
247
265
  }
266
+ .menu-tag + .menu-check-trailing {
267
+ margin-inline-start: 0;
268
+ padding-inline-start: 0;
269
+ }
248
270
  .menu-item:disabled {
249
271
  opacity: 0.45;
250
272
  cursor: not-allowed;
@@ -7,8 +7,14 @@ export interface MenuItem {
7
7
  /** Free-form trailing pill ("admin", "pro", "beta"…), rendered as a Badge after the label. */
8
8
  tag?: string;
9
9
  tagTone?: import('svelte').ComponentProps<typeof import('../atoms/Badge.svelte').default>['tone'];
10
- /** Two-state item: rendered as `menuitemcheckbox` with a check glyph when on. */
10
+ /**
11
+ * Two-state item: rendered as `menuitemcheckbox` with `aria-checked`. Its icon stays in
12
+ * the leading column and the check glyph moves to the trailing edge; without an icon
13
+ * the check glyph takes the leading column.
14
+ */
11
15
  pressed?: boolean;
16
+ /** Selecting this item leaves the menu open (overrides the menu's `closeOnSelect`). */
17
+ keepOpen?: boolean;
12
18
  /** Custom row content (a rename Input, a slider…) replacing icon/label/tag. */
13
19
  content?: import('svelte').Snippet<[MenuItem]>;
14
20
  /**
@@ -31,6 +37,8 @@ type $$ComponentProps = {
31
37
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
32
38
  /** Bindable open state; set it to open/close programmatically. */
33
39
  open?: boolean;
40
+ /** Close the menu after an item is selected; a per-item `keepOpen` wins over it. */
41
+ closeOnSelect?: boolean;
34
42
  class?: string;
35
43
  style?: string;
36
44
  panelClass?: string;
@@ -17,12 +17,25 @@
17
17
  block = false,
18
18
  type = 'button',
19
19
  disabled = false,
20
+ value,
21
+ description,
22
+ onfocuschange,
23
+ onfocus,
24
+ onpointerenter,
25
+ 'aria-describedby': describedBy,
20
26
  class: klass = '',
21
27
  children,
22
28
  ...rest
23
- }: HTMLButtonAttributes & {
29
+ }: Omit<HTMLButtonAttributes, 'value'> & {
24
30
  selected?: boolean;
25
31
  row?: boolean;
32
+ /** Identifies this option in `onfocuschange`. */
33
+ value?: string;
34
+ /** Secondary text under the content, announced via `aria-describedby`. */
35
+ description?: string;
36
+ /** Fires with `value` when the button gains keyboard focus or pointer
37
+ * hover, so a consumer can drive a preview pane across a set of cards. */
38
+ onfocuschange?: (value: string) => void;
26
39
  /** Horizontal content alignment. `start` gives a left-aligned list-row
27
40
  * variant (pair with `block` for a full-width menu item). */
28
41
  align?: 'center' | 'start';
@@ -30,6 +43,14 @@
30
43
  block?: boolean;
31
44
  children?: Snippet;
32
45
  } = $props();
46
+
47
+ const uid = $props.id();
48
+ const descId = $derived(description ? `${uid}-desc` : undefined);
49
+ const ariaDescribedBy = $derived([describedBy, descId].filter(Boolean).join(' ') || undefined);
50
+
51
+ function activate() {
52
+ if (value !== undefined) onfocuschange?.(value);
53
+ }
33
54
  </script>
34
55
 
35
56
  <button
@@ -37,6 +58,16 @@
37
58
  {...rest}
38
59
  {type}
39
60
  {disabled}
61
+ {value}
62
+ aria-describedby={ariaDescribedBy}
63
+ onfocus={(e) => {
64
+ onfocus?.(e);
65
+ activate();
66
+ }}
67
+ onpointerenter={(e) => {
68
+ onpointerenter?.(e);
69
+ activate();
70
+ }}
40
71
  class="opt {klass}"
41
72
  class:row
42
73
  class:align-start={align === 'start'}
@@ -45,6 +76,7 @@
45
76
  aria-pressed={selected}
46
77
  >
47
78
  {@render children?.()}
79
+ {#if description}<span class="description" id={descId}>{description}</span>{/if}
48
80
  </button>
49
81
 
50
82
  <style>
@@ -83,6 +115,15 @@
83
115
  .opt.block {
84
116
  width: 100%;
85
117
  }
118
+ .description {
119
+ font-size: var(--fs-xs);
120
+ color: var(--faint-color, var(--text-muted));
121
+ font-weight: var(--fw-normal);
122
+ }
123
+ .opt.row .description {
124
+ flex: 1 1 auto;
125
+ min-width: 0;
126
+ }
86
127
  /* Left-aligned list-row variant: content packs to the start on both axes. */
87
128
  .opt.align-start {
88
129
  align-items: flex-start;
@@ -1,8 +1,15 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLButtonAttributes } from 'svelte/elements';
3
- type $$ComponentProps = HTMLButtonAttributes & {
3
+ type $$ComponentProps = Omit<HTMLButtonAttributes, 'value'> & {
4
4
  selected?: boolean;
5
5
  row?: boolean;
6
+ /** Identifies this option in `onfocuschange`. */
7
+ value?: string;
8
+ /** Secondary text under the content, announced via `aria-describedby`. */
9
+ description?: string;
10
+ /** Fires with `value` when the button gains keyboard focus or pointer
11
+ * hover, so a consumer can drive a preview pane across a set of cards. */
12
+ onfocuschange?: (value: string) => void;
6
13
  /** Horizontal content alignment. `start` gives a left-aligned list-row
7
14
  * variant (pair with `block` for a full-width menu item). */
8
15
  align?: 'center' | 'start';
@@ -11,6 +11,7 @@
11
11
  // are broadly supported today.)
12
12
  import { tick, type Snippet } from 'svelte';
13
13
  import type { HTMLAttributes } from 'svelte/elements';
14
+ import { formatCount, hasCount } from '../../count';
14
15
  import { place } from '../../floating';
15
16
  import { HOVER_CLOSE_GRACE, HOVER_OPEN_DELAY, createHoverIntent, opensOnHover } from './popover-hover.js';
16
17
 
@@ -57,6 +58,10 @@
57
58
  block?: boolean;
58
59
  /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
59
60
  hitArea?: 'auto' | 'compact';
61
+ /** Corner count indicator on the trigger, as on `Button`: hidden at
62
+ * 0/undefined, capped at `countMax+`, the exact number joins `label`. */
63
+ count?: number;
64
+ countMax?: number;
60
65
  disabled?: boolean;
61
66
  /** `hover` adds pointer-driven opening on fine pointers; click, Enter and
62
67
  * touch keep working so keyboard and touch users reach the same panel. */
@@ -96,6 +101,8 @@
96
101
  control = false,
97
102
  block = false,
98
103
  hitArea = 'auto',
104
+ count,
105
+ countMax = 99,
99
106
  disabled = false,
100
107
  openOn = 'click',
101
108
  hoverDelay = HOVER_OPEN_DELAY,
@@ -115,6 +122,9 @@
115
122
  const canonicalChrome = $derived(
116
123
  variant !== undefined || tone !== 'none' || size !== undefined || control || block
117
124
  );
125
+ const counted = $derived(hasCount(count));
126
+ const countText = $derived(hasCount(count) ? formatCount(count, countMax) : '');
127
+ const triggerName = $derived(counted ? `${label}, ${count}` : label);
118
128
 
119
129
  const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
120
130
  let triggerEl = $state<HTMLElement | null>(null);
@@ -246,7 +256,8 @@
246
256
  class:trigger-tone-warn={tone === 'warn'}
247
257
  class:trigger-tone-danger={tone === 'danger'}
248
258
  class:is-disabled={disabled && as === 'a'}
249
- aria-label={label}
259
+ class:counted
260
+ aria-label={triggerName}
250
261
  aria-haspopup={haspopup}
251
262
  aria-expanded={open}
252
263
  {...triggerAttrs}
@@ -256,6 +267,7 @@
256
267
  onkeydown={onTriggerKeydown}
257
268
  >
258
269
  {@render trigger()}
270
+ {#if counted}<span class="pop-count" aria-hidden="true">{countText}</span>{/if}
259
271
  </svelte:element>
260
272
 
261
273
  <div
@@ -444,6 +456,42 @@
444
456
  cursor: pointer;
445
457
  text-decoration: none;
446
458
  }
459
+ /* Corner count, the same pill Button paints: anchored to the top-right
460
+ corner and pulled half outside it so the trigger keeps its own box. */
461
+ .pop-trigger.counted {
462
+ position: relative;
463
+ }
464
+ .pop-count {
465
+ position: absolute;
466
+ top: 0;
467
+ right: 0;
468
+ transform: translate(45%, -45%);
469
+ display: inline-flex;
470
+ align-items: center;
471
+ justify-content: center;
472
+ box-sizing: border-box;
473
+ min-width: var(--pop-count-size, 1.125rem);
474
+ height: var(--pop-count-size, 1.125rem);
475
+ padding: 0 var(--sp-1);
476
+ border-radius: var(--r-pill);
477
+ background: var(--pop-count-bg, var(--accent));
478
+ color: var(--pop-count-fg, var(--text-on-accent));
479
+ font-size: var(--fs-xs);
480
+ font-weight: var(--fw-semibold);
481
+ font-variant-numeric: tabular-nums;
482
+ line-height: 1;
483
+ white-space: nowrap;
484
+ pointer-events: none;
485
+ }
486
+ .trigger-sm .pop-count,
487
+ .bare .pop-count {
488
+ --pop-count-size: 1rem;
489
+ font-size: calc(var(--fs-xs) * 0.85);
490
+ }
491
+ .trigger-primary .pop-count {
492
+ background: var(--pop-count-bg, var(--text-on-accent));
493
+ color: var(--pop-count-fg, var(--accent));
494
+ }
447
495
  /* `bare`: strip the chrome down to a plain button the consumer styles. */
448
496
  :where(.pop-trigger.bare) {
449
497
  display: inline;
@@ -44,6 +44,10 @@ type Own = {
44
44
  block?: boolean;
45
45
  /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
46
46
  hitArea?: 'auto' | 'compact';
47
+ /** Corner count indicator on the trigger, as on `Button`: hidden at
48
+ * 0/undefined, capped at `countMax+`, the exact number joins `label`. */
49
+ count?: number;
50
+ countMax?: number;
47
51
  disabled?: boolean;
48
52
  /** `hover` adds pointer-driven opening on fine pointers; click, Enter and
49
53
  * touch keep working so keyboard and touch users reach the same panel. */