@dorsk/tsumikit 0.1.0 → 0.2.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 +4 -2
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +30 -0
- package/dist/components/atoms/Badge.svelte +65 -3
- package/dist/components/atoms/Badge.svelte.d.ts +4 -0
- package/dist/components/atoms/Button.svelte +80 -8
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/atoms/Checkbox.svelte +7 -1
- package/dist/components/atoms/Checkbox.svelte.d.ts +2 -0
- package/dist/components/atoms/Dot.svelte +67 -0
- package/dist/components/atoms/Dot.svelte.d.ts +12 -0
- package/dist/components/atoms/Input.svelte +28 -2
- package/dist/components/atoms/Input.svelte.d.ts +5 -1
- package/dist/components/atoms/Select.svelte +9 -2
- package/dist/components/atoms/Select.svelte.d.ts +2 -0
- package/dist/components/atoms/Slider.svelte +5 -4
- package/dist/components/atoms/Switch.svelte +6 -1
- package/dist/components/atoms/Switch.svelte.d.ts +1 -0
- package/dist/components/atoms/Textarea.svelte +26 -1
- package/dist/components/atoms/Textarea.svelte.d.ts +4 -0
- package/dist/components/layouts/AppShell.svelte +15 -8
- package/dist/components/molecules/Accordion.svelte +6 -3
- package/dist/components/molecules/CopyButton.svelte +2 -26
- package/dist/components/molecules/FileButton.svelte +45 -3
- package/dist/components/molecules/IconButton.svelte +23 -3
- package/dist/components/molecules/IconButton.svelte.d.ts +2 -0
- package/dist/components/molecules/Modal.svelte +15 -4
- package/dist/components/molecules/Modal.svelte.d.ts +3 -0
- package/dist/components/molecules/OptionButton.svelte +30 -20
- package/dist/components/molecules/Popover.svelte +46 -25
- package/dist/components/molecules/Popover.svelte.d.ts +7 -2
- package/dist/components/molecules/SelectButton.svelte +20 -16
- package/dist/components/molecules/Tabs.svelte +26 -7
- package/dist/components/molecules/Tabs.svelte.d.ts +2 -0
- package/dist/components/molecules/Toggle.svelte +30 -15
- package/dist/components/molecules/Tooltip.svelte +41 -28
- package/dist/components/molecules/Tooltip.svelte.d.ts +1 -1
- package/dist/components/organisms/DataTable.svelte +85 -4
- package/dist/components/organisms/DataTable.svelte.d.ts +6 -0
- package/dist/floating.d.ts +10 -0
- package/dist/floating.js +56 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/styles/app.css +14 -234
- package/dist/styles/variables.css +1 -1
- package/package.json +4 -3
- package/dist/components/atoms/Chip.svelte +0 -53
- package/dist/components/atoms/Chip.svelte.d.ts +0 -11
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
type Props = HTMLTextareaAttributes & {
|
|
12
12
|
mono?: boolean;
|
|
13
13
|
autoresize?: boolean;
|
|
14
|
+
size?: 'sm' | 'md';
|
|
15
|
+
/** Error state: danger border + aria-invalid (also styles if a consumer
|
|
16
|
+
* sets aria-invalid directly). */
|
|
17
|
+
invalid?: boolean;
|
|
14
18
|
class?: string;
|
|
15
19
|
value?: HTMLTextareaAttributes['value'];
|
|
16
20
|
el?: HTMLTextAreaElement | null;
|
|
@@ -19,6 +23,8 @@
|
|
|
19
23
|
let {
|
|
20
24
|
mono = false,
|
|
21
25
|
autoresize = false,
|
|
26
|
+
size = 'md',
|
|
27
|
+
invalid = false,
|
|
22
28
|
class: klass = '',
|
|
23
29
|
value = $bindable(),
|
|
24
30
|
el = $bindable(null),
|
|
@@ -31,12 +37,22 @@
|
|
|
31
37
|
bind:this={el}
|
|
32
38
|
class="textarea {klass}"
|
|
33
39
|
class:mono
|
|
40
|
+
class:textarea-sm={size === 'sm'}
|
|
34
41
|
bind:value
|
|
35
42
|
use:autoresizeAction={typeof value === 'string' ? value : ''}
|
|
36
43
|
{...rest}
|
|
44
|
+
aria-invalid={invalid || undefined}
|
|
37
45
|
></textarea>
|
|
38
46
|
{:else}
|
|
39
|
-
<textarea
|
|
47
|
+
<textarea
|
|
48
|
+
bind:this={el}
|
|
49
|
+
class="textarea {klass}"
|
|
50
|
+
class:mono
|
|
51
|
+
class:textarea-sm={size === 'sm'}
|
|
52
|
+
bind:value
|
|
53
|
+
{...rest}
|
|
54
|
+
aria-invalid={invalid || undefined}
|
|
55
|
+
></textarea>
|
|
40
56
|
{/if}
|
|
41
57
|
|
|
42
58
|
<style>
|
|
@@ -56,6 +72,15 @@
|
|
|
56
72
|
outline: none;
|
|
57
73
|
border-color: var(--accent);
|
|
58
74
|
}
|
|
75
|
+
.textarea-sm {
|
|
76
|
+
padding: var(--sp-2);
|
|
77
|
+
font-size: var(--fs-sm);
|
|
78
|
+
min-height: 4rem;
|
|
79
|
+
}
|
|
80
|
+
.textarea[aria-invalid='true'],
|
|
81
|
+
.textarea[aria-invalid='true']:focus {
|
|
82
|
+
border-color: var(--danger);
|
|
83
|
+
}
|
|
59
84
|
.mono {
|
|
60
85
|
font-family: var(--font-mono);
|
|
61
86
|
}
|
|
@@ -2,6 +2,10 @@ import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
|
2
2
|
type Props = HTMLTextareaAttributes & {
|
|
3
3
|
mono?: boolean;
|
|
4
4
|
autoresize?: boolean;
|
|
5
|
+
size?: 'sm' | 'md';
|
|
6
|
+
/** Error state: danger border + aria-invalid (also styles if a consumer
|
|
7
|
+
* sets aria-invalid directly). */
|
|
8
|
+
invalid?: boolean;
|
|
5
9
|
class?: string;
|
|
6
10
|
value?: HTMLTextareaAttributes['value'];
|
|
7
11
|
el?: HTMLTextAreaElement | null;
|
|
@@ -115,13 +115,16 @@
|
|
|
115
115
|
<div class="shell" class:dragging style="--shell-sidebar-w: {widthCss}">
|
|
116
116
|
<header class="shell-header">
|
|
117
117
|
{#if sidebar}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
<!-- Wrapper owned here so the responsive hide is a scoped rule on our own
|
|
119
|
+
element, not a :global reach into the IconButton's button. -->
|
|
120
|
+
<div class="shell-menu-btn">
|
|
121
|
+
<IconButton
|
|
122
|
+
icon="menu"
|
|
123
|
+
label="Toggle navigation"
|
|
124
|
+
aria-expanded={open}
|
|
125
|
+
onclick={() => (open = !open)}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
125
128
|
{/if}
|
|
126
129
|
{@render header?.()}
|
|
127
130
|
</header>
|
|
@@ -191,6 +194,10 @@
|
|
|
191
194
|
backdrop-filter: blur(8px);
|
|
192
195
|
border-bottom: 1px solid var(--border);
|
|
193
196
|
}
|
|
197
|
+
.shell-menu-btn {
|
|
198
|
+
display: inline-flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
}
|
|
194
201
|
.shell-main {
|
|
195
202
|
grid-area: main;
|
|
196
203
|
min-width: 0; /* let content shrink instead of overflowing the grid */
|
|
@@ -272,7 +279,7 @@
|
|
|
272
279
|
border-right: 1px solid var(--border);
|
|
273
280
|
}
|
|
274
281
|
.shell-scrim,
|
|
275
|
-
|
|
282
|
+
.shell-menu-btn {
|
|
276
283
|
display: none !important;
|
|
277
284
|
}
|
|
278
285
|
.shell-sidebar-resize {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<details name={groupName} open={item.open}>
|
|
38
38
|
<summary>
|
|
39
39
|
<span class="acc-title">{item.title}</span>
|
|
40
|
-
<Icon name="chevron-down"
|
|
40
|
+
<span class="acc-chevron"><Icon name="chevron-down" /></span>
|
|
41
41
|
</summary>
|
|
42
42
|
<div class="acc-panel">{@render item.content()}</div>
|
|
43
43
|
</details>
|
|
@@ -79,11 +79,14 @@
|
|
|
79
79
|
outline: 2px solid var(--accent);
|
|
80
80
|
outline-offset: -2px;
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
/* Wrap the chevron in our own element so rotation is a scoped rule (no
|
|
83
|
+
:global into the Icon child). The Icon inherits the color via currentColor. */
|
|
84
|
+
.acc-chevron {
|
|
85
|
+
display: inline-flex;
|
|
83
86
|
color: var(--text-muted);
|
|
84
87
|
transition: transform 0.15s var(--ease);
|
|
85
88
|
}
|
|
86
|
-
details[open] summary
|
|
89
|
+
details[open] summary .acc-chevron {
|
|
87
90
|
transform: rotate(180deg);
|
|
88
91
|
}
|
|
89
92
|
.acc-panel {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// the result for screen readers. Dependency-free.
|
|
7
7
|
import Button from '../atoms/Button.svelte';
|
|
8
8
|
import Icon from '../atoms/Icon.svelte';
|
|
9
|
+
import { copyToClipboard } from '../../clipboard';
|
|
9
10
|
|
|
10
11
|
let {
|
|
11
12
|
text,
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
33
|
|
|
33
34
|
async function copy() {
|
|
34
|
-
const ok = await
|
|
35
|
+
const ok = await copyToClipboard(text);
|
|
35
36
|
copied = ok;
|
|
36
37
|
status = ok ? copiedLabel : 'Copy failed';
|
|
37
38
|
clearTimeout(timer);
|
|
@@ -40,31 +41,6 @@
|
|
|
40
41
|
status = '';
|
|
41
42
|
}, resetMs);
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
-
async function writeClipboard(value: string): Promise<boolean> {
|
|
45
|
-
try {
|
|
46
|
-
if (navigator.clipboard?.writeText) {
|
|
47
|
-
await navigator.clipboard.writeText(value);
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
} catch {
|
|
51
|
-
/* fall through to legacy path */
|
|
52
|
-
}
|
|
53
|
-
// Fallback for insecure contexts / older browsers.
|
|
54
|
-
try {
|
|
55
|
-
const ta = document.createElement('textarea');
|
|
56
|
-
ta.value = value;
|
|
57
|
-
ta.style.position = 'fixed';
|
|
58
|
-
ta.style.opacity = '0';
|
|
59
|
-
document.body.appendChild(ta);
|
|
60
|
-
ta.select();
|
|
61
|
-
const ok = document.execCommand('copy');
|
|
62
|
-
document.body.removeChild(ta);
|
|
63
|
-
return ok;
|
|
64
|
-
} catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
44
|
</script>
|
|
69
45
|
|
|
70
46
|
<Button
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
</script>
|
|
35
35
|
|
|
36
36
|
<label
|
|
37
|
-
class="
|
|
38
|
-
class:
|
|
39
|
-
class:
|
|
37
|
+
class="file-btn {klass}"
|
|
38
|
+
class:primary={variant === 'primary'}
|
|
39
|
+
class:ghost={variant === 'ghost'}
|
|
40
40
|
class:disabled
|
|
41
41
|
>
|
|
42
42
|
<input
|
|
@@ -52,8 +52,50 @@
|
|
|
52
52
|
</label>
|
|
53
53
|
|
|
54
54
|
<style>
|
|
55
|
+
/* FileButton is a <label> (so the native file input stays inside it and
|
|
56
|
+
keyboard-focusable), not a <button>, so it can't be the Button atom — it
|
|
57
|
+
owns the canonical control look here, from the same tokens. */
|
|
55
58
|
.file-btn {
|
|
59
|
+
display: inline-flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
gap: var(--sp-2);
|
|
63
|
+
padding: var(--sp-2) var(--sp-4);
|
|
64
|
+
min-height: 2.5rem;
|
|
65
|
+
border: 1px solid var(--border-strong);
|
|
66
|
+
border-radius: var(--r-md);
|
|
67
|
+
background: var(--surface);
|
|
68
|
+
color: var(--text);
|
|
69
|
+
font-weight: var(--fw-medium);
|
|
70
|
+
font-size: var(--fs-sm);
|
|
71
|
+
line-height: 1;
|
|
72
|
+
white-space: nowrap;
|
|
56
73
|
cursor: pointer;
|
|
74
|
+
user-select: none;
|
|
75
|
+
transition:
|
|
76
|
+
background 0.12s var(--ease),
|
|
77
|
+
border-color 0.12s var(--ease),
|
|
78
|
+
opacity 0.12s var(--ease);
|
|
79
|
+
}
|
|
80
|
+
.file-btn:hover:not(.disabled) {
|
|
81
|
+
border-color: var(--accent);
|
|
82
|
+
}
|
|
83
|
+
.file-btn.primary {
|
|
84
|
+
background: var(--accent);
|
|
85
|
+
border-color: var(--accent);
|
|
86
|
+
color: var(--text-on-accent);
|
|
87
|
+
font-weight: var(--fw-semibold);
|
|
88
|
+
}
|
|
89
|
+
.file-btn.primary:hover:not(.disabled) {
|
|
90
|
+
filter: brightness(1.08);
|
|
91
|
+
}
|
|
92
|
+
.file-btn.ghost {
|
|
93
|
+
background: transparent;
|
|
94
|
+
border-color: transparent;
|
|
95
|
+
}
|
|
96
|
+
.file-btn.ghost:hover:not(.disabled) {
|
|
97
|
+
background: var(--bg-elevated-2);
|
|
98
|
+
border-color: transparent;
|
|
57
99
|
}
|
|
58
100
|
/* The hidden input keeps focusability (sr-only, not display:none), so mirror
|
|
59
101
|
its focus onto the label for a visible ring. */
|
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
11
11
|
size?: number;
|
|
12
12
|
// Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
|
|
13
|
-
// no square
|
|
13
|
+
// no square box; just a muted glyph that brightens on hover. Pair with
|
|
14
|
+
// `hoverDanger` to tint it red on hover (delete affordances).
|
|
14
15
|
inline?: boolean;
|
|
16
|
+
hoverDanger?: boolean;
|
|
17
|
+
// Two-state icon toggle (star/pin/favourite): sets `aria-pressed` and tints
|
|
18
|
+
// the glyph with the accent when on. Override the tint per-instance with
|
|
19
|
+
// `style="--btn-on: var(--warn)"`.
|
|
20
|
+
pressed?: boolean;
|
|
15
21
|
class?: string;
|
|
16
22
|
};
|
|
17
23
|
|
|
@@ -22,6 +28,8 @@
|
|
|
22
28
|
variant = 'ghost',
|
|
23
29
|
size = 18,
|
|
24
30
|
inline = false,
|
|
31
|
+
hoverDanger = false,
|
|
32
|
+
pressed,
|
|
25
33
|
disabled = false,
|
|
26
34
|
onclick,
|
|
27
35
|
class: klass = '',
|
|
@@ -30,7 +38,19 @@
|
|
|
30
38
|
</script>
|
|
31
39
|
|
|
32
40
|
<!-- Composition: the icon-only button is a Button (canonical control styling)
|
|
33
|
-
|
|
34
|
-
<Button
|
|
41
|
+
in its icon variant, wrapping an Icon. -->
|
|
42
|
+
<Button
|
|
43
|
+
{...rest}
|
|
44
|
+
{variant}
|
|
45
|
+
{disabled}
|
|
46
|
+
{title}
|
|
47
|
+
{onclick}
|
|
48
|
+
icon={!inline}
|
|
49
|
+
iconInline={inline}
|
|
50
|
+
{hoverDanger}
|
|
51
|
+
aria-pressed={pressed}
|
|
52
|
+
class={klass}
|
|
53
|
+
aria-label={label}
|
|
54
|
+
>
|
|
35
55
|
<Icon name={icon} {size} />
|
|
36
56
|
</Button>
|
|
@@ -6,6 +6,8 @@ type IconButtonProps = HTMLButtonAttributes & {
|
|
|
6
6
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
7
7
|
size?: number;
|
|
8
8
|
inline?: boolean;
|
|
9
|
+
hoverDanger?: boolean;
|
|
10
|
+
pressed?: boolean;
|
|
9
11
|
class?: string;
|
|
10
12
|
};
|
|
11
13
|
declare const IconButton: import("svelte").Component<IconButtonProps, {}, "">;
|
|
@@ -14,12 +14,16 @@
|
|
|
14
14
|
onclose,
|
|
15
15
|
body,
|
|
16
16
|
footer,
|
|
17
|
+
size = 'md',
|
|
17
18
|
resizeKey
|
|
18
19
|
}: {
|
|
19
20
|
title: string;
|
|
20
21
|
onclose: () => void;
|
|
21
22
|
body: Snippet;
|
|
22
23
|
footer?: Snippet;
|
|
24
|
+
/** Desktop width preset (sm 24rem / md 34rem / lg 48rem). A `resizeKey`
|
|
25
|
+
* drag still overrides it. */
|
|
26
|
+
size?: 'sm' | 'md' | 'lg';
|
|
23
27
|
/** When set, the sheet is horizontally resizable on desktop and the chosen
|
|
24
28
|
* width persists under this localStorage key. */
|
|
25
29
|
resizeKey?: string;
|
|
@@ -104,7 +108,7 @@
|
|
|
104
108
|
}}
|
|
105
109
|
onclick={onDialogClick}
|
|
106
110
|
>
|
|
107
|
-
<div class="sheet">
|
|
111
|
+
<div class="sheet" class:sheet-sm={size === 'sm'} class:sheet-lg={size === 'lg'}>
|
|
108
112
|
<div class="sheet-head">
|
|
109
113
|
<span id={titleId} class="sheet-title truncate">{title}</span>
|
|
110
114
|
<div class="spacer"></div>
|
|
@@ -167,9 +171,10 @@
|
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
.sheet {
|
|
174
|
+
--sw: 34rem; /* width preset; overridden by --sheet-w when resized */
|
|
170
175
|
position: relative;
|
|
171
176
|
width: 100%;
|
|
172
|
-
max-width:
|
|
177
|
+
max-width: var(--sw);
|
|
173
178
|
max-height: calc(100dvh - var(--safe-top) - var(--sp-6));
|
|
174
179
|
display: flex;
|
|
175
180
|
flex-direction: column;
|
|
@@ -184,8 +189,14 @@
|
|
|
184
189
|
.sheet {
|
|
185
190
|
border-radius: var(--r-lg);
|
|
186
191
|
padding-bottom: 0;
|
|
187
|
-
width: var(--sheet-w,
|
|
188
|
-
max-width: min(var(--sheet-w,
|
|
192
|
+
width: var(--sheet-w, var(--sw));
|
|
193
|
+
max-width: min(var(--sheet-w, var(--sw)), calc(100vw - 2rem));
|
|
194
|
+
}
|
|
195
|
+
.sheet-sm {
|
|
196
|
+
--sw: 24rem;
|
|
197
|
+
}
|
|
198
|
+
.sheet-lg {
|
|
199
|
+
--sw: 48rem;
|
|
189
200
|
}
|
|
190
201
|
}
|
|
191
202
|
@keyframes sheet-up {
|
|
@@ -4,6 +4,9 @@ type $$ComponentProps = {
|
|
|
4
4
|
onclose: () => void;
|
|
5
5
|
body: Snippet;
|
|
6
6
|
footer?: Snippet;
|
|
7
|
+
/** Desktop width preset (sm 24rem / md 34rem / lg 48rem). A `resizeKey`
|
|
8
|
+
* drag still overrides it. */
|
|
9
|
+
size?: 'sm' | 'md' | 'lg';
|
|
7
10
|
/** When set, the sheet is horizontally resizable on desktop and the chosen
|
|
8
11
|
* width persists under this localStorage key. */
|
|
9
12
|
resizeKey?: string;
|
|
@@ -5,17 +5,16 @@
|
|
|
5
5
|
// adapter, green/blue/red for permission mode). `row` lays the content out
|
|
6
6
|
// horizontally (icon + label) instead of the default label-over-hint column.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// overrides. Button's variant classes are scoped via :where() (0 specificity),
|
|
11
|
-
// so `.btn.opt-btn` (0,2,0) wins cleanly over `.btn-ghost`.
|
|
8
|
+
// A selection card shares almost nothing visually with Button, so it owns its
|
|
9
|
+
// own <button> + scoped styles rather than specializing the Button atom.
|
|
12
10
|
import type { Snippet } from 'svelte';
|
|
13
11
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
14
|
-
import Button from '../atoms/Button.svelte';
|
|
15
12
|
|
|
16
13
|
let {
|
|
17
14
|
selected = false,
|
|
18
15
|
row = false,
|
|
16
|
+
type = 'button',
|
|
17
|
+
disabled = false,
|
|
19
18
|
class: klass = '',
|
|
20
19
|
children,
|
|
21
20
|
...rest
|
|
@@ -26,36 +25,44 @@
|
|
|
26
25
|
} = $props();
|
|
27
26
|
</script>
|
|
28
27
|
|
|
29
|
-
<
|
|
28
|
+
<button
|
|
30
29
|
{...rest}
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
{type}
|
|
31
|
+
{disabled}
|
|
32
|
+
class="opt {klass}"
|
|
33
|
+
class:row
|
|
34
|
+
class:selected
|
|
33
35
|
aria-pressed={selected}
|
|
34
36
|
>
|
|
35
37
|
{@render children?.()}
|
|
36
|
-
</
|
|
38
|
+
</button>
|
|
37
39
|
|
|
38
40
|
<style>
|
|
39
|
-
|
|
40
|
-
atom's :where()-scoped variant classes. */
|
|
41
|
-
:global(.btn.opt-btn) {
|
|
41
|
+
.opt {
|
|
42
42
|
display: flex;
|
|
43
43
|
flex-direction: column;
|
|
44
44
|
gap: 2px;
|
|
45
45
|
padding: var(--sp-2);
|
|
46
|
-
min-height: 0;
|
|
47
46
|
background: var(--bg);
|
|
48
47
|
border: 1px solid var(--border-strong);
|
|
49
48
|
border-radius: var(--r-md);
|
|
50
49
|
color: var(--text);
|
|
51
50
|
text-align: left;
|
|
52
51
|
white-space: normal;
|
|
52
|
+
user-select: none;
|
|
53
|
+
transition:
|
|
54
|
+
background 0.12s var(--ease),
|
|
55
|
+
border-color 0.12s var(--ease),
|
|
56
|
+
color 0.12s var(--ease);
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
.opt:disabled {
|
|
59
|
+
opacity: 0.45;
|
|
60
|
+
cursor: not-allowed;
|
|
61
|
+
}
|
|
62
|
+
.opt:hover:not(:disabled):not(.selected) {
|
|
55
63
|
border-color: var(--border-strong);
|
|
56
|
-
background: var(--bg);
|
|
57
64
|
}
|
|
58
|
-
|
|
65
|
+
.opt.row {
|
|
59
66
|
flex-direction: row;
|
|
60
67
|
align-items: center;
|
|
61
68
|
justify-content: center;
|
|
@@ -63,14 +70,17 @@
|
|
|
63
70
|
color: var(--text-muted);
|
|
64
71
|
font-weight: var(--fw-medium);
|
|
65
72
|
}
|
|
66
|
-
|
|
73
|
+
.opt.selected {
|
|
67
74
|
--oc: var(--opt-accent, var(--accent));
|
|
75
|
+
/* Retint slotted `.faint` hint text toward the accent — via an inherited
|
|
76
|
+
custom property, so no :global reach into slotted content. */
|
|
77
|
+
--faint-color: color-mix(in srgb, var(--oc) 70%, var(--text-muted));
|
|
68
78
|
border-color: var(--oc);
|
|
69
79
|
background: color-mix(in srgb, var(--oc) 14%, var(--bg));
|
|
70
80
|
color: var(--oc);
|
|
71
81
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
.opt.selected:hover:not(:disabled) {
|
|
83
|
+
border-color: var(--oc);
|
|
84
|
+
background: color-mix(in srgb, var(--oc) 20%, var(--bg));
|
|
75
85
|
}
|
|
76
86
|
</style>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// ships beyond Chromium; the popover semantics above are the hard part and
|
|
10
10
|
// are broadly supported today.)
|
|
11
11
|
import type { Snippet } from 'svelte';
|
|
12
|
+
import { place } from '../../floating';
|
|
12
13
|
|
|
13
14
|
type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
14
15
|
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
label,
|
|
19
20
|
trigger,
|
|
20
21
|
children,
|
|
22
|
+
triggerClass = '',
|
|
23
|
+
bare = false,
|
|
21
24
|
onopen,
|
|
22
25
|
onclose
|
|
23
26
|
}: {
|
|
@@ -25,11 +28,16 @@
|
|
|
25
28
|
gap?: number;
|
|
26
29
|
/** Accessible name for the trigger. */
|
|
27
30
|
label: string;
|
|
28
|
-
/** Trigger content (rendered inside a
|
|
29
|
-
* wiring). */
|
|
31
|
+
/** Trigger content (rendered inside a button that owns the popover wiring). */
|
|
30
32
|
trigger: Snippet;
|
|
31
33
|
/** Panel content. */
|
|
32
34
|
children: Snippet;
|
|
35
|
+
/** Extra class on the trigger button — style it from your own scoped CSS,
|
|
36
|
+
* no :global needed (you supply the class). */
|
|
37
|
+
triggerClass?: string;
|
|
38
|
+
/** Drop the default ghost-icon chrome so the trigger is an unstyled button
|
|
39
|
+
* you fully own (pair with `triggerClass`). */
|
|
40
|
+
bare?: boolean;
|
|
33
41
|
onopen?: () => void;
|
|
34
42
|
onclose?: () => void;
|
|
35
43
|
} = $props();
|
|
@@ -38,34 +46,19 @@
|
|
|
38
46
|
let triggerEl = $state<HTMLButtonElement | null>(null);
|
|
39
47
|
let panelEl = $state<HTMLDivElement | null>(null);
|
|
40
48
|
|
|
41
|
-
function
|
|
42
|
-
if (
|
|
43
|
-
const t = triggerEl.getBoundingClientRect();
|
|
44
|
-
const p = panelEl.getBoundingClientRect();
|
|
45
|
-
const vw = document.documentElement.clientWidth;
|
|
46
|
-
const vh = document.documentElement.clientHeight;
|
|
47
|
-
|
|
48
|
-
let top = placement.startsWith('bottom') ? t.bottom + gap : t.top - p.height - gap;
|
|
49
|
-
let left = placement.endsWith('end') ? t.right - p.width : t.left;
|
|
50
|
-
|
|
51
|
-
// Flip vertically / clamp horizontally to stay on screen.
|
|
52
|
-
if (top + p.height > vh && t.top - p.height - gap > 0) top = t.top - p.height - gap;
|
|
53
|
-
if (top < 0) top = gap;
|
|
54
|
-
left = Math.max(gap, Math.min(left, vw - p.width - gap));
|
|
55
|
-
|
|
56
|
-
panelEl.style.top = `${Math.round(top)}px`;
|
|
57
|
-
panelEl.style.left = `${Math.round(left)}px`;
|
|
49
|
+
function reposition() {
|
|
50
|
+
if (triggerEl && panelEl) place(triggerEl, panelEl, placement, gap);
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
function onToggle(e: ToggleEvent) {
|
|
61
54
|
if (e.newState === 'open') {
|
|
62
|
-
|
|
63
|
-
addEventListener('scroll',
|
|
64
|
-
addEventListener('resize',
|
|
55
|
+
reposition();
|
|
56
|
+
addEventListener('scroll', reposition, true);
|
|
57
|
+
addEventListener('resize', reposition);
|
|
65
58
|
onopen?.();
|
|
66
59
|
} else {
|
|
67
|
-
removeEventListener('scroll',
|
|
68
|
-
removeEventListener('resize',
|
|
60
|
+
removeEventListener('scroll', reposition, true);
|
|
61
|
+
removeEventListener('resize', reposition);
|
|
69
62
|
onclose?.();
|
|
70
63
|
}
|
|
71
64
|
}
|
|
@@ -74,7 +67,8 @@
|
|
|
74
67
|
<button
|
|
75
68
|
bind:this={triggerEl}
|
|
76
69
|
type="button"
|
|
77
|
-
class="
|
|
70
|
+
class="pop-trigger {triggerClass}"
|
|
71
|
+
class:bare
|
|
78
72
|
popovertarget={id}
|
|
79
73
|
aria-label={label}
|
|
80
74
|
>
|
|
@@ -94,10 +88,37 @@
|
|
|
94
88
|
</div>
|
|
95
89
|
|
|
96
90
|
<style>
|
|
91
|
+
/* The trigger owns its look (a ghost icon-button) from tokens — it no longer
|
|
92
|
+
borrows global .btn classes, so a consumer can restyle it via `triggerClass`
|
|
93
|
+
(+ `bare`) from their own scoped CSS instead of fighting globals. */
|
|
97
94
|
.pop-trigger {
|
|
98
95
|
display: inline-flex;
|
|
99
96
|
align-items: center;
|
|
100
97
|
justify-content: center;
|
|
98
|
+
min-height: 2.25rem;
|
|
99
|
+
min-width: 2.25rem;
|
|
100
|
+
padding: var(--sp-2);
|
|
101
|
+
border: 1px solid transparent;
|
|
102
|
+
border-radius: var(--r-md);
|
|
103
|
+
background: transparent;
|
|
104
|
+
color: var(--text);
|
|
105
|
+
transition:
|
|
106
|
+
background 0.12s var(--ease),
|
|
107
|
+
border-color 0.12s var(--ease);
|
|
108
|
+
}
|
|
109
|
+
.pop-trigger:hover {
|
|
110
|
+
background: var(--bg-elevated-2);
|
|
111
|
+
}
|
|
112
|
+
/* `bare`: strip the chrome down to a plain button the consumer styles. */
|
|
113
|
+
.pop-trigger.bare {
|
|
114
|
+
min-height: 0;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
padding: 0;
|
|
117
|
+
border: 0;
|
|
118
|
+
background: none;
|
|
119
|
+
}
|
|
120
|
+
.pop-trigger.bare:hover {
|
|
121
|
+
background: none;
|
|
101
122
|
}
|
|
102
123
|
.pop-panel {
|
|
103
124
|
position: fixed;
|
|
@@ -5,11 +5,16 @@ type $$ComponentProps = {
|
|
|
5
5
|
gap?: number;
|
|
6
6
|
/** Accessible name for the trigger. */
|
|
7
7
|
label: string;
|
|
8
|
-
/** Trigger content (rendered inside a
|
|
9
|
-
* wiring). */
|
|
8
|
+
/** Trigger content (rendered inside a button that owns the popover wiring). */
|
|
10
9
|
trigger: Snippet;
|
|
11
10
|
/** Panel content. */
|
|
12
11
|
children: Snippet;
|
|
12
|
+
/** Extra class on the trigger button — style it from your own scoped CSS,
|
|
13
|
+
* no :global needed (you supply the class). */
|
|
14
|
+
triggerClass?: string;
|
|
15
|
+
/** Drop the default ghost-icon chrome so the trigger is an unstyled button
|
|
16
|
+
* you fully own (pair with `triggerClass`). */
|
|
17
|
+
bare?: boolean;
|
|
13
18
|
onopen?: () => void;
|
|
14
19
|
onclose?: () => void;
|
|
15
20
|
};
|
|
@@ -27,26 +27,30 @@
|
|
|
27
27
|
} = $props();
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
aria-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
</
|
|
30
|
+
<!-- The wrapper (owned here, so styled scoped) is the positioning context that
|
|
31
|
+
clips the transparent overlaid <select>; the icon Button shows through. -->
|
|
32
|
+
<span class="select-button {klass}">
|
|
33
|
+
<Button variant="ghost" icon {title} aria-label={label}>
|
|
34
|
+
<span aria-hidden="true">{glyph}</span>
|
|
35
|
+
<Select
|
|
36
|
+
variant="ghost"
|
|
37
|
+
aria-label={label}
|
|
38
|
+
{value}
|
|
39
|
+
onchange={(e) => onchange((e.currentTarget as HTMLSelectElement).value)}
|
|
40
|
+
>
|
|
41
|
+
{#each options as o (o.value)}
|
|
42
|
+
<option value={o.value}>{o.label}</option>
|
|
43
|
+
{/each}
|
|
44
|
+
</Select>
|
|
45
|
+
</Button>
|
|
46
|
+
</span>
|
|
43
47
|
|
|
44
48
|
<style>
|
|
45
|
-
|
|
46
|
-
Button (child component), so target it via :global. */
|
|
47
|
-
:global(.select-button) {
|
|
49
|
+
.select-button {
|
|
48
50
|
position: relative;
|
|
51
|
+
display: inline-flex;
|
|
49
52
|
overflow: hidden;
|
|
53
|
+
border-radius: var(--r-md);
|
|
50
54
|
font-weight: var(--fw-bold);
|
|
51
55
|
}
|
|
52
56
|
</style>
|