@dorsk/tsumikit 0.26.0 → 0.28.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 +12 -3
- package/dist/components/atoms/Card.svelte +53 -4
- package/dist/components/atoms/Card.svelte.d.ts +4 -1
- package/dist/components/atoms/Input.svelte +124 -12
- package/dist/components/atoms/Input.svelte.d.ts +12 -0
- package/dist/components/molecules/Field.svelte +8 -1
- package/dist/components/molecules/Field.svelte.d.ts +2 -0
- package/dist/components/molecules/FilterInput.svelte +89 -4
- package/dist/components/molecules/FilterInput.svelte.d.ts +12 -0
- package/dist/components/organisms/DataTable.svelte +133 -3
- package/dist/components/organisms/DataTable.svelte.d.ts +8 -0
- package/dist/components/organisms/FilterSearchBar.svelte +27 -1
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,7 +144,10 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
|
|
|
144
144
|
|
|
145
145
|
## Components
|
|
146
146
|
|
|
147
|
-
**Atoms:** Text, Heading, Button, Input
|
|
147
|
+
**Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
|
|
148
|
+
`clearable` + `onclear`, `shape="pill"`, `width` fixed + `flex: none`,
|
|
149
|
+
`onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
|
|
150
|
+
is used), Textarea, Select, Switch, Checkbox,
|
|
148
151
|
Slider, Progress, Artwork (lazy cover image with seeded gradient + initials
|
|
149
152
|
fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
|
|
150
153
|
(`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
|
|
@@ -153,7 +156,7 @@ fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline
|
|
|
153
156
|
always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
|
|
154
157
|
any custom SVG).
|
|
155
158
|
|
|
156
|
-
**Molecules:** Field, IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
159
|
+
**Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
157
160
|
Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
|
|
158
161
|
Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
|
|
159
162
|
ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore.
|
|
@@ -166,7 +169,13 @@ paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
|
|
|
166
169
|
for a hover/focus-revealed trailing cell (always visible on touch),
|
|
167
170
|
`stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
|
|
168
171
|
`onloadmore` footer button, `empty` as string or snippet; `data-part`
|
|
169
|
-
hooks on head/row/cell
|
|
172
|
+
hooks on head/row/cell; `responsive="stack"` turns rows into cards below
|
|
173
|
+
`stackBelow` (48rem, measured on the table's own box) using `Column.role`
|
|
174
|
+
`title | detail | meta | actions | hidden` — detail cells get a `data-label`
|
|
175
|
+
prefix, the `<table>` stays a table for assistive tech), FilterSearchBar /
|
|
176
|
+
FilterInput (`size="sm"` compact bar on `--control-height-compact`,
|
|
177
|
+
`shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
|
|
178
|
+
outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`).
|
|
170
179
|
|
|
171
180
|
**Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
|
|
172
181
|
sidebar on desktop, overlay drawer on mobile, optionally resizable;
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
// rows); `as` lets it be a button/anchor when the whole surface is clickable.
|
|
6
6
|
// `padding` dials the inner spacing (none/sm/md/lg) for denser cards.
|
|
7
7
|
// `tone` tints the surface itself (border + faint background wash) with a
|
|
8
|
-
// semantic hue for inline banners; `neutral` is the plain card
|
|
8
|
+
// semantic hue for inline banners; `neutral` is the plain card; `attention`
|
|
9
|
+
// adds a left bar for needs-input rows.
|
|
10
|
+
//
|
|
11
|
+
// `interactive` makes a non-button/anchor card keyboard-operable
|
|
12
|
+
// (role=button, tabindex, Enter/Space) and ignores clicks originating from
|
|
13
|
+
// nested interactive elements so inner buttons/links keep working.
|
|
9
14
|
//
|
|
10
15
|
// `stacked` fakes a pile of cards by drawing two layers peeking out below
|
|
11
16
|
// (and optionally to the right) via pseudo-elements. `stackTone` tints those
|
|
@@ -20,10 +25,11 @@
|
|
|
20
25
|
import type { Snippet } from 'svelte';
|
|
21
26
|
import SectionHeader from '../molecules/SectionHeader.svelte';
|
|
22
27
|
|
|
23
|
-
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
28
|
+
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'attention';
|
|
24
29
|
|
|
25
30
|
let {
|
|
26
31
|
tap = false,
|
|
32
|
+
interactive = false,
|
|
27
33
|
as = 'div',
|
|
28
34
|
padding = 'md',
|
|
29
35
|
surface = 'base',
|
|
@@ -35,6 +41,8 @@
|
|
|
35
41
|
title,
|
|
36
42
|
subtitle,
|
|
37
43
|
gap,
|
|
44
|
+
maxWidth,
|
|
45
|
+
onclick,
|
|
38
46
|
class: klass = '',
|
|
39
47
|
style = '',
|
|
40
48
|
header,
|
|
@@ -44,6 +52,7 @@
|
|
|
44
52
|
...rest
|
|
45
53
|
}: {
|
|
46
54
|
tap?: boolean;
|
|
55
|
+
interactive?: boolean;
|
|
47
56
|
as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
|
|
48
57
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
49
58
|
surface?: 'base' | 'raised' | 'sunken';
|
|
@@ -55,6 +64,8 @@
|
|
|
55
64
|
title?: string;
|
|
56
65
|
subtitle?: string;
|
|
57
66
|
gap?: string;
|
|
67
|
+
maxWidth?: string;
|
|
68
|
+
onclick?: (e: MouseEvent | KeyboardEvent) => void;
|
|
58
69
|
class?: string;
|
|
59
70
|
style?: string;
|
|
60
71
|
header?: Snippet;
|
|
@@ -68,6 +79,26 @@
|
|
|
68
79
|
stacked ? `--stack-y:${stackY}px;--stack-x:${stackX}px;` : ''
|
|
69
80
|
);
|
|
70
81
|
const framed = $derived(!!(header || footer || title));
|
|
82
|
+
const native = $derived(as === 'button' || as === 'a');
|
|
83
|
+
|
|
84
|
+
const NESTED = 'button,a,input,select,textarea,[role=button],[contenteditable],[contenteditable=true]';
|
|
85
|
+
function fromNested(e: Event): boolean {
|
|
86
|
+
const target = e.target as Element | null;
|
|
87
|
+
const hit = target?.closest?.(NESTED);
|
|
88
|
+
return !!hit && hit !== e.currentTarget;
|
|
89
|
+
}
|
|
90
|
+
function handleClick(e: MouseEvent) {
|
|
91
|
+
if (!onclick) return;
|
|
92
|
+
if (interactive && fromNested(e)) return;
|
|
93
|
+
onclick(e);
|
|
94
|
+
}
|
|
95
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
96
|
+
if (!interactive || native || !onclick) return;
|
|
97
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
98
|
+
if (fromNested(e)) return;
|
|
99
|
+
if (e.key === ' ') e.preventDefault();
|
|
100
|
+
onclick(e);
|
|
101
|
+
}
|
|
71
102
|
</script>
|
|
72
103
|
|
|
73
104
|
<svelte:element
|
|
@@ -79,11 +110,13 @@
|
|
|
79
110
|
class:pad-lg={padding === 'lg'}
|
|
80
111
|
class:surface-raised={surface === 'raised'}
|
|
81
112
|
class:surface-sunken={surface === 'sunken'}
|
|
82
|
-
class:card-tap={tap}
|
|
113
|
+
class:card-tap={tap || interactive}
|
|
114
|
+
class:card-max={maxWidth !== undefined}
|
|
83
115
|
class:card-ok={tone === 'ok'}
|
|
84
116
|
class:card-warn={tone === 'warn'}
|
|
85
117
|
class:card-danger={tone === 'danger'}
|
|
86
118
|
class:card-info={tone === 'info'}
|
|
119
|
+
class:card-attention={tone === 'attention'}
|
|
87
120
|
class:card-stacked={stacked}
|
|
88
121
|
class:stack-ok={stacked && stackTone === 'ok'}
|
|
89
122
|
class:stack-warn={stacked && stackTone === 'warn'}
|
|
@@ -92,7 +125,12 @@
|
|
|
92
125
|
class:card-framed={framed}
|
|
93
126
|
class:card-gap={!framed && gap !== undefined}
|
|
94
127
|
style:--card-gap={gap}
|
|
128
|
+
style:max-width={maxWidth}
|
|
95
129
|
style={`${stackStyle}${style}`}
|
|
130
|
+
role={interactive && !native ? 'button' : undefined}
|
|
131
|
+
tabindex={interactive && !native ? 0 : undefined}
|
|
132
|
+
onclick={handleClick}
|
|
133
|
+
onkeydown={handleKeydown}
|
|
96
134
|
{...rest}
|
|
97
135
|
>
|
|
98
136
|
{#if framed}
|
|
@@ -177,6 +215,9 @@
|
|
|
177
215
|
.card-tap:hover {
|
|
178
216
|
border-color: var(--border-strong);
|
|
179
217
|
}
|
|
218
|
+
.card-max {
|
|
219
|
+
width: 100%;
|
|
220
|
+
}
|
|
180
221
|
|
|
181
222
|
.card-ok {
|
|
182
223
|
--card-tone: var(--ok);
|
|
@@ -190,13 +231,21 @@
|
|
|
190
231
|
.card-info {
|
|
191
232
|
--card-tone: var(--info);
|
|
192
233
|
}
|
|
234
|
+
.card-attention {
|
|
235
|
+
--card-tone: var(--attention-bar);
|
|
236
|
+
}
|
|
193
237
|
.card-ok,
|
|
194
238
|
.card-warn,
|
|
195
239
|
.card-danger,
|
|
196
|
-
.card-info
|
|
240
|
+
.card-info,
|
|
241
|
+
.card-attention {
|
|
197
242
|
border-color: color-mix(in srgb, var(--card-tone) 55%, var(--border));
|
|
198
243
|
background: color-mix(in srgb, var(--card-tone) 8%, var(--bg-elevated));
|
|
199
244
|
}
|
|
245
|
+
.card-attention {
|
|
246
|
+
background: var(--attention-bg);
|
|
247
|
+
box-shadow: inset 3px 0 0 var(--attention-bar);
|
|
248
|
+
}
|
|
200
249
|
|
|
201
250
|
/* Stacked effect — two back layers peeking out bottom-right. The front
|
|
202
251
|
surface keeps its own background so the layers only show at the edges. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
2
|
+
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'attention';
|
|
3
3
|
type $$ComponentProps = {
|
|
4
4
|
tap?: boolean;
|
|
5
|
+
interactive?: boolean;
|
|
5
6
|
as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
|
|
6
7
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
7
8
|
surface?: 'base' | 'raised' | 'sunken';
|
|
@@ -13,6 +14,8 @@ type $$ComponentProps = {
|
|
|
13
14
|
title?: string;
|
|
14
15
|
subtitle?: string;
|
|
15
16
|
gap?: string;
|
|
17
|
+
maxWidth?: string;
|
|
18
|
+
onclick?: (e: MouseEvent | KeyboardEvent) => void;
|
|
16
19
|
class?: string;
|
|
17
20
|
style?: string;
|
|
18
21
|
header?: Snippet;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Text input primitive. Owns its styling from theme tokens; supports
|
|
3
3
|
// `bind:value` and passes through every native <input> attribute. `mono`
|
|
4
|
-
// switches to the monospace family (paths, tokens, env values).
|
|
4
|
+
// switches to the monospace family (paths, tokens, env values). A wrapper is
|
|
5
|
+
// rendered only when `icon` / `clearable` are used, so the bare <input> DOM
|
|
6
|
+
// stays as-is otherwise.
|
|
5
7
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
8
|
+
import Icon, { type IconName } from './Icon.svelte';
|
|
6
9
|
|
|
7
10
|
// `size` shadows the native char-width attribute (unused in token-sized
|
|
8
11
|
// layouts) to expose a height preset instead.
|
|
@@ -15,6 +18,17 @@
|
|
|
15
18
|
/** Error state: danger border + aria-invalid (also styles if a consumer
|
|
16
19
|
* sets aria-invalid directly). */
|
|
17
20
|
invalid?: boolean;
|
|
21
|
+
/** Leading icon, inset inside the control. */
|
|
22
|
+
icon?: IconName;
|
|
23
|
+
/** Trailing clear (✕) button shown while the value is non-empty. */
|
|
24
|
+
clearable?: boolean;
|
|
25
|
+
onclear?: () => void;
|
|
26
|
+
clearLabel?: string;
|
|
27
|
+
shape?: 'square' | 'pill';
|
|
28
|
+
/** Fixed width (also `flex: none` so flex rows don't stretch it). */
|
|
29
|
+
width?: string;
|
|
30
|
+
/** Fires with the current value on Enter. */
|
|
31
|
+
onenter?: (value: string) => void;
|
|
18
32
|
class?: string;
|
|
19
33
|
value?: HTMLInputAttributes['value'];
|
|
20
34
|
el?: HTMLInputElement | null;
|
|
@@ -25,24 +39,73 @@
|
|
|
25
39
|
size = 'md',
|
|
26
40
|
grow = false,
|
|
27
41
|
invalid = false,
|
|
42
|
+
icon,
|
|
43
|
+
clearable = false,
|
|
44
|
+
onclear,
|
|
45
|
+
clearLabel = 'Clear',
|
|
46
|
+
shape = 'square',
|
|
47
|
+
width,
|
|
48
|
+
onenter,
|
|
49
|
+
onkeydown,
|
|
28
50
|
class: klass = '',
|
|
29
51
|
value = $bindable(),
|
|
30
52
|
el = $bindable(null),
|
|
31
53
|
...rest
|
|
32
54
|
}: Props = $props();
|
|
55
|
+
|
|
56
|
+
const wrapped = $derived(!!icon || clearable);
|
|
57
|
+
|
|
58
|
+
function handleKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
|
59
|
+
onkeydown?.(e);
|
|
60
|
+
if (onenter && e.key === 'Enter' && !e.defaultPrevented) onenter(String(value ?? ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clear() {
|
|
64
|
+
value = '';
|
|
65
|
+
onclear?.();
|
|
66
|
+
el?.focus();
|
|
67
|
+
}
|
|
33
68
|
</script>
|
|
34
69
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
{#snippet control()}
|
|
71
|
+
<input
|
|
72
|
+
bind:this={el}
|
|
73
|
+
data-tsu="Input"
|
|
74
|
+
class="input {klass}"
|
|
75
|
+
class:mono
|
|
76
|
+
class:input-sm={size === 'sm'}
|
|
77
|
+
class:input-grow={grow}
|
|
78
|
+
class:input-fixed={!!width && !wrapped}
|
|
79
|
+
class:input-pill={shape === 'pill'}
|
|
80
|
+
class:has-icon={!!icon}
|
|
81
|
+
class:has-clear={clearable}
|
|
82
|
+
style:width={wrapped ? undefined : width}
|
|
83
|
+
bind:value
|
|
84
|
+
{...rest}
|
|
85
|
+
onkeydown={onenter || onkeydown ? handleKeydown : undefined}
|
|
86
|
+
aria-invalid={invalid || undefined}
|
|
87
|
+
/>
|
|
88
|
+
{/snippet}
|
|
89
|
+
|
|
90
|
+
{#if wrapped}
|
|
91
|
+
<div
|
|
92
|
+
class="input-wrap"
|
|
93
|
+
data-part="wrap"
|
|
94
|
+
class:input-grow={grow}
|
|
95
|
+
class:input-fixed={!!width}
|
|
96
|
+
style:width
|
|
97
|
+
>
|
|
98
|
+
{#if icon}<span class="input-icon"><Icon name={icon} /></span>{/if}
|
|
99
|
+
{@render control()}
|
|
100
|
+
{#if clearable && value}
|
|
101
|
+
<button type="button" class="input-clear" aria-label={clearLabel} onclick={clear}>
|
|
102
|
+
<Icon name="x" />
|
|
103
|
+
</button>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
{:else}
|
|
107
|
+
{@render control()}
|
|
108
|
+
{/if}
|
|
46
109
|
|
|
47
110
|
<style>
|
|
48
111
|
.input {
|
|
@@ -67,6 +130,13 @@
|
|
|
67
130
|
width: auto;
|
|
68
131
|
min-width: 0;
|
|
69
132
|
}
|
|
133
|
+
.input-fixed {
|
|
134
|
+
flex: none;
|
|
135
|
+
}
|
|
136
|
+
.input-pill {
|
|
137
|
+
border-radius: var(--r-pill);
|
|
138
|
+
padding-inline: var(--sp-4);
|
|
139
|
+
}
|
|
70
140
|
.input[aria-invalid='true'] {
|
|
71
141
|
border-color: var(--danger);
|
|
72
142
|
}
|
|
@@ -76,4 +146,46 @@
|
|
|
76
146
|
.mono {
|
|
77
147
|
font-family: var(--font-mono);
|
|
78
148
|
}
|
|
149
|
+
|
|
150
|
+
.input-wrap {
|
|
151
|
+
position: relative;
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
width: 100%;
|
|
155
|
+
}
|
|
156
|
+
.input-wrap .input {
|
|
157
|
+
width: 100%;
|
|
158
|
+
}
|
|
159
|
+
.has-icon {
|
|
160
|
+
padding-inline-start: calc(var(--sp-3) + 1em + var(--sp-2));
|
|
161
|
+
}
|
|
162
|
+
.has-clear {
|
|
163
|
+
padding-inline-end: calc(var(--sp-3) + 1em + var(--sp-2));
|
|
164
|
+
}
|
|
165
|
+
.input-icon,
|
|
166
|
+
.input-clear {
|
|
167
|
+
position: absolute;
|
|
168
|
+
top: 0;
|
|
169
|
+
bottom: 0;
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
color: var(--text-faint);
|
|
173
|
+
pointer-events: none;
|
|
174
|
+
}
|
|
175
|
+
.input-icon {
|
|
176
|
+
left: var(--sp-3);
|
|
177
|
+
}
|
|
178
|
+
.input-clear {
|
|
179
|
+
right: var(--sp-2);
|
|
180
|
+
pointer-events: auto;
|
|
181
|
+
border: 0;
|
|
182
|
+
background: transparent;
|
|
183
|
+
padding: 0 var(--sp-1);
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
border-radius: var(--r-sm);
|
|
186
|
+
}
|
|
187
|
+
.input-clear:hover,
|
|
188
|
+
.input-clear:focus-visible {
|
|
189
|
+
color: var(--text);
|
|
190
|
+
}
|
|
79
191
|
</style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
2
|
+
import { type IconName } from './Icon.svelte';
|
|
2
3
|
type Props = Omit<HTMLInputAttributes, 'size'> & {
|
|
3
4
|
mono?: boolean;
|
|
4
5
|
size?: 'sm' | 'md';
|
|
@@ -8,6 +9,17 @@ type Props = Omit<HTMLInputAttributes, 'size'> & {
|
|
|
8
9
|
/** Error state: danger border + aria-invalid (also styles if a consumer
|
|
9
10
|
* sets aria-invalid directly). */
|
|
10
11
|
invalid?: boolean;
|
|
12
|
+
/** Leading icon, inset inside the control. */
|
|
13
|
+
icon?: IconName;
|
|
14
|
+
/** Trailing clear (✕) button shown while the value is non-empty. */
|
|
15
|
+
clearable?: boolean;
|
|
16
|
+
onclear?: () => void;
|
|
17
|
+
clearLabel?: string;
|
|
18
|
+
shape?: 'square' | 'pill';
|
|
19
|
+
/** Fixed width (also `flex: none` so flex rows don't stretch it). */
|
|
20
|
+
width?: string;
|
|
21
|
+
/** Fires with the current value on Enter. */
|
|
22
|
+
onenter?: (value: string) => void;
|
|
11
23
|
class?: string;
|
|
12
24
|
value?: HTMLInputAttributes['value'];
|
|
13
25
|
el?: HTMLInputElement | null;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
for: forId,
|
|
11
11
|
hint,
|
|
12
12
|
error,
|
|
13
|
+
grow = false,
|
|
13
14
|
class: klass = '',
|
|
14
15
|
children
|
|
15
16
|
}: {
|
|
@@ -17,12 +18,14 @@
|
|
|
17
18
|
for?: string;
|
|
18
19
|
hint?: string;
|
|
19
20
|
error?: string;
|
|
21
|
+
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
22
|
+
grow?: boolean;
|
|
20
23
|
class?: string;
|
|
21
24
|
children?: Snippet;
|
|
22
25
|
} = $props();
|
|
23
26
|
</script>
|
|
24
27
|
|
|
25
|
-
<div class="field {klass}" data-tsu="Field">
|
|
28
|
+
<div class="field {klass}" class:field-grow={grow} data-tsu="Field">
|
|
26
29
|
{#if label}
|
|
27
30
|
{#if forId}
|
|
28
31
|
<label class="label" for={forId}>{label}</label>
|
|
@@ -41,6 +44,10 @@
|
|
|
41
44
|
flex-direction: column;
|
|
42
45
|
gap: var(--sp-1);
|
|
43
46
|
}
|
|
47
|
+
.field-grow {
|
|
48
|
+
flex: 1 1 0;
|
|
49
|
+
min-width: 0;
|
|
50
|
+
}
|
|
44
51
|
.label {
|
|
45
52
|
font-size: var(--fs-sm);
|
|
46
53
|
font-weight: var(--fw-medium);
|
|
@@ -51,6 +51,12 @@
|
|
|
51
51
|
autoQuote = true,
|
|
52
52
|
showClear = true,
|
|
53
53
|
icon = 'search',
|
|
54
|
+
size = 'md',
|
|
55
|
+
shape = 'square',
|
|
56
|
+
surface = 'base',
|
|
57
|
+
hotkey,
|
|
58
|
+
showHotkey = false,
|
|
59
|
+
grow = false,
|
|
54
60
|
onchange,
|
|
55
61
|
onsubmit,
|
|
56
62
|
inline,
|
|
@@ -70,6 +76,18 @@
|
|
|
70
76
|
showClear?: boolean;
|
|
71
77
|
/** Leading icon; pass `null` to hide it. */
|
|
72
78
|
icon?: IconName | null;
|
|
79
|
+
/** `sm` renders a compact-toolbar bar (`--control-height-compact`). */
|
|
80
|
+
size?: 'sm' | 'md';
|
|
81
|
+
shape?: 'square' | 'pill';
|
|
82
|
+
/** Theme-aware surface shade of the bar (mirrors Card). */
|
|
83
|
+
surface?: 'base' | 'raised' | 'sunken';
|
|
84
|
+
/** Key (e.g. `'/'`) that focuses the input when pressed outside an editable
|
|
85
|
+
* element and without modifiers. */
|
|
86
|
+
hotkey?: string;
|
|
87
|
+
/** Render the hotkey as a `<kbd>` hint, hidden on focus / when non-empty. */
|
|
88
|
+
showHotkey?: boolean;
|
|
89
|
+
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
90
|
+
grow?: boolean;
|
|
73
91
|
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
74
92
|
onchange?: (query: Query, raw: string) => void;
|
|
75
93
|
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
@@ -93,8 +111,29 @@
|
|
|
93
111
|
let active = $state(0);
|
|
94
112
|
let open = $state(false);
|
|
95
113
|
let keyboardNavigation = $state(false);
|
|
114
|
+
let focused = $state(false);
|
|
96
115
|
let reqId = 0;
|
|
97
116
|
|
|
117
|
+
function isEditable(target: EventTarget | null): boolean {
|
|
118
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
119
|
+
if (target.isContentEditable) return true;
|
|
120
|
+
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
$effect(() => {
|
|
124
|
+
if (!hotkey) return;
|
|
125
|
+
const key = hotkey;
|
|
126
|
+
function onHotkey(e: KeyboardEvent) {
|
|
127
|
+
if (e.key !== key || e.defaultPrevented) return;
|
|
128
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
129
|
+
if (isEditable(e.target)) return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
el?.focus();
|
|
132
|
+
}
|
|
133
|
+
document.addEventListener('keydown', onHotkey);
|
|
134
|
+
return () => document.removeEventListener('keydown', onHotkey);
|
|
135
|
+
});
|
|
136
|
+
|
|
98
137
|
// Parsed view (drives the onchange AST + the snippet context).
|
|
99
138
|
const ast = $derived(parse(value, schema));
|
|
100
139
|
const chips = $derived(filters(ast));
|
|
@@ -256,7 +295,15 @@
|
|
|
256
295
|
};
|
|
257
296
|
</script>
|
|
258
297
|
|
|
259
|
-
<div
|
|
298
|
+
<div
|
|
299
|
+
class="fi"
|
|
300
|
+
class:fi--sm={size === 'sm'}
|
|
301
|
+
class:fi--pill={shape === 'pill'}
|
|
302
|
+
class:surface-raised={surface === 'raised'}
|
|
303
|
+
class:surface-sunken={surface === 'sunken'}
|
|
304
|
+
class:fi--grow={grow}
|
|
305
|
+
data-tsu="FilterInput"
|
|
306
|
+
>
|
|
260
307
|
<div class="fi__bar">
|
|
261
308
|
{#if icon}<span class="fi__icon"><Icon name={icon} label="Search" /></span>{/if}
|
|
262
309
|
{#if inline}{@render inline(ctx)}{/if}
|
|
@@ -272,10 +319,19 @@
|
|
|
272
319
|
onkeyup={(e) => {
|
|
273
320
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') refresh();
|
|
274
321
|
}}
|
|
275
|
-
onfocus={
|
|
276
|
-
|
|
322
|
+
onfocus={() => {
|
|
323
|
+
focused = true;
|
|
324
|
+
refresh();
|
|
325
|
+
}}
|
|
326
|
+
onblur={() => {
|
|
327
|
+
focused = false;
|
|
328
|
+
setTimeout(() => (open = false), 120);
|
|
329
|
+
}}
|
|
277
330
|
{onkeydown}
|
|
278
331
|
/>
|
|
332
|
+
{#if showHotkey && hotkey && !focused && !value}
|
|
333
|
+
<kbd class="fi__kbd" aria-hidden="true">{hotkey}</kbd>
|
|
334
|
+
{/if}
|
|
279
335
|
{#if showClear && value}
|
|
280
336
|
<button
|
|
281
337
|
type="button"
|
|
@@ -324,16 +380,45 @@
|
|
|
324
380
|
.fi {
|
|
325
381
|
position: relative;
|
|
326
382
|
}
|
|
383
|
+
.fi--grow {
|
|
384
|
+
flex: 1 1 0;
|
|
385
|
+
min-width: 0;
|
|
386
|
+
}
|
|
327
387
|
.fi__bar {
|
|
328
388
|
display: flex;
|
|
329
389
|
align-items: center;
|
|
330
390
|
gap: var(--sp-2);
|
|
331
391
|
padding: 0 var(--sp-3);
|
|
332
|
-
min-height:
|
|
392
|
+
min-height: var(--control-height-default);
|
|
333
393
|
background: var(--bg-elevated);
|
|
334
394
|
border: 1px solid var(--border-strong);
|
|
335
395
|
border-radius: var(--r-md);
|
|
336
396
|
}
|
|
397
|
+
.fi--sm .fi__bar {
|
|
398
|
+
min-height: var(--control-height-compact);
|
|
399
|
+
}
|
|
400
|
+
.fi--sm .fi__input {
|
|
401
|
+
font-size: var(--fs-sm);
|
|
402
|
+
}
|
|
403
|
+
.fi--pill .fi__bar {
|
|
404
|
+
border-radius: var(--r-pill);
|
|
405
|
+
padding-inline: var(--sp-4);
|
|
406
|
+
}
|
|
407
|
+
.surface-raised .fi__bar {
|
|
408
|
+
background: var(--surface);
|
|
409
|
+
}
|
|
410
|
+
.surface-sunken .fi__bar {
|
|
411
|
+
background: var(--bg-elevated-2);
|
|
412
|
+
}
|
|
413
|
+
.fi__kbd {
|
|
414
|
+
font-family: var(--font-mono);
|
|
415
|
+
font-size: var(--fs-xs);
|
|
416
|
+
line-height: 1;
|
|
417
|
+
color: var(--text-faint);
|
|
418
|
+
border: 1px solid var(--border);
|
|
419
|
+
border-radius: var(--r-sm);
|
|
420
|
+
padding: 2px var(--sp-1);
|
|
421
|
+
}
|
|
337
422
|
.fi__bar:focus-within {
|
|
338
423
|
border-color: var(--accent);
|
|
339
424
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
|
@@ -36,6 +36,18 @@ type $$ComponentProps = {
|
|
|
36
36
|
showClear?: boolean;
|
|
37
37
|
/** Leading icon; pass `null` to hide it. */
|
|
38
38
|
icon?: IconName | null;
|
|
39
|
+
/** `sm` renders a compact-toolbar bar (`--control-height-compact`). */
|
|
40
|
+
size?: 'sm' | 'md';
|
|
41
|
+
shape?: 'square' | 'pill';
|
|
42
|
+
/** Theme-aware surface shade of the bar (mirrors Card). */
|
|
43
|
+
surface?: 'base' | 'raised' | 'sunken';
|
|
44
|
+
/** Key (e.g. `'/'`) that focuses the input when pressed outside an editable
|
|
45
|
+
* element and without modifiers. */
|
|
46
|
+
hotkey?: string;
|
|
47
|
+
/** Render the hotkey as a `<kbd>` hint, hidden on focus / when non-empty. */
|
|
48
|
+
showHotkey?: boolean;
|
|
49
|
+
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
50
|
+
grow?: boolean;
|
|
39
51
|
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
40
52
|
onchange?: (query: Query, raw: string) => void;
|
|
41
53
|
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
/** Drop the column when the table's own box is narrower than the breakpoint
|
|
19
19
|
* (sm 30rem, md 48rem, lg 64rem). */
|
|
20
20
|
hideBelow?: 'sm' | 'md' | 'lg';
|
|
21
|
+
/** Placement inside the stacked card (`responsive="stack"` only). Columns
|
|
22
|
+
* without a role render as `label: value` detail lines. */
|
|
23
|
+
role?: 'title' | 'detail' | 'meta' | 'actions' | 'hidden';
|
|
21
24
|
}
|
|
22
25
|
</script>
|
|
23
26
|
|
|
@@ -50,7 +53,9 @@
|
|
|
50
53
|
loading = false,
|
|
51
54
|
loadingLabel = 'Loading…',
|
|
52
55
|
onloadmore,
|
|
53
|
-
loadMoreLabel = 'Load more'
|
|
56
|
+
loadMoreLabel = 'Load more',
|
|
57
|
+
responsive = 'scroll',
|
|
58
|
+
stackBelow = '48rem'
|
|
54
59
|
}: {
|
|
55
60
|
columns: Column<T>[];
|
|
56
61
|
rows: T[];
|
|
@@ -82,8 +87,38 @@
|
|
|
82
87
|
/** Renders a footer "load more" button that calls this. */
|
|
83
88
|
onloadmore?: () => void;
|
|
84
89
|
loadMoreLabel?: string;
|
|
90
|
+
/** `stack` renders each row as a card once the table's own box is narrower
|
|
91
|
+
* than `stackBelow`; `scroll` (default) keeps a horizontal scroller. */
|
|
92
|
+
responsive?: 'scroll' | 'stack';
|
|
93
|
+
/** Width (px/em/rem) under which `stack` mode kicks in. */
|
|
94
|
+
stackBelow?: string;
|
|
85
95
|
} = $props();
|
|
86
96
|
|
|
97
|
+
let wrapEl = $state<HTMLDivElement | null>(null);
|
|
98
|
+
let stacked = $state(false);
|
|
99
|
+
|
|
100
|
+
function toPx(length: string, el: HTMLElement): number {
|
|
101
|
+
const n = Number.parseFloat(length);
|
|
102
|
+
const fontSize = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize);
|
|
103
|
+
if (length.endsWith('rem')) return n * fontSize(document.documentElement);
|
|
104
|
+
if (length.endsWith('em')) return n * fontSize(el);
|
|
105
|
+
return n;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
$effect(() => {
|
|
109
|
+
if (responsive !== 'stack' || !wrapEl) {
|
|
110
|
+
stacked = false;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const el = wrapEl;
|
|
114
|
+
const limit = toPx(stackBelow, el);
|
|
115
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
116
|
+
stacked = entry.contentRect.width < limit;
|
|
117
|
+
});
|
|
118
|
+
observer.observe(el);
|
|
119
|
+
return () => observer.disconnect();
|
|
120
|
+
});
|
|
121
|
+
|
|
87
122
|
let sortKey = $state<string | null>(null);
|
|
88
123
|
let sortDir = $state<'asc' | 'desc'>('asc');
|
|
89
124
|
|
|
@@ -121,7 +156,15 @@
|
|
|
121
156
|
});
|
|
122
157
|
</script>
|
|
123
158
|
|
|
124
|
-
<div
|
|
159
|
+
<div
|
|
160
|
+
bind:this={wrapEl}
|
|
161
|
+
class="dt-scroll"
|
|
162
|
+
class:dt-stack={responsive === 'stack'}
|
|
163
|
+
data-tsu="DataTable"
|
|
164
|
+
data-size={size}
|
|
165
|
+
data-stacked={stacked || undefined}
|
|
166
|
+
aria-busy={loading || undefined}
|
|
167
|
+
>
|
|
125
168
|
<table
|
|
126
169
|
class="dt"
|
|
127
170
|
class:sticky={stickyHeader}
|
|
@@ -199,9 +242,11 @@
|
|
|
199
242
|
<td
|
|
200
243
|
data-part="cell"
|
|
201
244
|
data-hide-below={col.hideBelow}
|
|
245
|
+
data-role={responsive === 'stack' ? (col.role ?? 'detail') : undefined}
|
|
246
|
+
data-label={responsive === 'stack' ? col.label : undefined}
|
|
202
247
|
class:truncate={col.truncate}
|
|
203
248
|
class:nowrap={col.nowrap}
|
|
204
|
-
style:text-align={col.align ?? 'left'}
|
|
249
|
+
style:text-align={stacked ? undefined : (col.align ?? 'left')}
|
|
205
250
|
>
|
|
206
251
|
{#if cellSnippets[col.key]}
|
|
207
252
|
{@render cellSnippets[col.key](row)}
|
|
@@ -389,6 +434,91 @@
|
|
|
389
434
|
display: none;
|
|
390
435
|
}
|
|
391
436
|
}
|
|
437
|
+
/* Stacked cards: the <table> stays a table for AT, but every row lays out as
|
|
438
|
+
a wrapping flex row (title | actions, detail lines, trailing meta cluster).
|
|
439
|
+
The header is clipped, not removed. */
|
|
440
|
+
[data-stacked] {
|
|
441
|
+
overflow-x: visible;
|
|
442
|
+
}
|
|
443
|
+
[data-stacked] thead th {
|
|
444
|
+
position: absolute;
|
|
445
|
+
width: 1px;
|
|
446
|
+
height: 1px;
|
|
447
|
+
padding: 0;
|
|
448
|
+
margin: -1px;
|
|
449
|
+
overflow: hidden;
|
|
450
|
+
clip: rect(0, 0, 0, 0);
|
|
451
|
+
white-space: nowrap;
|
|
452
|
+
border: 0;
|
|
453
|
+
}
|
|
454
|
+
[data-stacked] tbody,
|
|
455
|
+
[data-stacked] tfoot,
|
|
456
|
+
[data-stacked] td {
|
|
457
|
+
display: block;
|
|
458
|
+
}
|
|
459
|
+
[data-stacked] tr {
|
|
460
|
+
display: flex;
|
|
461
|
+
flex-wrap: wrap;
|
|
462
|
+
align-items: baseline;
|
|
463
|
+
gap: var(--sp-1) var(--sp-3);
|
|
464
|
+
padding: var(--sp-3);
|
|
465
|
+
border-bottom: 1px solid var(--border);
|
|
466
|
+
}
|
|
467
|
+
[data-stacked] tbody tr:last-child {
|
|
468
|
+
border-bottom: none;
|
|
469
|
+
}
|
|
470
|
+
[data-stacked] td {
|
|
471
|
+
flex: 0 0 100%;
|
|
472
|
+
min-width: 0;
|
|
473
|
+
padding: 0;
|
|
474
|
+
border: 0;
|
|
475
|
+
}
|
|
476
|
+
[data-stacked] td.truncate {
|
|
477
|
+
max-width: none;
|
|
478
|
+
}
|
|
479
|
+
[data-stacked] tr.toned td:first-child {
|
|
480
|
+
box-shadow: none;
|
|
481
|
+
}
|
|
482
|
+
[data-stacked] tr.toned {
|
|
483
|
+
box-shadow: inset 3px 0 0 var(--dt-tone);
|
|
484
|
+
}
|
|
485
|
+
[data-stacked] td[data-role='title'] {
|
|
486
|
+
flex: 1 1 0;
|
|
487
|
+
order: -2;
|
|
488
|
+
font-weight: var(--fw-semibold);
|
|
489
|
+
font-size: var(--fs-md);
|
|
490
|
+
color: var(--text);
|
|
491
|
+
}
|
|
492
|
+
[data-stacked] td[data-role='detail']::before {
|
|
493
|
+
content: attr(data-label) ': ';
|
|
494
|
+
color: var(--text-muted);
|
|
495
|
+
}
|
|
496
|
+
[data-stacked] td[data-role='meta'] {
|
|
497
|
+
flex: none;
|
|
498
|
+
order: 1;
|
|
499
|
+
font-family: var(--font-mono);
|
|
500
|
+
font-size: var(--fs-xs);
|
|
501
|
+
color: var(--text-muted);
|
|
502
|
+
}
|
|
503
|
+
[data-stacked] td[data-role='meta']::before {
|
|
504
|
+
content: attr(data-label) ' ';
|
|
505
|
+
color: var(--text-faint);
|
|
506
|
+
}
|
|
507
|
+
[data-stacked] td[data-role='actions'],
|
|
508
|
+
[data-stacked] td.dt-actions {
|
|
509
|
+
flex: none;
|
|
510
|
+
order: -1;
|
|
511
|
+
margin-inline-start: auto;
|
|
512
|
+
width: auto;
|
|
513
|
+
opacity: 1;
|
|
514
|
+
}
|
|
515
|
+
[data-stacked] td[data-role='hidden'] {
|
|
516
|
+
display: none;
|
|
517
|
+
}
|
|
518
|
+
[data-stacked] td.dt-empty,
|
|
519
|
+
[data-stacked] td.dt-more {
|
|
520
|
+
text-align: center;
|
|
521
|
+
}
|
|
392
522
|
.dt-empty {
|
|
393
523
|
text-align: center;
|
|
394
524
|
color: var(--text-faint);
|
|
@@ -16,6 +16,9 @@ export interface Column<T> {
|
|
|
16
16
|
/** Drop the column when the table's own box is narrower than the breakpoint
|
|
17
17
|
* (sm 30rem, md 48rem, lg 64rem). */
|
|
18
18
|
hideBelow?: 'sm' | 'md' | 'lg';
|
|
19
|
+
/** Placement inside the stacked card (`responsive="stack"` only). Columns
|
|
20
|
+
* without a role render as `label: value` detail lines. */
|
|
21
|
+
role?: 'title' | 'detail' | 'meta' | 'actions' | 'hidden';
|
|
19
22
|
}
|
|
20
23
|
import type { Snippet } from 'svelte';
|
|
21
24
|
declare function $$render<T>(): {
|
|
@@ -50,6 +53,11 @@ declare function $$render<T>(): {
|
|
|
50
53
|
/** Renders a footer "load more" button that calls this. */
|
|
51
54
|
onloadmore?: () => void;
|
|
52
55
|
loadMoreLabel?: string;
|
|
56
|
+
/** `stack` renders each row as a card once the table's own box is narrower
|
|
57
|
+
* than `stackBelow`; `scroll` (default) keeps a horizontal scroller. */
|
|
58
|
+
responsive?: "scroll" | "stack";
|
|
59
|
+
/** Width (px/em/rem) under which `stack` mode kicks in. */
|
|
60
|
+
stackBelow?: string;
|
|
53
61
|
};
|
|
54
62
|
exports: {};
|
|
55
63
|
bindings: "";
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
placeholder = 'artist:"Daft Punk" AND year>=2000',
|
|
25
25
|
showChips = true,
|
|
26
26
|
autoQuote = true,
|
|
27
|
+
size = 'md',
|
|
28
|
+
shape = 'square',
|
|
29
|
+
surface = 'base',
|
|
30
|
+
hotkey,
|
|
31
|
+
showHotkey = false,
|
|
32
|
+
grow = false,
|
|
27
33
|
onchange,
|
|
28
34
|
onsubmit
|
|
29
35
|
}: {
|
|
@@ -39,6 +45,13 @@
|
|
|
39
45
|
* the quotes. Set false for bare typing where spaces split the value.
|
|
40
46
|
*/
|
|
41
47
|
autoQuote?: boolean;
|
|
48
|
+
/** Forwarded to FilterInput. */
|
|
49
|
+
size?: 'sm' | 'md';
|
|
50
|
+
shape?: 'square' | 'pill';
|
|
51
|
+
surface?: 'base' | 'raised' | 'sunken';
|
|
52
|
+
hotkey?: string;
|
|
53
|
+
showHotkey?: boolean;
|
|
54
|
+
grow?: boolean;
|
|
42
55
|
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
43
56
|
onchange?: (query: Query, raw: string) => void;
|
|
44
57
|
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
@@ -50,7 +63,20 @@
|
|
|
50
63
|
}
|
|
51
64
|
</script>
|
|
52
65
|
|
|
53
|
-
<FilterInput
|
|
66
|
+
<FilterInput
|
|
67
|
+
{schema}
|
|
68
|
+
bind:value
|
|
69
|
+
{placeholder}
|
|
70
|
+
{autoQuote}
|
|
71
|
+
{size}
|
|
72
|
+
{shape}
|
|
73
|
+
{surface}
|
|
74
|
+
{hotkey}
|
|
75
|
+
{showHotkey}
|
|
76
|
+
{grow}
|
|
77
|
+
{onchange}
|
|
78
|
+
{onsubmit}
|
|
79
|
+
>
|
|
54
80
|
{#snippet below({ filters: chips, text, remove })}
|
|
55
81
|
{#if showChips && (chips.length || text)}
|
|
56
82
|
<div class="fsb__chips">
|
|
@@ -13,6 +13,13 @@ type $$ComponentProps = {
|
|
|
13
13
|
* the quotes. Set false for bare typing where spaces split the value.
|
|
14
14
|
*/
|
|
15
15
|
autoQuote?: boolean;
|
|
16
|
+
/** Forwarded to FilterInput. */
|
|
17
|
+
size?: 'sm' | 'md';
|
|
18
|
+
shape?: 'square' | 'pill';
|
|
19
|
+
surface?: 'base' | 'raised' | 'sunken';
|
|
20
|
+
hotkey?: string;
|
|
21
|
+
showHotkey?: boolean;
|
|
22
|
+
grow?: boolean;
|
|
16
23
|
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
17
24
|
onchange?: (query: Query, raw: string) => void;
|
|
18
25
|
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
package/package.json
CHANGED