@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.
- 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 +30 -5
- package/dist/components/atoms/Select.svelte +11 -1
- package/dist/components/atoms/Swatch.svelte +114 -0
- package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
- package/dist/components/atoms/Textarea.svelte +41 -7
- 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
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
-
|
|
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. */
|