@dorsk/tsumikit 0.25.0 → 0.27.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 +33 -5
- package/dist/artwork.d.ts +7 -0
- package/dist/artwork.js +28 -0
- package/dist/components/atoms/Artwork.svelte +152 -0
- package/dist/components/atoms/Artwork.svelte.d.ts +27 -0
- package/dist/components/atoms/Icon.svelte +5 -1
- package/dist/components/atoms/Icon.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +124 -12
- package/dist/components/atoms/Input.svelte.d.ts +12 -0
- package/dist/components/layouts/NavItem.svelte +30 -3
- package/dist/components/layouts/NavItem.svelte.d.ts +9 -1
- 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/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,15 +144,19 @@ 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
|
|
148
|
-
|
|
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,
|
|
151
|
+
Slider, Progress, Artwork (lazy cover image with seeded gradient + initials
|
|
152
|
+
fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
|
|
149
153
|
(`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
|
|
150
154
|
`icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
|
|
151
155
|
`--badge-tone`), Dot (`ring` dark halo over artwork), Link (`tone`, `underline`
|
|
152
156
|
always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
|
|
153
157
|
any custom SVG).
|
|
154
158
|
|
|
155
|
-
**Molecules:** Field, IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
159
|
+
**Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
156
160
|
Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
|
|
157
161
|
Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
|
|
158
162
|
ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore.
|
|
@@ -165,7 +169,13 @@ paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
|
|
|
165
169
|
for a hover/focus-revealed trailing cell (always visible on touch),
|
|
166
170
|
`stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
|
|
167
171
|
`onloadmore` footer button, `empty` as string or snippet; `data-part`
|
|
168
|
-
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`).
|
|
169
179
|
|
|
170
180
|
**Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
|
|
171
181
|
sidebar on desktop, overlay drawer on mobile, optionally resizable;
|
|
@@ -174,7 +184,9 @@ the content column only, `stickySidebar` pins it to the viewport, and
|
|
|
174
184
|
`sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
|
|
175
185
|
children are `min-width: 0` so a wide title/actions row can't widen the grid on
|
|
176
186
|
mobile), NavItem
|
|
177
|
-
(collapses to an icon rail when the sidebar is narrow
|
|
187
|
+
(collapses to an icon rail when the sidebar is narrow; `icon` from the
|
|
188
|
+
registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
|
|
189
|
+
for the inset-bar active look), Container, Stack
|
|
178
190
|
(vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
|
|
179
191
|
query container and stacks children full-width below 18/30/40/48rem — phone
|
|
180
192
|
action rows without a viewport query), AutoGrid (intrinsically responsive
|
|
@@ -321,6 +333,22 @@ error | done, `onload`, `label`, `loadingLabel`, `errorLabel`, `retryLabel`,
|
|
|
321
333
|
</SectionHeader>
|
|
322
334
|
```
|
|
323
335
|
|
|
336
|
+
### Artwork
|
|
337
|
+
|
|
338
|
+
`Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
|
|
339
|
+
lazy, async-decoded `<img>`; a missing or failing source swaps to a gradient
|
|
340
|
+
seeded from `seed` (default `alt`) with `fallback` initials | icon | none.
|
|
341
|
+
`aspect` ('1/1', '2/3', '16/9' or any ratio), `size` (width), `radius`
|
|
342
|
+
sm | md | lg | pill, `fit` cover | contain, `hover` for tappable tiles,
|
|
343
|
+
`status` snippet overlaid bottom-right, `onerror`. `artworkGradient(seed)`,
|
|
344
|
+
`artworkHue(seed)` and `initials(text)` are exported for non-component use.
|
|
345
|
+
|
|
346
|
+
```svelte
|
|
347
|
+
<Artwork src={album.cover} alt={album.title} aspect="1/1" size="9rem" hover>
|
|
348
|
+
{#snippet status()}<Dot status="active" ring />{/snippet}
|
|
349
|
+
</Artwork>
|
|
350
|
+
```
|
|
351
|
+
|
|
324
352
|
## Container queries
|
|
325
353
|
|
|
326
354
|
AppShell's `main` and `sidebar` are query containers (`container-name: main` /
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Hue (0–359) derived from `seed`; stable across runs and environments. */
|
|
2
|
+
export declare function artworkHue(seed: string): number;
|
|
3
|
+
/** CSS `linear-gradient(...)` for the artwork fallback, keyed on `seed`. */
|
|
4
|
+
export declare function artworkGradient(seed: string): string;
|
|
5
|
+
/** Up to two uppercase initials: first letters of the first two words, or the
|
|
6
|
+
* first two characters of a single word. Empty input yields ''. */
|
|
7
|
+
export declare function initials(text: string): string;
|
package/dist/artwork.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Placeholder look for missing artwork: a deterministic hue from `seed` so the
|
|
2
|
+
// same album/show always gets the same tint, tinted over the surface tokens so
|
|
3
|
+
// the fallback tracks the active theme instead of being a fixed dark gradient.
|
|
4
|
+
function hash(text) {
|
|
5
|
+
let h = 0;
|
|
6
|
+
for (const ch of text) {
|
|
7
|
+
h = (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
|
|
8
|
+
}
|
|
9
|
+
return h;
|
|
10
|
+
}
|
|
11
|
+
/** Hue (0–359) derived from `seed`; stable across runs and environments. */
|
|
12
|
+
export function artworkHue(seed) {
|
|
13
|
+
return hash(seed) % 360;
|
|
14
|
+
}
|
|
15
|
+
/** CSS `linear-gradient(...)` for the artwork fallback, keyed on `seed`. */
|
|
16
|
+
export function artworkGradient(seed) {
|
|
17
|
+
const h = artworkHue(seed);
|
|
18
|
+
return `linear-gradient(160deg, color-mix(in srgb, hsl(${h} 60% 55%) 28%, var(--bg-elevated-2)), color-mix(in srgb, hsl(${h} 60% 40%) 16%, var(--bg-elevated)))`;
|
|
19
|
+
}
|
|
20
|
+
/** Up to two uppercase initials: first letters of the first two words, or the
|
|
21
|
+
* first two characters of a single word. Empty input yields ''. */
|
|
22
|
+
export function initials(text) {
|
|
23
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
24
|
+
if (words.length === 0)
|
|
25
|
+
return '';
|
|
26
|
+
const picked = words.length === 1 ? [...words[0]].slice(0, 2) : words.slice(0, 2).map((w) => [...w][0]);
|
|
27
|
+
return picked.join('').toUpperCase();
|
|
28
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Cover / thumbnail tile: a lazy <img> that swaps to a seeded gradient with
|
|
3
|
+
// initials (or an icon) when there is no source or it fails to load. Width
|
|
4
|
+
// comes from `size`, height from `aspect`; `status` overlays a Dot/Badge in
|
|
5
|
+
// the bottom-right corner.
|
|
6
|
+
import type { Snippet } from 'svelte';
|
|
7
|
+
import { artworkGradient, initials as initialsOf } from '../../artwork';
|
|
8
|
+
import Icon from './Icon.svelte';
|
|
9
|
+
import type { IconName } from './Icon.svelte';
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
src,
|
|
13
|
+
alt,
|
|
14
|
+
seed,
|
|
15
|
+
aspect = '1/1',
|
|
16
|
+
size,
|
|
17
|
+
radius = 'md',
|
|
18
|
+
fit = 'cover',
|
|
19
|
+
fallback = 'initials',
|
|
20
|
+
icon = 'image',
|
|
21
|
+
status,
|
|
22
|
+
hover = false,
|
|
23
|
+
onerror,
|
|
24
|
+
class: klass = '',
|
|
25
|
+
...rest
|
|
26
|
+
}: {
|
|
27
|
+
src?: string | null;
|
|
28
|
+
alt: string;
|
|
29
|
+
/** Drives the gradient hue and the initials. Defaults to `alt`. */
|
|
30
|
+
seed?: string;
|
|
31
|
+
/** CSS aspect-ratio, e.g. '1/1', '2/3', '16/9'. */
|
|
32
|
+
aspect?: '1/1' | '2/3' | '16/9' | (string & {});
|
|
33
|
+
/** Width (any CSS length). Omit to fill the container. */
|
|
34
|
+
size?: string;
|
|
35
|
+
radius?: 'sm' | 'md' | 'lg' | 'pill';
|
|
36
|
+
fit?: 'cover' | 'contain';
|
|
37
|
+
fallback?: 'initials' | 'icon' | 'none';
|
|
38
|
+
/** Glyph shown when `fallback="icon"`. */
|
|
39
|
+
icon?: IconName;
|
|
40
|
+
/** Overlay rendered bottom-right (a Dot or Badge). */
|
|
41
|
+
status?: Snippet;
|
|
42
|
+
/** Stronger border + shadow on hover, for tappable tiles. */
|
|
43
|
+
hover?: boolean;
|
|
44
|
+
onerror?: () => void;
|
|
45
|
+
class?: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
} = $props();
|
|
48
|
+
|
|
49
|
+
let failed = $state(false);
|
|
50
|
+
$effect(() => {
|
|
51
|
+
src;
|
|
52
|
+
failed = false;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const key = $derived(seed ?? alt);
|
|
56
|
+
const showImage = $derived(Boolean(src) && !failed);
|
|
57
|
+
const gradient = $derived(showImage ? undefined : artworkGradient(key));
|
|
58
|
+
const text = $derived(fallback === 'initials' ? initialsOf(key) : '');
|
|
59
|
+
|
|
60
|
+
function fail() {
|
|
61
|
+
failed = true;
|
|
62
|
+
onerror?.();
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<div
|
|
67
|
+
data-tsu="Artwork"
|
|
68
|
+
class="artwork r-{radius} {klass}"
|
|
69
|
+
class:hover
|
|
70
|
+
style:aspect-ratio={aspect}
|
|
71
|
+
style:width={size}
|
|
72
|
+
style:background={gradient}
|
|
73
|
+
role={showImage ? undefined : 'img'}
|
|
74
|
+
aria-label={showImage ? undefined : alt}
|
|
75
|
+
{...rest}
|
|
76
|
+
>
|
|
77
|
+
{#if showImage}
|
|
78
|
+
<img {src} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
|
|
79
|
+
{:else if fallback === 'initials' && text}
|
|
80
|
+
<span class="initials" aria-hidden="true">{text}</span>
|
|
81
|
+
{:else if fallback === 'icon'}
|
|
82
|
+
<span class="glyph" aria-hidden="true"><Icon name={icon} /></span>
|
|
83
|
+
{/if}
|
|
84
|
+
{#if status}
|
|
85
|
+
<span class="status">{@render status()}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.artwork {
|
|
91
|
+
position: relative;
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
justify-content: center;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
flex: none;
|
|
97
|
+
background: var(--bg-elevated-2);
|
|
98
|
+
border: 1px solid var(--border);
|
|
99
|
+
color: var(--text-muted);
|
|
100
|
+
container-type: inline-size;
|
|
101
|
+
transition:
|
|
102
|
+
border-color 0.12s var(--ease),
|
|
103
|
+
box-shadow 0.12s var(--ease);
|
|
104
|
+
}
|
|
105
|
+
.r-sm {
|
|
106
|
+
border-radius: var(--r-sm);
|
|
107
|
+
}
|
|
108
|
+
.r-md {
|
|
109
|
+
border-radius: var(--r-md);
|
|
110
|
+
}
|
|
111
|
+
.r-lg {
|
|
112
|
+
border-radius: var(--r-lg);
|
|
113
|
+
}
|
|
114
|
+
.r-pill {
|
|
115
|
+
border-radius: var(--r-pill);
|
|
116
|
+
}
|
|
117
|
+
.hover:hover {
|
|
118
|
+
border-color: var(--border-strong);
|
|
119
|
+
box-shadow: var(--shadow-md);
|
|
120
|
+
}
|
|
121
|
+
.img {
|
|
122
|
+
position: absolute;
|
|
123
|
+
inset: 0;
|
|
124
|
+
width: 100%;
|
|
125
|
+
height: 100%;
|
|
126
|
+
display: block;
|
|
127
|
+
}
|
|
128
|
+
.fit-cover {
|
|
129
|
+
object-fit: cover;
|
|
130
|
+
}
|
|
131
|
+
.fit-contain {
|
|
132
|
+
object-fit: contain;
|
|
133
|
+
}
|
|
134
|
+
.initials {
|
|
135
|
+
font-size: 28cqw;
|
|
136
|
+
font-weight: var(--fw-semibold);
|
|
137
|
+
letter-spacing: 0.04em;
|
|
138
|
+
color: color-mix(in srgb, var(--text) 55%, transparent);
|
|
139
|
+
user-select: none;
|
|
140
|
+
}
|
|
141
|
+
.glyph {
|
|
142
|
+
font-size: 30cqw;
|
|
143
|
+
display: flex;
|
|
144
|
+
color: color-mix(in srgb, var(--text) 40%, transparent);
|
|
145
|
+
}
|
|
146
|
+
.status {
|
|
147
|
+
position: absolute;
|
|
148
|
+
right: var(--sp-2);
|
|
149
|
+
bottom: var(--sp-2);
|
|
150
|
+
display: flex;
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { IconName } from './Icon.svelte';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
src?: string | null;
|
|
5
|
+
alt: string;
|
|
6
|
+
/** Drives the gradient hue and the initials. Defaults to `alt`. */
|
|
7
|
+
seed?: string;
|
|
8
|
+
/** CSS aspect-ratio, e.g. '1/1', '2/3', '16/9'. */
|
|
9
|
+
aspect?: '1/1' | '2/3' | '16/9' | (string & {});
|
|
10
|
+
/** Width (any CSS length). Omit to fill the container. */
|
|
11
|
+
size?: string;
|
|
12
|
+
radius?: 'sm' | 'md' | 'lg' | 'pill';
|
|
13
|
+
fit?: 'cover' | 'contain';
|
|
14
|
+
fallback?: 'initials' | 'icon' | 'none';
|
|
15
|
+
/** Glyph shown when `fallback="icon"`. */
|
|
16
|
+
icon?: IconName;
|
|
17
|
+
/** Overlay rendered bottom-right (a Dot or Badge). */
|
|
18
|
+
status?: Snippet;
|
|
19
|
+
/** Stronger border + shadow on hover, for tappable tiles. */
|
|
20
|
+
hover?: boolean;
|
|
21
|
+
onerror?: () => void;
|
|
22
|
+
class?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
};
|
|
25
|
+
declare const Artwork: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
26
|
+
type Artwork = ReturnType<typeof Artwork>;
|
|
27
|
+
export default Artwork;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// e.g. a lucide-svelte component's contents — without adding it to the
|
|
6
6
|
// registry. Sized at 1em so it tracks the surrounding text (and the
|
|
7
7
|
// --fs-scale font control).
|
|
8
|
-
const
|
|
8
|
+
const GLYPHS = {
|
|
9
9
|
// — navigation / chevrons —
|
|
10
10
|
back: '<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />',
|
|
11
11
|
'arrow-right': '<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />',
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />',
|
|
56
56
|
archive: '<rect width="20" height="5" x="2" y="3" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" /><path d="M10 12h4" />',
|
|
57
57
|
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />',
|
|
58
|
+
book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />',
|
|
58
59
|
markdown: '<rect x="3" y="5" width="18" height="14" rx="2" /><path d="M7 15V9l3 3 3-3v6" /><path d="m15 11 2 2 2-2" />',
|
|
59
60
|
list: '<path d="M3 5h.01" /><path d="M3 12h.01" /><path d="M3 19h.01" /><path d="M8 5h13" /><path d="M8 12h13" /><path d="M8 19h13" />',
|
|
60
61
|
grid: '<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" />',
|
|
@@ -93,6 +94,9 @@
|
|
|
93
94
|
'alert-circle': '<circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" />',
|
|
94
95
|
} as const;
|
|
95
96
|
|
|
97
|
+
// Alternate names resolving to the same path data.
|
|
98
|
+
const ICONS = { ...GLYPHS, alert: GLYPHS.warning } as const;
|
|
99
|
+
|
|
96
100
|
export type IconName = keyof typeof ICONS;
|
|
97
101
|
|
|
98
102
|
/** True when `s` is a registered glyph name (vs. e.g. a raw emoji string). */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
declare const ICONS: {
|
|
2
|
+
readonly alert: "<path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\" /><path d=\"M12 9v4\" /><path d=\"M12 17h.01\" />";
|
|
2
3
|
readonly back: "<path d=\"m12 19-7-7 7-7\" /><path d=\"M19 12H5\" />";
|
|
3
4
|
readonly 'arrow-right': "<path d=\"M5 12h14\" /><path d=\"m12 5 7 7-7 7\" />";
|
|
4
5
|
readonly 'arrow-up': "<path d=\"m5 12 7-7 7 7\" /><path d=\"M12 19V5\" />";
|
|
@@ -39,6 +40,7 @@ declare const ICONS: {
|
|
|
39
40
|
readonly folder: "<path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\" />";
|
|
40
41
|
readonly archive: "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\" /><path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\" /><path d=\"M10 12h4\" />";
|
|
41
42
|
readonly image: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" /><circle cx=\"9\" cy=\"9\" r=\"2\" /><path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />";
|
|
43
|
+
readonly book: "<path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\" />";
|
|
42
44
|
readonly markdown: "<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\" /><path d=\"M7 15V9l3 3 3-3v6\" /><path d=\"m15 11 2 2 2-2\" />";
|
|
43
45
|
readonly list: "<path d=\"M3 5h.01\" /><path d=\"M3 12h.01\" /><path d=\"M3 19h.01\" /><path d=\"M8 5h13\" /><path d=\"M8 12h13\" /><path d=\"M8 19h13\" />";
|
|
44
46
|
readonly grid: "<rect width=\"7\" height=\"7\" x=\"3\" y=\"3\" rx=\"1\" /><rect width=\"7\" height=\"7\" x=\"14\" y=\"3\" rx=\"1\" /><rect width=\"7\" height=\"7\" x=\"14\" y=\"14\" rx=\"1\" /><rect width=\"7\" height=\"7\" x=\"3\" y=\"14\" rx=\"1\" />";
|
|
@@ -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;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// collapses to a small dot indicator so the count cue survives the rail.
|
|
7
7
|
// `title`/`aria-label` carry the name so the icon-only state stays accessible
|
|
8
8
|
// and shows a tooltip. Renders an <a> when `href` is set, else a <button>.
|
|
9
|
+
// The glyph is `iconChildren` (raw 24×24 SVG content), else `iconPath`
|
|
10
|
+
// (single path d), else the registry `icon`.
|
|
9
11
|
import type { Snippet } from 'svelte';
|
|
10
12
|
import Icon from '../atoms/Icon.svelte';
|
|
11
13
|
import type { IconName } from '../atoms/Icon.svelte';
|
|
@@ -15,19 +17,31 @@
|
|
|
15
17
|
|
|
16
18
|
let {
|
|
17
19
|
icon,
|
|
20
|
+
iconChildren,
|
|
21
|
+
iconPath,
|
|
22
|
+
iconSize = 18,
|
|
18
23
|
label,
|
|
19
24
|
href,
|
|
20
25
|
active = false,
|
|
26
|
+
activeStyle = 'fill',
|
|
21
27
|
onclick,
|
|
22
28
|
badge,
|
|
23
29
|
badgeTone = 'neutral',
|
|
24
30
|
children,
|
|
25
31
|
...rest
|
|
26
32
|
}: {
|
|
27
|
-
icon
|
|
33
|
+
icon?: IconName;
|
|
34
|
+
/** Raw 24×24 SVG content, for glyphs outside the registry. Wins over `iconPath` and `icon`. */
|
|
35
|
+
iconChildren?: Snippet;
|
|
36
|
+
/** Single `<path d>` for a 24×24 outline glyph. Wins over `icon`. */
|
|
37
|
+
iconPath?: string;
|
|
38
|
+
iconSize?: number;
|
|
28
39
|
label: string;
|
|
29
40
|
href?: string;
|
|
30
41
|
active?: boolean;
|
|
42
|
+
/** 'fill' = accent-tinted pill with a rounded left marker; 'bar' = lighter
|
|
43
|
+
* tint with a flat 2px inset accent bar. */
|
|
44
|
+
activeStyle?: 'fill' | 'bar';
|
|
31
45
|
onclick?: (e: MouseEvent) => void;
|
|
32
46
|
/** Optional trailing count pill (e.g. unread / missing items). When the
|
|
33
47
|
* item is `active` it adopts the accent fill; otherwise it uses `badgeTone`. */
|
|
@@ -49,13 +63,20 @@
|
|
|
49
63
|
type={href ? undefined : 'button'}
|
|
50
64
|
class="nav-item"
|
|
51
65
|
class:active
|
|
66
|
+
class:bar={activeStyle === 'bar'}
|
|
52
67
|
title={label}
|
|
53
68
|
aria-label={label}
|
|
54
69
|
aria-current={active ? 'page' : undefined}
|
|
55
70
|
{onclick}
|
|
56
71
|
{...rest}
|
|
57
72
|
>
|
|
58
|
-
|
|
73
|
+
{#if iconChildren}
|
|
74
|
+
<Icon size={iconSize}>{@render iconChildren()}</Icon>
|
|
75
|
+
{:else if iconPath}
|
|
76
|
+
<Icon size={iconSize}><path d={iconPath} /></Icon>
|
|
77
|
+
{:else if icon}
|
|
78
|
+
<Icon name={icon} size={iconSize} />
|
|
79
|
+
{/if}
|
|
59
80
|
<span class="nav-label">{label}</span>
|
|
60
81
|
{#if hasBadge}
|
|
61
82
|
<span class="nav-trail">
|
|
@@ -101,7 +122,7 @@
|
|
|
101
122
|
}
|
|
102
123
|
/* Active-route accent inset: a left rail marker that survives the icon-rail
|
|
103
124
|
collapse (the tint background does too, but the marker reads at a glance). */
|
|
104
|
-
.nav-item.active::before {
|
|
125
|
+
.nav-item.active:not(.bar)::before {
|
|
105
126
|
content: '';
|
|
106
127
|
position: absolute;
|
|
107
128
|
left: 0;
|
|
@@ -112,6 +133,12 @@
|
|
|
112
133
|
border-radius: var(--r-pill);
|
|
113
134
|
background: var(--accent);
|
|
114
135
|
}
|
|
136
|
+
.nav-item.bar.active {
|
|
137
|
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
138
|
+
box-shadow: inset 2px 0 0 var(--accent);
|
|
139
|
+
border-start-start-radius: 0;
|
|
140
|
+
border-end-start-radius: 0;
|
|
141
|
+
}
|
|
115
142
|
.nav-label {
|
|
116
143
|
flex: 1;
|
|
117
144
|
min-width: 0; /* allow the label to shrink/ellipsis instead of overflowing */
|
|
@@ -2,10 +2,18 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
import type { IconName } from '../atoms/Icon.svelte';
|
|
3
3
|
type BadgeTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
4
4
|
type $$ComponentProps = {
|
|
5
|
-
icon
|
|
5
|
+
icon?: IconName;
|
|
6
|
+
/** Raw 24×24 SVG content, for glyphs outside the registry. Wins over `iconPath` and `icon`. */
|
|
7
|
+
iconChildren?: Snippet;
|
|
8
|
+
/** Single `<path d>` for a 24×24 outline glyph. Wins over `icon`. */
|
|
9
|
+
iconPath?: string;
|
|
10
|
+
iconSize?: number;
|
|
6
11
|
label: string;
|
|
7
12
|
href?: string;
|
|
8
13
|
active?: boolean;
|
|
14
|
+
/** 'fill' = accent-tinted pill with a rounded left marker; 'bar' = lighter
|
|
15
|
+
* tint with a flat 2px inset accent bar. */
|
|
16
|
+
activeStyle?: 'fill' | 'bar';
|
|
9
17
|
onclick?: (e: MouseEvent) => void;
|
|
10
18
|
/** Optional trailing count pill (e.g. unread / missing items). When the
|
|
11
19
|
* item is `active` it adopts the accent fill; otherwise it uses `badgeTone`. */
|
|
@@ -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/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export { artworkGradient, artworkHue, initials } from './artwork';
|
|
1
2
|
export { autoresize } from './autoresize';
|
|
2
3
|
export { copyToClipboard } from './clipboard';
|
|
4
|
+
export { default as Artwork } from './components/atoms/Artwork.svelte';
|
|
3
5
|
export { default as Badge } from './components/atoms/Badge.svelte';
|
|
4
6
|
export { default as Button } from './components/atoms/Button.svelte';
|
|
5
7
|
export { default as Card } from './components/atoms/Card.svelte';
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// your app root:
|
|
5
5
|
// import '@dorsk/tsumikit/styles/app.css';
|
|
6
6
|
// then use the components below.
|
|
7
|
+
export { artworkGradient, artworkHue, initials } from './artwork';
|
|
7
8
|
export { autoresize } from './autoresize';
|
|
8
9
|
export { copyToClipboard } from './clipboard';
|
|
10
|
+
export { default as Artwork } from './components/atoms/Artwork.svelte';
|
|
9
11
|
export { default as Badge } from './components/atoms/Badge.svelte';
|
|
10
12
|
export { default as Button } from './components/atoms/Button.svelte';
|
|
11
13
|
export { default as Card } from './components/atoms/Card.svelte';
|
package/package.json
CHANGED