@dorsk/tsumikit 0.57.0 → 0.58.1

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 +28 -19
  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 +61 -2
  31. package/dist/components/molecules/Popover.svelte.d.ts +7 -1
  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 +175 -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
@@ -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;
@@ -92,16 +103,6 @@
92
103
 
93
104
  let listEl = $state<HTMLDivElement | null>(null);
94
105
 
95
- $effect(() => {
96
- const pop = listEl?.closest<HTMLElement>('[popover]');
97
- if (!pop) return;
98
- const isOpen = pop.matches(':popover-open');
99
- try {
100
- if (open && !isOpen) pop.showPopover();
101
- else if (!open && isOpen) pop.hidePopover();
102
- } catch {}
103
- });
104
-
105
106
  function buttons(): HTMLButtonElement[] {
106
107
  return listEl
107
108
  ? Array.from(listEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled), [role="menuitemcheckbox"]:not(:disabled)'))
@@ -130,7 +131,7 @@
130
131
  }
131
132
  function select(item: MenuItem, close: () => void) {
132
133
  if (item.disabled) return;
133
- close();
134
+ if (menuItemCloses(item, closeOnSelect)) close();
134
135
  item.onselect();
135
136
  }
136
137
  </script>
@@ -156,12 +157,9 @@
156
157
  {gap}
157
158
  role="menu"
158
159
  haspopup="menu"
159
- onclose={() => {
160
- open = false;
161
- onclose?.();
162
- }}
160
+ bind:open
161
+ {onclose}
163
162
  onopen={() => {
164
- open = true;
165
163
  queueMicrotask(() => focusAt(0));
166
164
  onopen?.();
167
165
  }}
@@ -184,7 +182,8 @@
184
182
  {#if item.content}
185
183
  {@render item.content(item)}
186
184
  {:else}
187
- {#if item.pressed !== undefined}<span class="menu-check"><Icon name="check" /></span>{:else if item.icon}<Icon name={item.icon} />{/if}
185
+ {@const glyphs = menuItemGlyphs(item)}
186
+ {#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
187
  <span>{item.label}</span>
189
188
  {#if item.tag !== undefined}
190
189
  {#if tag}
@@ -195,6 +194,7 @@
195
194
  </span>
196
195
  {/if}
197
196
  {/if}
197
+ {#if glyphs.trailingCheck}<span class="menu-check menu-check-trailing"><Icon name="check" /></span>{/if}
198
198
  {/if}
199
199
  </button>
200
200
  {/each}
@@ -239,12 +239,21 @@
239
239
  visibility: visible;
240
240
  color: var(--accent);
241
241
  }
242
+ .menu-check-trailing {
243
+ flex: none;
244
+ margin-inline-start: auto;
245
+ padding-inline-start: var(--sp-3);
246
+ }
242
247
  .menu-tag {
243
248
  display: inline-flex;
244
249
  flex: none;
245
250
  margin-inline-start: auto;
246
251
  padding-inline-start: var(--sp-3);
247
252
  }
253
+ .menu-tag + .menu-check-trailing {
254
+ margin-inline-start: 0;
255
+ padding-inline-start: 0;
256
+ }
248
257
  .menu-item:disabled {
249
258
  opacity: 0.45;
250
259
  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. */
@@ -71,6 +76,8 @@
71
76
  role?: PanelRole;
72
77
  /** `aria-haspopup` announced on the trigger. */
73
78
  haspopup?: HasPopup;
79
+ /** Bindable open state; setting it opens or closes the panel, even before its first open. */
80
+ open?: boolean;
74
81
  onopen?: () => void;
75
82
  onclose?: () => void;
76
83
  class?: string;
@@ -96,6 +103,8 @@
96
103
  control = false,
97
104
  block = false,
98
105
  hitArea = 'auto',
106
+ count,
107
+ countMax = 99,
99
108
  disabled = false,
100
109
  openOn = 'click',
101
110
  hoverDelay = HOVER_OPEN_DELAY,
@@ -103,6 +112,7 @@
103
112
  href,
104
113
  role = 'dialog',
105
114
  haspopup = 'dialog',
115
+ open = $bindable(false),
106
116
  onopen,
107
117
  onclose,
108
118
  class: klass = '',
@@ -115,6 +125,9 @@
115
125
  const canonicalChrome = $derived(
116
126
  variant !== undefined || tone !== 'none' || size !== undefined || control || block
117
127
  );
128
+ const counted = $derived(hasCount(count));
129
+ const countText = $derived(hasCount(count) ? formatCount(count, countMax) : '');
130
+ const triggerName = $derived(counted ? `${label}, ${count}` : label);
118
131
 
119
132
  const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
120
133
  let triggerEl = $state<HTMLElement | null>(null);
@@ -123,7 +136,14 @@
123
136
  // reopening is instant. The panel element itself always renders — the native
124
137
  // `popovertarget` wiring needs its id present in the DOM at all times.
125
138
  let opened = $state(false);
126
- let open = $state(false);
139
+
140
+ let shown = false;
141
+
142
+ $effect(() => {
143
+ if (!panelEl) return;
144
+ if (open && !shown) show();
145
+ else if (!open && shown) close();
146
+ });
127
147
 
128
148
  function close() {
129
149
  try {
@@ -200,6 +220,7 @@
200
220
  }
201
221
 
202
222
  async function onToggle(e: ToggleEvent) {
223
+ shown = e.newState === 'open';
203
224
  if (e.newState === 'open') {
204
225
  const firstOpen = !opened;
205
226
  opened = true;
@@ -246,7 +267,8 @@
246
267
  class:trigger-tone-warn={tone === 'warn'}
247
268
  class:trigger-tone-danger={tone === 'danger'}
248
269
  class:is-disabled={disabled && as === 'a'}
249
- aria-label={label}
270
+ class:counted
271
+ aria-label={triggerName}
250
272
  aria-haspopup={haspopup}
251
273
  aria-expanded={open}
252
274
  {...triggerAttrs}
@@ -256,6 +278,7 @@
256
278
  onkeydown={onTriggerKeydown}
257
279
  >
258
280
  {@render trigger()}
281
+ {#if counted}<span class="pop-count" aria-hidden="true">{countText}</span>{/if}
259
282
  </svelte:element>
260
283
 
261
284
  <div
@@ -444,6 +467,42 @@
444
467
  cursor: pointer;
445
468
  text-decoration: none;
446
469
  }
470
+ /* Corner count, the same pill Button paints: anchored to the top-right
471
+ corner and pulled half outside it so the trigger keeps its own box. */
472
+ .pop-trigger.counted {
473
+ position: relative;
474
+ }
475
+ .pop-count {
476
+ position: absolute;
477
+ top: 0;
478
+ right: 0;
479
+ transform: translate(45%, -45%);
480
+ display: inline-flex;
481
+ align-items: center;
482
+ justify-content: center;
483
+ box-sizing: border-box;
484
+ min-width: var(--pop-count-size, 1.125rem);
485
+ height: var(--pop-count-size, 1.125rem);
486
+ padding: 0 var(--sp-1);
487
+ border-radius: var(--r-pill);
488
+ background: var(--pop-count-bg, var(--accent));
489
+ color: var(--pop-count-fg, var(--text-on-accent));
490
+ font-size: var(--fs-xs);
491
+ font-weight: var(--fw-semibold);
492
+ font-variant-numeric: tabular-nums;
493
+ line-height: 1;
494
+ white-space: nowrap;
495
+ pointer-events: none;
496
+ }
497
+ .trigger-sm .pop-count,
498
+ .bare .pop-count {
499
+ --pop-count-size: 1rem;
500
+ font-size: calc(var(--fs-xs) * 0.85);
501
+ }
502
+ .trigger-primary .pop-count {
503
+ background: var(--pop-count-bg, var(--text-on-accent));
504
+ color: var(--pop-count-fg, var(--accent));
505
+ }
447
506
  /* `bare`: strip the chrome down to a plain button the consumer styles. */
448
507
  :where(.pop-trigger.bare) {
449
508
  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. */
@@ -58,6 +62,8 @@ type Own = {
58
62
  role?: PanelRole;
59
63
  /** `aria-haspopup` announced on the trigger. */
60
64
  haspopup?: HasPopup;
65
+ /** Bindable open state; setting it opens or closes the panel, even before its first open. */
66
+ open?: boolean;
61
67
  onopen?: () => void;
62
68
  onclose?: () => void;
63
69
  class?: string;
@@ -67,6 +73,6 @@ type Own = {
67
73
  panelStyle?: string;
68
74
  };
69
75
  type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
70
- declare const Popover: import("svelte").Component<$$ComponentProps, {}, "">;
76
+ declare const Popover: import("svelte").Component<$$ComponentProps, {}, "open">;
71
77
  type Popover = ReturnType<typeof Popover>;
72
78
  export default Popover;