@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.
- 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 +28 -19
- 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 +61 -2
- package/dist/components/molecules/Popover.svelte.d.ts +7 -1
- package/dist/components/molecules/RadioGroup.svelte +59 -7
- package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
- package/dist/components/molecules/SplitButton.svelte +175 -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;
|
|
@@ -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
|
-
|
|
160
|
-
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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;
|