@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
value: string;
|
|
4
4
|
label: string;
|
|
5
5
|
hint?: string;
|
|
6
|
+
/** Secondary text under the label, announced via `aria-describedby`. */
|
|
6
7
|
description?: string;
|
|
7
8
|
note?: string;
|
|
8
9
|
disabled?: boolean;
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
variant = 'list',
|
|
27
28
|
action,
|
|
28
29
|
below,
|
|
30
|
+
onfocuschange,
|
|
29
31
|
class: klass = ''
|
|
30
32
|
}: {
|
|
31
33
|
options: RadioOption[];
|
|
@@ -36,8 +38,21 @@
|
|
|
36
38
|
variant?: 'list' | 'rows' | 'swatch';
|
|
37
39
|
action?: Snippet<[RadioOption]>;
|
|
38
40
|
below?: Snippet<[RadioOption]>;
|
|
41
|
+
/** Fires once each time a different option becomes active — by keyboard
|
|
42
|
+
* focus or by pointer hover — so a consumer can drive a preview pane. */
|
|
43
|
+
onfocuschange?: (value: string) => void;
|
|
39
44
|
class?: string;
|
|
40
45
|
} = $props();
|
|
46
|
+
|
|
47
|
+
const uid = $props.id();
|
|
48
|
+
const descId = (o: RadioOption) => (o.description ? `${uid}-${o.value}-desc` : undefined);
|
|
49
|
+
|
|
50
|
+
let active: string | undefined;
|
|
51
|
+
function activate(v: string) {
|
|
52
|
+
if (v === active) return;
|
|
53
|
+
active = v;
|
|
54
|
+
onfocuschange?.(v);
|
|
55
|
+
}
|
|
41
56
|
</script>
|
|
42
57
|
|
|
43
58
|
<div
|
|
@@ -51,22 +66,40 @@
|
|
|
51
66
|
>
|
|
52
67
|
{#each options as o (o.value)}
|
|
53
68
|
{#if variant === 'swatch'}
|
|
54
|
-
<label class="swatch" class:disabled={o.disabled} title={o.label}>
|
|
55
|
-
<input
|
|
69
|
+
<label class="swatch" class:disabled={o.disabled} title={o.label} onpointerenter={() => activate(o.value)}>
|
|
70
|
+
<input
|
|
71
|
+
type="radio"
|
|
72
|
+
{name}
|
|
73
|
+
value={o.value}
|
|
74
|
+
bind:group={value}
|
|
75
|
+
disabled={o.disabled}
|
|
76
|
+
aria-label={o.label}
|
|
77
|
+
aria-describedby={descId(o)}
|
|
78
|
+
onfocus={() => activate(o.value)}
|
|
79
|
+
/>
|
|
80
|
+
{#if o.description}<span class="sr-only" id={descId(o)}>{o.description}</span>{/if}
|
|
56
81
|
<span class="swatch-chip" aria-hidden="true" style="--swatch: {o.color ?? 'var(--accent)'}"></span>
|
|
57
82
|
</label>
|
|
58
83
|
{:else if variant === 'rows'}
|
|
59
84
|
<div class="row-wrap">
|
|
60
85
|
<div class="row" class:selected={o.value === value} class:disabled={o.disabled}>
|
|
61
|
-
<label class="radio">
|
|
62
|
-
<input
|
|
86
|
+
<label class="radio" onpointerenter={() => activate(o.value)}>
|
|
87
|
+
<input
|
|
88
|
+
type="radio"
|
|
89
|
+
{name}
|
|
90
|
+
value={o.value}
|
|
91
|
+
bind:group={value}
|
|
92
|
+
disabled={o.disabled}
|
|
93
|
+
aria-describedby={descId(o)}
|
|
94
|
+
onfocus={() => activate(o.value)}
|
|
95
|
+
/>
|
|
63
96
|
<span class="dot-ctl" aria-hidden="true"></span>
|
|
64
97
|
<span class="texts">
|
|
65
98
|
<span class="label-line">
|
|
66
99
|
<span class="label-text">{o.label}</span>
|
|
67
100
|
{#if o.note}<span class="note">{o.note}</span>{/if}
|
|
68
101
|
</span>
|
|
69
|
-
{#if o.description}<span class="description">{o.description}</span>{/if}
|
|
102
|
+
{#if o.description}<span class="description" id={descId(o)}>{o.description}</span>{/if}
|
|
70
103
|
{#if o.hint}<span class="hint">{o.hint}</span>{/if}
|
|
71
104
|
</span>
|
|
72
105
|
</label>
|
|
@@ -75,11 +108,20 @@
|
|
|
75
108
|
{#if below}<div class="below">{@render below(o)}</div>{/if}
|
|
76
109
|
</div>
|
|
77
110
|
{:else}
|
|
78
|
-
<label class="radio" class:disabled={o.disabled}>
|
|
79
|
-
<input
|
|
111
|
+
<label class="radio" class:disabled={o.disabled} onpointerenter={() => activate(o.value)}>
|
|
112
|
+
<input
|
|
113
|
+
type="radio"
|
|
114
|
+
{name}
|
|
115
|
+
value={o.value}
|
|
116
|
+
bind:group={value}
|
|
117
|
+
disabled={o.disabled}
|
|
118
|
+
aria-describedby={descId(o)}
|
|
119
|
+
onfocus={() => activate(o.value)}
|
|
120
|
+
/>
|
|
80
121
|
<span class="dot-ctl" aria-hidden="true"></span>
|
|
81
122
|
<span class="texts">
|
|
82
123
|
<span class="label-text">{o.label}</span>
|
|
124
|
+
{#if o.description}<span class="description" id={descId(o)}>{o.description}</span>{/if}
|
|
83
125
|
{#if o.hint}<span class="hint">{o.hint}</span>{/if}
|
|
84
126
|
</span>
|
|
85
127
|
</label>
|
|
@@ -208,10 +250,20 @@
|
|
|
208
250
|
.description {
|
|
209
251
|
font-size: var(--fs-xs);
|
|
210
252
|
color: var(--text-muted);
|
|
253
|
+
}
|
|
254
|
+
.row .description {
|
|
211
255
|
overflow: hidden;
|
|
212
256
|
text-overflow: ellipsis;
|
|
213
257
|
white-space: nowrap;
|
|
214
258
|
}
|
|
259
|
+
.sr-only {
|
|
260
|
+
position: absolute;
|
|
261
|
+
width: 1px;
|
|
262
|
+
height: 1px;
|
|
263
|
+
overflow: hidden;
|
|
264
|
+
clip: rect(0 0 0 0);
|
|
265
|
+
white-space: nowrap;
|
|
266
|
+
}
|
|
215
267
|
.action {
|
|
216
268
|
flex: none;
|
|
217
269
|
margin-left: auto;
|
|
@@ -2,6 +2,7 @@ export interface RadioOption {
|
|
|
2
2
|
value: string;
|
|
3
3
|
label: string;
|
|
4
4
|
hint?: string;
|
|
5
|
+
/** Secondary text under the label, announced via `aria-describedby`. */
|
|
5
6
|
description?: string;
|
|
6
7
|
note?: string;
|
|
7
8
|
disabled?: boolean;
|
|
@@ -18,6 +19,9 @@ type $$ComponentProps = {
|
|
|
18
19
|
variant?: 'list' | 'rows' | 'swatch';
|
|
19
20
|
action?: Snippet<[RadioOption]>;
|
|
20
21
|
below?: Snippet<[RadioOption]>;
|
|
22
|
+
/** Fires once each time a different option becomes active — by keyboard
|
|
23
|
+
* focus or by pointer hover — so a consumer can drive a preview pane. */
|
|
24
|
+
onfocuschange?: (value: string) => void;
|
|
21
25
|
class?: string;
|
|
22
26
|
};
|
|
23
27
|
declare const RadioGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// One control, two segments: a primary action and a narrow caret that opens
|
|
3
|
+
// a Menu. Both segments keep their own hover/active/focus, and the caret
|
|
4
|
+
// carries the popup semantics. The segments are the stock Button and Menu
|
|
5
|
+
// triggers, joined through their published `--btn-*` / `--pop-trigger-*`
|
|
6
|
+
// properties so the fused look needs no reach into their scoped CSS.
|
|
7
|
+
import type { ControlSize } from '../../size';
|
|
8
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
9
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
10
|
+
import Button from '../atoms/Button.svelte';
|
|
11
|
+
import Icon from '../atoms/Icon.svelte';
|
|
12
|
+
import Menu, { type MenuItem } from './Menu.svelte';
|
|
13
|
+
|
|
14
|
+
type MenuProps = ComponentProps<typeof Menu>;
|
|
15
|
+
|
|
16
|
+
type Own = {
|
|
17
|
+
variant?: 'default' | 'primary' | 'danger';
|
|
18
|
+
size?: ControlSize;
|
|
19
|
+
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
20
|
+
control?: boolean;
|
|
21
|
+
/** Disables both segments. */
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
/** Disables the caret only; the primary action keeps working. */
|
|
24
|
+
menuDisabled?: boolean;
|
|
25
|
+
/** Accessible name of the caret ("More send options"). */
|
|
26
|
+
label: string;
|
|
27
|
+
items: MenuItem[];
|
|
28
|
+
placement?: MenuProps['placement'];
|
|
29
|
+
/** Custom trailing content for items that carry a `tag`. */
|
|
30
|
+
tag?: MenuProps['tag'];
|
|
31
|
+
panelClass?: string;
|
|
32
|
+
gap?: number;
|
|
33
|
+
/** Bindable open state of the menu. */
|
|
34
|
+
open?: boolean;
|
|
35
|
+
onopen?: () => void;
|
|
36
|
+
onclose?: () => void;
|
|
37
|
+
/** `type` of the primary action, for a submit inside a form. */
|
|
38
|
+
type?: 'button' | 'submit';
|
|
39
|
+
/** Busy primary action: spinner, blocks clicks, `aria-busy`. */
|
|
40
|
+
loading?: boolean;
|
|
41
|
+
/** Handler of the primary action. */
|
|
42
|
+
onclick?: (e: MouseEvent) => void;
|
|
43
|
+
class?: string;
|
|
44
|
+
style?: string;
|
|
45
|
+
/** Content of the primary action. */
|
|
46
|
+
children: Snippet;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let {
|
|
50
|
+
variant = 'default',
|
|
51
|
+
size = 'md',
|
|
52
|
+
control = false,
|
|
53
|
+
disabled = false,
|
|
54
|
+
menuDisabled = false,
|
|
55
|
+
label,
|
|
56
|
+
items,
|
|
57
|
+
placement = 'bottom-end',
|
|
58
|
+
tag,
|
|
59
|
+
panelClass,
|
|
60
|
+
gap,
|
|
61
|
+
open = $bindable(false),
|
|
62
|
+
onopen,
|
|
63
|
+
onclose,
|
|
64
|
+
type = 'button',
|
|
65
|
+
loading = false,
|
|
66
|
+
onclick,
|
|
67
|
+
class: klass = '',
|
|
68
|
+
style: styleProp = '',
|
|
69
|
+
children,
|
|
70
|
+
...rest
|
|
71
|
+
}: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
|
|
72
|
+
|
|
73
|
+
const HEIGHT: Record<ControlSize, string> = {
|
|
74
|
+
sm: 'var(--control-height-compact)',
|
|
75
|
+
md: 'var(--control-height-default)',
|
|
76
|
+
lg: 'var(--control-height-large)',
|
|
77
|
+
};
|
|
78
|
+
const height = $derived(control ? 'var(--control-height)' : HEIGHT[size]);
|
|
79
|
+
const caretDisabled = $derived(disabled || menuDisabled);
|
|
80
|
+
|
|
81
|
+
let caretEl = $state<HTMLSpanElement | null>(null);
|
|
82
|
+
|
|
83
|
+
function openMenu() {
|
|
84
|
+
caretEl?.querySelector<HTMLButtonElement>('[popovertarget]')?.click();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onPrimaryKeydown(e: KeyboardEvent) {
|
|
88
|
+
if (e.key !== 'ArrowDown' || caretDisabled) return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
openMenu();
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<div
|
|
95
|
+
{...rest}
|
|
96
|
+
data-tsu="SplitButton"
|
|
97
|
+
class="split {klass}"
|
|
98
|
+
class:split-primary={variant === 'primary'}
|
|
99
|
+
style={styleProp}
|
|
100
|
+
style:--split-height={height}
|
|
101
|
+
>
|
|
102
|
+
<span class="split-main">
|
|
103
|
+
<Button
|
|
104
|
+
{variant}
|
|
105
|
+
{size}
|
|
106
|
+
{control}
|
|
107
|
+
{type}
|
|
108
|
+
{loading}
|
|
109
|
+
{disabled}
|
|
110
|
+
block
|
|
111
|
+
{onclick}
|
|
112
|
+
onkeydown={onPrimaryKeydown}
|
|
113
|
+
>
|
|
114
|
+
{@render children()}
|
|
115
|
+
</Button>
|
|
116
|
+
</span>
|
|
117
|
+
<span class="split-caret" bind:this={caretEl}>
|
|
118
|
+
<Menu
|
|
119
|
+
{label}
|
|
120
|
+
{items}
|
|
121
|
+
{placement}
|
|
122
|
+
{tag}
|
|
123
|
+
{panelClass}
|
|
124
|
+
{gap}
|
|
125
|
+
{variant}
|
|
126
|
+
{size}
|
|
127
|
+
{control}
|
|
128
|
+
block
|
|
129
|
+
disabled={caretDisabled}
|
|
130
|
+
bind:open
|
|
131
|
+
{onopen}
|
|
132
|
+
{onclose}
|
|
133
|
+
>
|
|
134
|
+
{#snippet trigger()}<Icon name="chevron-down" size={size === 'sm' ? 14 : 16} />{/snippet}
|
|
135
|
+
</Menu>
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<style>
|
|
140
|
+
.split {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: stretch;
|
|
143
|
+
vertical-align: middle;
|
|
144
|
+
}
|
|
145
|
+
.split-main {
|
|
146
|
+
display: flex;
|
|
147
|
+
position: relative;
|
|
148
|
+
--btn-radius: var(--r-md) 0 0 var(--r-md);
|
|
149
|
+
}
|
|
150
|
+
/* The caret is as wide as the control is tall; its border overlaps the
|
|
151
|
+
primary's so the two 1px lines read as one divider. */
|
|
152
|
+
.split-caret {
|
|
153
|
+
display: flex;
|
|
154
|
+
position: relative;
|
|
155
|
+
width: var(--split-height);
|
|
156
|
+
flex: none;
|
|
157
|
+
margin-inline-start: -1px;
|
|
158
|
+
--pop-trigger-radius: 0 var(--r-md) var(--r-md) 0;
|
|
159
|
+
--pop-trigger-pad: 0;
|
|
160
|
+
}
|
|
161
|
+
/* The hovered or focused segment paints above its neighbour, so its
|
|
162
|
+
accent border and focus ring are not cut by the overlap. */
|
|
163
|
+
.split-main:hover,
|
|
164
|
+
.split-main:focus-within,
|
|
165
|
+
.split-caret:hover,
|
|
166
|
+
.split-caret:focus-within {
|
|
167
|
+
z-index: 1;
|
|
168
|
+
}
|
|
169
|
+
/* On a filled primary the borders match the fill; paint the divider in the
|
|
170
|
+
on-accent foreground instead. */
|
|
171
|
+
.split-primary .split-caret::before {
|
|
172
|
+
content: '';
|
|
173
|
+
position: absolute;
|
|
174
|
+
inset-block: 0;
|
|
175
|
+
inset-inline-start: 0;
|
|
176
|
+
width: 1px;
|
|
177
|
+
background: color-mix(in srgb, var(--text-on-accent) 35%, transparent);
|
|
178
|
+
pointer-events: none;
|
|
179
|
+
z-index: 2;
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ControlSize } from '../../size';
|
|
2
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import Menu, { type MenuItem } from './Menu.svelte';
|
|
5
|
+
type MenuProps = ComponentProps<typeof Menu>;
|
|
6
|
+
type Own = {
|
|
7
|
+
variant?: 'default' | 'primary' | 'danger';
|
|
8
|
+
size?: ControlSize;
|
|
9
|
+
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
10
|
+
control?: boolean;
|
|
11
|
+
/** Disables both segments. */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Disables the caret only; the primary action keeps working. */
|
|
14
|
+
menuDisabled?: boolean;
|
|
15
|
+
/** Accessible name of the caret ("More send options"). */
|
|
16
|
+
label: string;
|
|
17
|
+
items: MenuItem[];
|
|
18
|
+
placement?: MenuProps['placement'];
|
|
19
|
+
/** Custom trailing content for items that carry a `tag`. */
|
|
20
|
+
tag?: MenuProps['tag'];
|
|
21
|
+
panelClass?: string;
|
|
22
|
+
gap?: number;
|
|
23
|
+
/** Bindable open state of the menu. */
|
|
24
|
+
open?: boolean;
|
|
25
|
+
onopen?: () => void;
|
|
26
|
+
onclose?: () => void;
|
|
27
|
+
/** `type` of the primary action, for a submit inside a form. */
|
|
28
|
+
type?: 'button' | 'submit';
|
|
29
|
+
/** Busy primary action: spinner, blocks clicks, `aria-busy`. */
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
/** Handler of the primary action. */
|
|
32
|
+
onclick?: (e: MouseEvent) => void;
|
|
33
|
+
class?: string;
|
|
34
|
+
style?: string;
|
|
35
|
+
/** Content of the primary action. */
|
|
36
|
+
children: Snippet;
|
|
37
|
+
};
|
|
38
|
+
type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
|
|
39
|
+
declare const SplitButton: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
40
|
+
type SplitButton = ReturnType<typeof SplitButton>;
|
|
41
|
+
export default SplitButton;
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
/** Trailing count badge. */
|
|
9
9
|
count?: number | string;
|
|
10
|
+
/** Hover text for the whole tab — the full document name behind a truncated label. */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Per-tab override of the strip's `closable` (pin one tab open). */
|
|
13
|
+
closable?: boolean;
|
|
10
14
|
/**
|
|
11
15
|
* Extra attributes for this tab's button — `data-*`, `title`…
|
|
12
16
|
* The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
|
|
@@ -23,15 +27,25 @@
|
|
|
23
27
|
// and reveal the panel in one step; roving tabindex keeps exactly one tab in
|
|
24
28
|
// the Tab order. `value` is bindable; `panel` is a snippet that receives the
|
|
25
29
|
// active id so the caller renders the matching content.
|
|
30
|
+
//
|
|
31
|
+
// Without `panel` the tablist stands alone as a document strip: `closable`
|
|
32
|
+
// tabs gain a close control, Delete and middle-click close the focused tab,
|
|
33
|
+
// `leading` decorates each tab and `actions` trails the strip.
|
|
26
34
|
import type { Snippet } from 'svelte';
|
|
27
35
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
28
36
|
import Icon from '../atoms/Icon.svelte';
|
|
37
|
+
import IconButton from './IconButton.svelte';
|
|
29
38
|
|
|
30
39
|
let {
|
|
31
40
|
tabs,
|
|
32
41
|
value = $bindable(),
|
|
33
42
|
label = 'Tabs',
|
|
34
43
|
panel,
|
|
44
|
+
closable = false,
|
|
45
|
+
onclose,
|
|
46
|
+
closeLabel = 'Close tab',
|
|
47
|
+
leading,
|
|
48
|
+
actions,
|
|
35
49
|
class: klass = '',
|
|
36
50
|
style: styleProp = '',
|
|
37
51
|
panelClass = '',
|
|
@@ -43,7 +57,18 @@
|
|
|
43
57
|
tabs: TabItem[];
|
|
44
58
|
value?: string;
|
|
45
59
|
label?: string;
|
|
46
|
-
|
|
60
|
+
/** Content for the active tab. Omit it for a strip-only tablist. */
|
|
61
|
+
panel?: Snippet<[string]>;
|
|
62
|
+
/** Every tab gets a close control; Delete and middle-click close too. */
|
|
63
|
+
closable?: boolean;
|
|
64
|
+
/** Called with the tab id; the caller removes it from `tabs`. */
|
|
65
|
+
onclose?: (id: string) => void;
|
|
66
|
+
/** Accessible name of each close control. */
|
|
67
|
+
closeLabel?: string;
|
|
68
|
+
/** Rendered before the icon and label of every tab (a status dot, a number). */
|
|
69
|
+
leading?: Snippet<[TabItem]>;
|
|
70
|
+
/** Trailing controls after the strip (close all, new tab). */
|
|
71
|
+
actions?: Snippet;
|
|
47
72
|
class?: string;
|
|
48
73
|
style?: string;
|
|
49
74
|
panelClass?: string;
|
|
@@ -60,12 +85,17 @@
|
|
|
60
85
|
|
|
61
86
|
let listEl = $state<HTMLDivElement | null>(null);
|
|
62
87
|
const baseId = `tabs-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
|
+
const panelId = $derived(panel ? `${baseId}-panel` : undefined);
|
|
89
|
+
|
|
90
|
+
function canClose(t: TabItem): boolean {
|
|
91
|
+
return t.closable ?? closable;
|
|
92
|
+
}
|
|
63
93
|
|
|
64
94
|
function select(id: string, focus = false) {
|
|
65
95
|
if (tabs.find((t) => t.id === id)?.disabled) return;
|
|
66
96
|
value = id;
|
|
67
97
|
queueMicrotask(() => {
|
|
68
|
-
const el = listEl?.querySelector<
|
|
98
|
+
const el = listEl?.querySelector<HTMLElement>(`#${baseId}-tab-${id}`);
|
|
69
99
|
// The list scrolls when it is too narrow, so keyboard selection has to
|
|
70
100
|
// bring the tab back into view or it moves somewhere unseen.
|
|
71
101
|
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
@@ -80,9 +110,33 @@
|
|
|
80
110
|
}
|
|
81
111
|
return from;
|
|
82
112
|
}
|
|
113
|
+
// Closing the selected tab hands selection to a neighbour first, so the
|
|
114
|
+
// caller's removal never leaves the strip without a tabbable tab.
|
|
115
|
+
function close(id: string, focus = false) {
|
|
116
|
+
const i = tabs.findIndex((t) => t.id === id);
|
|
117
|
+
if (i < 0 || !canClose(tabs[i])) return;
|
|
118
|
+
if (value === id) {
|
|
119
|
+
const others = tabs.filter((t) => t.id !== id && !t.disabled);
|
|
120
|
+
const next = others.find((t) => tabs.indexOf(t) > i) ?? others.at(-1);
|
|
121
|
+
if (next) select(next.id, focus);
|
|
122
|
+
else value = undefined;
|
|
123
|
+
}
|
|
124
|
+
onclose?.(id);
|
|
125
|
+
}
|
|
126
|
+
function onauxclick(e: MouseEvent, id: string) {
|
|
127
|
+
if (e.button !== 1) return;
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
close(id);
|
|
130
|
+
}
|
|
83
131
|
function onkeydown(e: KeyboardEvent) {
|
|
84
132
|
const i = tabs.findIndex((t) => t.id === value);
|
|
85
133
|
if (i < 0) return;
|
|
134
|
+
if (e.key === 'Delete') {
|
|
135
|
+
if (!canClose(tabs[i])) return;
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
close(tabs[i].id, true);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
86
140
|
let next = i;
|
|
87
141
|
if (e.key === 'ArrowRight') next = step(i, 1);
|
|
88
142
|
else if (e.key === 'ArrowLeft') next = step(i, -1);
|
|
@@ -94,36 +148,90 @@
|
|
|
94
148
|
}
|
|
95
149
|
</script>
|
|
96
150
|
|
|
151
|
+
{#snippet body(t: TabItem)}
|
|
152
|
+
{#if leading}{@render leading(t)}{/if}
|
|
153
|
+
{#if t.icon}<Icon name={t.icon} />{/if}
|
|
154
|
+
<span class="label">{t.label}</span>
|
|
155
|
+
{#if t.count !== undefined}<Badge size="sm" tone={value === t.id ? 'accent' : 'neutral'}>{t.count}</Badge>{/if}
|
|
156
|
+
{/snippet}
|
|
157
|
+
|
|
97
158
|
<div {...rest} class="tabs {klass}" style={styleProp} data-tsu="Tabs">
|
|
98
|
-
<div
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
159
|
+
<div class="strip">
|
|
160
|
+
<div bind:this={listEl} role="tablist" aria-label={label} tabindex="-1" class="tablist" {onkeydown}>
|
|
161
|
+
{#each tabs as t (t.id)}
|
|
162
|
+
{#if canClose(t)}
|
|
163
|
+
<!-- A close control cannot nest inside a <button>, so a closable tab is a div. -->
|
|
164
|
+
<div
|
|
165
|
+
{...t.attrs as HTMLAttributes<HTMLDivElement>}
|
|
166
|
+
role="tab"
|
|
167
|
+
id="{baseId}-tab-{t.id}"
|
|
168
|
+
title={t.title ?? t.attrs?.title}
|
|
169
|
+
aria-selected={value === t.id}
|
|
170
|
+
aria-controls={panelId}
|
|
171
|
+
aria-disabled={t.disabled || undefined}
|
|
172
|
+
tabindex={value === t.id ? 0 : -1}
|
|
173
|
+
class="tab closable"
|
|
174
|
+
class:selected={value === t.id}
|
|
175
|
+
class:disabled={t.disabled}
|
|
176
|
+
onclick={() => select(t.id)}
|
|
177
|
+
onauxclick={(e) => onauxclick(e, t.id)}
|
|
178
|
+
>
|
|
179
|
+
{@render body(t)}
|
|
180
|
+
<IconButton
|
|
181
|
+
icon="x"
|
|
182
|
+
label={closeLabel}
|
|
183
|
+
inline
|
|
184
|
+
hoverDanger
|
|
185
|
+
size={14}
|
|
186
|
+
hitArea="compact"
|
|
187
|
+
tabindex={-1}
|
|
188
|
+
disabled={t.disabled}
|
|
189
|
+
onclick={(e) => {
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
close(t.id);
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
{:else}
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
{...t.attrs}
|
|
199
|
+
role="tab"
|
|
200
|
+
id="{baseId}-tab-{t.id}"
|
|
201
|
+
title={t.title ?? t.attrs?.title}
|
|
202
|
+
aria-selected={value === t.id}
|
|
203
|
+
aria-controls={panelId}
|
|
204
|
+
disabled={t.disabled}
|
|
205
|
+
tabindex={value === t.id ? 0 : -1}
|
|
206
|
+
class="tab"
|
|
207
|
+
class:selected={value === t.id}
|
|
208
|
+
onclick={() => select(t.id)}
|
|
209
|
+
>
|
|
210
|
+
{@render body(t)}
|
|
211
|
+
</button>
|
|
212
|
+
{/if}
|
|
213
|
+
{/each}
|
|
214
|
+
</div>
|
|
215
|
+
{#if actions}
|
|
216
|
+
<div class="actions">{@render actions()}</div>
|
|
217
|
+
{/if}
|
|
121
218
|
</div>
|
|
219
|
+
{#if panel}
|
|
220
|
+
<div role="tabpanel" id={panelId} tabindex="0" class="tabpanel {panelClass}" class:pad-none={panelPadding === 'none'} class:pad-sm={panelPadding === 'sm'}>
|
|
221
|
+
{#if value !== undefined}{@render panel(value)}{/if}
|
|
222
|
+
</div>
|
|
223
|
+
{/if}
|
|
122
224
|
</div>
|
|
123
225
|
|
|
124
226
|
<style>
|
|
227
|
+
.strip {
|
|
228
|
+
display: flex;
|
|
229
|
+
align-items: stretch;
|
|
230
|
+
}
|
|
125
231
|
.tablist {
|
|
126
232
|
display: flex;
|
|
233
|
+
flex: 1 1 auto;
|
|
234
|
+
min-width: 0;
|
|
127
235
|
gap: var(--sp-1);
|
|
128
236
|
border-bottom: 1px solid var(--border);
|
|
129
237
|
overflow-x: auto;
|
|
@@ -144,14 +252,21 @@
|
|
|
144
252
|
font-weight: var(--fw-medium);
|
|
145
253
|
border-bottom: 2px solid transparent;
|
|
146
254
|
margin-bottom: -1px;
|
|
255
|
+
max-width: var(--tab-max-width, none);
|
|
147
256
|
transition:
|
|
148
257
|
color 0.12s var(--ease),
|
|
149
258
|
border-color 0.12s var(--ease);
|
|
150
259
|
}
|
|
151
|
-
.tab
|
|
260
|
+
.tab.closable {
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
padding-inline-end: var(--sp-2);
|
|
263
|
+
user-select: none;
|
|
264
|
+
}
|
|
265
|
+
.tab:hover:not(:disabled, .disabled) {
|
|
152
266
|
color: var(--text);
|
|
153
267
|
}
|
|
154
|
-
.tab:disabled
|
|
268
|
+
.tab:disabled,
|
|
269
|
+
.tab.disabled {
|
|
155
270
|
opacity: 0.45;
|
|
156
271
|
cursor: not-allowed;
|
|
157
272
|
}
|
|
@@ -159,6 +274,19 @@
|
|
|
159
274
|
color: var(--accent);
|
|
160
275
|
border-bottom-color: var(--accent);
|
|
161
276
|
}
|
|
277
|
+
.label {
|
|
278
|
+
min-width: 0;
|
|
279
|
+
overflow: hidden;
|
|
280
|
+
text-overflow: ellipsis;
|
|
281
|
+
}
|
|
282
|
+
.actions {
|
|
283
|
+
display: flex;
|
|
284
|
+
flex: 0 0 auto;
|
|
285
|
+
align-items: center;
|
|
286
|
+
gap: var(--sp-1);
|
|
287
|
+
padding-inline-start: var(--sp-2);
|
|
288
|
+
border-bottom: 1px solid var(--border);
|
|
289
|
+
}
|
|
162
290
|
.tabpanel {
|
|
163
291
|
padding-top: var(--sp-4);
|
|
164
292
|
}
|
|
@@ -6,6 +6,10 @@ export interface TabItem {
|
|
|
6
6
|
disabled?: boolean;
|
|
7
7
|
/** Trailing count badge. */
|
|
8
8
|
count?: number | string;
|
|
9
|
+
/** Hover text for the whole tab — the full document name behind a truncated label. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Per-tab override of the strip's `closable` (pin one tab open). */
|
|
12
|
+
closable?: boolean;
|
|
9
13
|
/**
|
|
10
14
|
* Extra attributes for this tab's button — `data-*`, `title`…
|
|
11
15
|
* The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
|
|
@@ -19,7 +23,18 @@ type Own = {
|
|
|
19
23
|
tabs: TabItem[];
|
|
20
24
|
value?: string;
|
|
21
25
|
label?: string;
|
|
22
|
-
|
|
26
|
+
/** Content for the active tab. Omit it for a strip-only tablist. */
|
|
27
|
+
panel?: Snippet<[string]>;
|
|
28
|
+
/** Every tab gets a close control; Delete and middle-click close too. */
|
|
29
|
+
closable?: boolean;
|
|
30
|
+
/** Called with the tab id; the caller removes it from `tabs`. */
|
|
31
|
+
onclose?: (id: string) => void;
|
|
32
|
+
/** Accessible name of each close control. */
|
|
33
|
+
closeLabel?: string;
|
|
34
|
+
/** Rendered before the icon and label of every tab (a status dot, a number). */
|
|
35
|
+
leading?: Snippet<[TabItem]>;
|
|
36
|
+
/** Trailing controls after the strip (close all, new tab). */
|
|
37
|
+
actions?: Snippet;
|
|
23
38
|
class?: string;
|
|
24
39
|
style?: string;
|
|
25
40
|
panelClass?: string;
|