@dorsk/tsumikit 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +26 -4
  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 +49 -1
  31. package/dist/components/molecules/Popover.svelte.d.ts +4 -0
  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 +181 -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
@@ -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 type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} aria-label={o.label} />
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 type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
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 type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
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
- panel: Snippet<[string]>;
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<HTMLButtonElement>(`#${baseId}-tab-${id}`);
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 bind:this={listEl} role="tablist" aria-label={label} tabindex="-1" class="tablist" {onkeydown}>
99
- {#each tabs as t (t.id)}
100
- <button
101
- type="button"
102
- {...t.attrs}
103
- role="tab"
104
- id="{baseId}-tab-{t.id}"
105
- aria-selected={value === t.id}
106
- aria-controls="{baseId}-panel"
107
- disabled={t.disabled}
108
- tabindex={value === t.id ? 0 : -1}
109
- class="tab"
110
- class:selected={value === t.id}
111
- onclick={() => select(t.id)}
112
- >
113
- {#if t.icon}<Icon name={t.icon} />{/if}
114
- <span>{t.label}</span>
115
- {#if t.count !== undefined}<Badge size="sm" tone={value === t.id ? 'accent' : 'neutral'}>{t.count}</Badge>{/if}
116
- </button>
117
- {/each}
118
- </div>
119
- <div role="tabpanel" id="{baseId}-panel" tabindex="0" class="tabpanel {panelClass}" class:pad-none={panelPadding === 'none'} class:pad-sm={panelPadding === 'sm'}>
120
- {#if value !== undefined}{@render panel(value)}{/if}
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:hover:not(:disabled) {
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
- panel: Snippet<[string]>;
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;