@lyeve-labs/ui-kit 0.38.2 → 0.38.4
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/dist/components/AccountMenu.svelte +2 -6
- package/dist/components/AccountMenu.svelte.d.ts +1 -1
- package/dist/components/Alert.svelte +2 -2
- package/dist/components/AppShell.svelte +20 -2
- package/dist/components/Autocomplete.svelte +4 -2
- package/dist/components/Avatar.svelte +16 -9
- package/dist/components/Banner.svelte +14 -2
- package/dist/components/CheckboxGroup.svelte +1 -1
- package/dist/components/DatePicker.svelte +13 -3
- package/dist/components/FileInput.svelte +4 -1
- package/dist/components/MultiSelect.svelte +105 -82
- package/dist/components/NumberInput.svelte +1 -0
- package/dist/components/RadioGroup.svelte +1 -1
- package/dist/components/Select.svelte +1 -1
- package/dist/components/SidebarNav.svelte +2 -6
- package/dist/components/Tabs.svelte +4 -2
- package/dist/components/TimePicker.svelte +87 -27
- package/dist/components/TreeView.svelte +41 -7
- package/dist/components/dialog/ConfirmDialog.svelte +1 -1
- package/dist/components/dialog/Dialog.svelte +6 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/choice.d.ts +6 -1
- package/dist/internal/choice.js +18 -2
- package/dist/internal/panel.d.ts +6 -1
- package/dist/internal/panel.js +33 -4
- package/dist/internal/tone.d.ts +11 -0
- package/dist/internal/tone.js +17 -0
- package/dist/styles/theme.css +3 -2
- package/package.json +1 -1
- package/src/lib/styles/theme.css +3 -2
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import type { Snippet } from 'svelte';
|
|
20
20
|
import { TOUCH_GROW } from '../internal/touch.js';
|
|
21
21
|
import { ChevronDown } from '@lucide/svelte';
|
|
22
|
-
import type
|
|
22
|
+
import { ROW_CHIP_TONE, type AccentTone } from '../internal/tone.js';
|
|
23
23
|
|
|
24
24
|
interface Props {
|
|
25
25
|
/** The line people recognize themselves by: a display name, or the email. */
|
|
@@ -62,11 +62,7 @@
|
|
|
62
62
|
|
|
63
63
|
const AVATAR_TONE: Record<AccentTone, string> = {
|
|
64
64
|
neutral: 'bg-surface-2 text-fg',
|
|
65
|
-
|
|
66
|
-
success: 'bg-success/15 text-success',
|
|
67
|
-
warn: 'bg-warn/15 text-warn',
|
|
68
|
-
danger: 'bg-danger/15 text-danger',
|
|
69
|
-
violet: 'bg-violet/15 text-violet',
|
|
65
|
+
...ROW_CHIP_TONE,
|
|
70
66
|
};
|
|
71
67
|
|
|
72
68
|
let root = $state<HTMLDetailsElement | null>(null);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* to sign out.
|
|
17
17
|
*/
|
|
18
18
|
import type { Snippet } from 'svelte';
|
|
19
|
-
import type
|
|
19
|
+
import { type AccentTone } from '../internal/tone.js';
|
|
20
20
|
interface Props {
|
|
21
21
|
/** The line people recognize themselves by: a display name, or the email. */
|
|
22
22
|
name: string;
|
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
* whatever was being read. An alert that also clears itself must not be
|
|
66
66
|
* assertive: it would interrupt to say something and then take it away.
|
|
67
67
|
*/
|
|
68
|
-
const live = $derived(t === 'danger' || t === 'warn');
|
|
69
|
-
|
|
70
68
|
const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
|
|
71
69
|
|
|
70
|
+
const live = $derived((t === 'danger' || t === 'warn') && delay <= 0);
|
|
71
|
+
|
|
72
72
|
/** An alert that can close itself can also be closed by hand. */
|
|
73
73
|
const closable = $derived(dismissible || delay > 0);
|
|
74
74
|
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import type { Snippet } from 'svelte';
|
|
37
37
|
import { Menu, PanelLeft, PanelLeftClose } from '@lucide/svelte';
|
|
38
38
|
import { overlay } from '../internal/overlay.js';
|
|
39
|
+
import * as motion from '../motion.js';
|
|
39
40
|
import {
|
|
40
41
|
APP_BRAND,
|
|
41
42
|
APP_HEADER,
|
|
@@ -134,6 +135,20 @@
|
|
|
134
135
|
/** md: to lg:, where the expanded column costs the page more than it gives. */
|
|
135
136
|
let isNarrow = $state(false);
|
|
136
137
|
const drawerOpen = $derived(isMobile && navOpen);
|
|
138
|
+
|
|
139
|
+
let shellEl = $state<HTMLDivElement | undefined>();
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The edge the phone drawer slides from. It is docked at the start edge,
|
|
143
|
+
* which a right-to-left page puts on the right, and a slide from the left
|
|
144
|
+
* would cross the whole screen to get there. Read when the drawer moves,
|
|
145
|
+
* so a page that changes direction is followed.
|
|
146
|
+
*/
|
|
147
|
+
function startSide(): 'left' | 'right' {
|
|
148
|
+
return shellEl !== undefined && getComputedStyle(shellEl).direction === 'rtl'
|
|
149
|
+
? 'right'
|
|
150
|
+
: 'left';
|
|
151
|
+
}
|
|
137
152
|
/**
|
|
138
153
|
* Collapsing is a desktop gesture. Below md: the same aside is the drawer,
|
|
139
154
|
* so honoring `collapsed` there would leave the hamburger opening nothing.
|
|
@@ -194,7 +209,7 @@
|
|
|
194
209
|
|
|
195
210
|
<svelte:window onkeydown={onWindowKeydown} />
|
|
196
211
|
|
|
197
|
-
<div data-print="unclip" class="flex h-screen {klass}">
|
|
212
|
+
<div bind:this={shellEl} data-print="unclip" class="flex h-screen {klass}">
|
|
198
213
|
<!-- First focusable thing in the document. Without it a keyboard reader tabs
|
|
199
214
|
the whole sidebar again on every page. -->
|
|
200
215
|
<a
|
|
@@ -211,6 +226,7 @@
|
|
|
211
226
|
reaching the page under the open drawer. -->
|
|
212
227
|
<div
|
|
213
228
|
data-print="hide"
|
|
229
|
+
transition:motion.scrim|global
|
|
214
230
|
class="fixed inset-0 z-overlay bg-black/60 backdrop-blur-sm"
|
|
215
231
|
role="presentation"
|
|
216
232
|
onclick={() => (navOpen = false)}
|
|
@@ -227,11 +243,13 @@
|
|
|
227
243
|
complementary landmark, and the a11y gate rejects a non-interactive element
|
|
228
244
|
taking an interactive role. use:overlay is what makes aria-modal true rather
|
|
229
245
|
than merely claimed. It moves focus in, keeps Tab inside, and hands focus
|
|
230
|
-
back to the hamburger on close.
|
|
246
|
+
back to the hamburger on close. It slides from the start edge and back
|
|
247
|
+
on the drawer preset, as every other drawer does, rather than snapping.
|
|
231
248
|
-->
|
|
232
249
|
{#if drawerOpen}
|
|
233
250
|
<div
|
|
234
251
|
data-print="hide"
|
|
252
|
+
transition:motion.drawer|global={{ side: startSide() }}
|
|
235
253
|
class="fixed inset-y-0 start-0 z-drawer flex shadow-2xl"
|
|
236
254
|
role="dialog"
|
|
237
255
|
aria-modal="true"
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
panelOption,
|
|
50
50
|
placePanel,
|
|
51
51
|
} from '../internal/panel.js';
|
|
52
|
+
import { HIT_AREA_POSITIONED } from '../internal/touch.js';
|
|
52
53
|
import * as motion from '../motion.js';
|
|
53
54
|
|
|
54
55
|
interface Props {
|
|
@@ -216,13 +217,14 @@
|
|
|
216
217
|
box is positioned from its right edge, so a negative right margin holds
|
|
217
218
|
the glyph exactly where it was, while a negative top margin would fight
|
|
218
219
|
the translate that centers it and lift it 6px off the middle of the
|
|
219
|
-
field.
|
|
220
|
+
field. Under a finger the hit box grows to 44px, as SearchInput's
|
|
221
|
+
clear button does.
|
|
220
222
|
-->
|
|
221
223
|
<button
|
|
222
224
|
type="button"
|
|
223
225
|
aria-label="Clear"
|
|
224
226
|
onclick={clear}
|
|
225
|
-
class="absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint transition-colors hover:text-fg"
|
|
227
|
+
class="{HIT_AREA_POSITIONED} absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint transition-colors hover:text-fg"
|
|
226
228
|
>
|
|
227
229
|
<svg
|
|
228
230
|
width="12"
|
|
@@ -21,15 +21,22 @@
|
|
|
21
21
|
xl: { wrap: 'w-14 h-14', text: 'text-base' },
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/*
|
|
25
|
+
* Each fill names the text it carries. Initials in ink on a translucent hue
|
|
26
|
+
* took the color of whatever the avatar sat on, and seven of the eight read
|
|
27
|
+
* under 4.5:1 in one theme or the other, down to 1.87:1. A solid hue holds
|
|
28
|
+
* ink in both themes, and a quarter tint holds fg, so the five strong hues
|
|
29
|
+
* went solid and the three soft ones went to a tint.
|
|
30
|
+
*/
|
|
24
31
|
const hues = [
|
|
25
|
-
'bg-violet
|
|
26
|
-
'bg-brand
|
|
27
|
-
'bg-success
|
|
28
|
-
'bg-warn
|
|
29
|
-
'bg-danger
|
|
30
|
-
'bg-violet/
|
|
31
|
-
'bg-brand/
|
|
32
|
-
'bg-success/
|
|
32
|
+
'bg-violet text-ink',
|
|
33
|
+
'bg-brand text-ink',
|
|
34
|
+
'bg-success text-ink',
|
|
35
|
+
'bg-warn text-ink',
|
|
36
|
+
'bg-danger text-ink',
|
|
37
|
+
'bg-violet/25 text-fg',
|
|
38
|
+
'bg-brand/25 text-fg',
|
|
39
|
+
'bg-success/25 text-fg',
|
|
33
40
|
];
|
|
34
41
|
|
|
35
42
|
let imgError = $state(false);
|
|
@@ -49,7 +56,7 @@
|
|
|
49
56
|
|
|
50
57
|
<span
|
|
51
58
|
class="inline-flex items-center justify-center rounded-full font-semibold shrink-0
|
|
52
|
-
{s.wrap} {hue}
|
|
59
|
+
{s.wrap} {hue} {cls}"
|
|
53
60
|
title={name}
|
|
54
61
|
use:brokenImage={() => (imgError = true)}
|
|
55
62
|
>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { statusTone, type StatusTone, type StatusToneInput } from '../internal/tone.js';
|
|
3
3
|
import { HIT_AREA } from '../internal/touch.js';
|
|
4
|
+
import * as motion from '../motion.js';
|
|
4
5
|
import type { Snippet } from 'svelte';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -67,14 +68,25 @@
|
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
const t = $derived(statusTone(tone));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A failure and a warning interrupt, because the reader has to act, as
|
|
74
|
+
* Alert does. Anything else waits for a pause. A banner that clears itself
|
|
75
|
+
* waits too: interrupting to say something and then taking it away leaves
|
|
76
|
+
* the reader with nothing to act on.
|
|
77
|
+
*/
|
|
78
|
+
const live = $derived((t === 'danger' || t === 'warn') && delay <= 0);
|
|
70
79
|
</script>
|
|
71
80
|
|
|
72
81
|
{#if visible}
|
|
73
82
|
<!-- The timer stops while the pointer is over the banner or focus is inside
|
|
74
83
|
it, and starts again when they leave. Text is never pulled away from
|
|
75
|
-
somebody reading it.
|
|
84
|
+
somebody reading it. It leaves on the toast preset, as Alert does,
|
|
85
|
+
rather than vanishing in one frame. -->
|
|
76
86
|
<div
|
|
77
|
-
|
|
87
|
+
transition:motion.toast|global
|
|
88
|
+
role={live ? 'alert' : 'status'}
|
|
89
|
+
aria-live={live ? 'assertive' : 'polite'}
|
|
78
90
|
class="relative flex items-center gap-3 border-b px-4 py-3 text-sm {tones[t]} {cls}"
|
|
79
91
|
onmouseenter={closable ? () => (held = true) : undefined}
|
|
80
92
|
onmouseleave={closable ? () => (held = false) : undefined}
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
>{/if}
|
|
138
138
|
</legend>
|
|
139
139
|
|
|
140
|
-
<div class={choiceGroupList(orientation)}>
|
|
140
|
+
<div class={choiceGroupList(orientation, size, variant)}>
|
|
141
141
|
{#each options as option (option.value)}
|
|
142
142
|
<!--
|
|
143
143
|
The option renders through Checkbox rather than through a copy of it.
|
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
inside is capped by the room and not by the list token, which is shorter
|
|
249
249
|
than six weeks of days, so a short modal scrolls the calendar rather
|
|
250
250
|
than cutting it.
|
|
251
|
+
|
|
252
|
+
Under a finger each day keeps its 32px face and a 44px hit box, so the
|
|
253
|
+
days have to sit 44px apart or each box covers a third of its
|
|
254
|
+
neighbors'. Seven 44px columns are 308px. Inside a 1px border and 6px
|
|
255
|
+
of padding that is a 320px panel, the width of the narrowest phone,
|
|
256
|
+
and placePanel slides it back on screen from the page gutter. The rows
|
|
257
|
+
open a 12px gap for the same reason.
|
|
251
258
|
-->
|
|
252
259
|
<div
|
|
253
260
|
id={calendarId}
|
|
@@ -255,9 +262,12 @@
|
|
|
255
262
|
aria-label="Choose date"
|
|
256
263
|
use:placePanel
|
|
257
264
|
transition:motion.popover|global
|
|
258
|
-
class="{PANEL_SURFACE} w-68"
|
|
265
|
+
class="{PANEL_SURFACE} w-68 coarse:w-80"
|
|
259
266
|
>
|
|
260
|
-
<div
|
|
267
|
+
<div
|
|
268
|
+
class="overflow-y-auto overscroll-contain p-3 coarse:p-1.5"
|
|
269
|
+
data-panel-list={PANEL_LIST_UNCAPPED}
|
|
270
|
+
>
|
|
261
271
|
<!-- Header -->
|
|
262
272
|
<div class="flex items-center justify-between mb-2">
|
|
263
273
|
<button
|
|
@@ -303,7 +313,7 @@
|
|
|
303
313
|
</div>
|
|
304
314
|
|
|
305
315
|
<!-- Day grid -->
|
|
306
|
-
<div class="grid grid-cols-7 gap-0.5">
|
|
316
|
+
<div class="grid grid-cols-7 gap-0.5 coarse:gap-y-3" data-day-grid>
|
|
307
317
|
{#each cells as d}
|
|
308
318
|
{#if d === null}
|
|
309
319
|
<span></span>
|
|
@@ -54,10 +54,13 @@
|
|
|
54
54
|
<!-- The dashed border is the only thing marking this region as a control, so
|
|
55
55
|
it rests on line-strong: line reads 1.25:1 and failed SC 1.4.11. The
|
|
56
56
|
hover is full-strength brand, because a focus-adjacent color dropped to
|
|
57
|
-
half alpha reads as a weaker affordance for no reason a user can infer.
|
|
57
|
+
half alpha reads as a weaker affordance for no reason a user can infer.
|
|
58
|
+
The input is sr-only, so the ring the browser draws on it lands on a
|
|
59
|
+
clipped 1px box nobody sees. The drop target draws it instead. -->
|
|
58
60
|
<label
|
|
59
61
|
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-xl
|
|
60
62
|
border-2 border-dashed px-4 py-8 text-center transition-colors
|
|
63
|
+
has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-brand
|
|
61
64
|
{error
|
|
62
65
|
? 'border-danger bg-danger/5 hover:bg-danger/8'
|
|
63
66
|
: dragOver
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
FIELD_HINT,
|
|
16
16
|
FIELD_LABEL,
|
|
17
17
|
FIELD_WRAP,
|
|
18
|
-
controlBorder,
|
|
19
18
|
describedBy,
|
|
20
19
|
} from '../internal/field.js';
|
|
21
20
|
import { createListbox } from '../internal/listbox.svelte.js';
|
|
@@ -112,8 +111,12 @@
|
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
function remove(v: string): void {
|
|
114
|
+
if (disabled) return;
|
|
115
115
|
value = value.filter((x) => x !== v);
|
|
116
116
|
onchange?.(value);
|
|
117
|
+
// The pressed button leaves with its chip, and focus would fall to the
|
|
118
|
+
// page. The field is where the reader was.
|
|
119
|
+
triggerEl?.focus();
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
function search(event: Event & { currentTarget: HTMLInputElement }): void {
|
|
@@ -126,12 +129,14 @@
|
|
|
126
129
|
box.openList();
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
function onFrameClick(): void {
|
|
133
|
+
if (disabled) return;
|
|
134
|
+
triggerEl?.focus();
|
|
135
|
+
box.toggle();
|
|
136
|
+
}
|
|
137
|
+
|
|
129
138
|
function onTriggerKeydown(event: KeyboardEvent): void {
|
|
130
139
|
if (disabled) return;
|
|
131
|
-
// A chip's remove button sits inside the trigger, so its keys bubble
|
|
132
|
-
// through here. Taken as the trigger's own they would open the panel and
|
|
133
|
-
// preventDefault the click that removes the chip.
|
|
134
|
-
if (event.target !== event.currentTarget) return;
|
|
135
140
|
if (box.onkeydown(event)) return;
|
|
136
141
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
137
142
|
|
|
@@ -168,89 +173,107 @@
|
|
|
168
173
|
|
|
169
174
|
<div class="relative" use:anchor>
|
|
170
175
|
<!--
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
out
|
|
175
|
-
|
|
176
|
+
The frame draws the field. The chips and the combobox sit inside it side
|
|
177
|
+
by side, so no remove button is a control nested inside another control,
|
|
178
|
+
which a screen reader reads as one widget and a keyboard cannot sort
|
|
179
|
+
out. A press anywhere on the frame that is not a remove button opens the
|
|
180
|
+
list from the combobox, as a press on the field always did.
|
|
176
181
|
-->
|
|
177
182
|
<div
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
role="combobox"
|
|
181
|
-
tabindex={disabled ? -1 : 0}
|
|
182
|
-
aria-disabled={disabled ? 'true' : undefined}
|
|
183
|
-
aria-labelledby={label ? `${fieldId}-label` : undefined}
|
|
184
|
-
aria-required={required ? 'true' : undefined}
|
|
185
|
-
aria-describedby={describedBy(fieldId, error, hint)}
|
|
186
|
-
onclick={() => {
|
|
187
|
-
if (!disabled) box.toggle();
|
|
188
|
-
}}
|
|
189
|
-
onkeydown={onTriggerKeydown}
|
|
190
|
-
{...box.triggerAttrs}
|
|
183
|
+
role="presentation"
|
|
184
|
+
onclick={onFrameClick}
|
|
191
185
|
class="flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-lg
|
|
192
186
|
border bg-surface-2 px-2.5 py-1.5 text-start text-sm transition-colors
|
|
193
|
-
|
|
187
|
+
has-[[role=combobox]:focus-visible]:ring-2 has-[[role=combobox]:focus-visible]:ring-inset
|
|
188
|
+
has-[[role=combobox]:focus-visible]:ring-brand
|
|
189
|
+
{disabled ? 'cursor-not-allowed opacity-50' : ''}
|
|
190
|
+
{error ? 'border-danger' : 'border-line-strong has-[[role=combobox]:focus]:border-brand'}"
|
|
194
191
|
>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
192
|
+
<!--
|
|
193
|
+
A div and not an input: there is nothing to type here. The labels of
|
|
194
|
+
the chosen values are its text, so focusing it reads the label and
|
|
195
|
+
then what is chosen, as a select reads its value. It comes first in
|
|
196
|
+
the markup, so Tab reaches the field before any remove button, and
|
|
197
|
+
order-last draws it after the chips.
|
|
198
|
+
-->
|
|
199
|
+
<div
|
|
200
|
+
bind:this={triggerEl}
|
|
201
|
+
id={fieldId}
|
|
202
|
+
role="combobox"
|
|
203
|
+
tabindex={disabled ? -1 : 0}
|
|
204
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
205
|
+
aria-labelledby={label ? `${fieldId}-label` : undefined}
|
|
206
|
+
aria-required={required ? 'true' : undefined}
|
|
207
|
+
aria-describedby={describedBy(fieldId, error, hint)}
|
|
208
|
+
onkeydown={onTriggerKeydown}
|
|
209
|
+
{...box.triggerAttrs}
|
|
210
|
+
class="order-last flex min-w-12 flex-1 items-center gap-1.5 self-stretch outline-none"
|
|
211
|
+
>
|
|
212
|
+
{#if selected.length === 0}
|
|
213
|
+
<span class="px-0.5 text-faint">{placeholder}</span>
|
|
214
|
+
{:else}
|
|
215
|
+
<span class="sr-only">{selected.map((option) => option.label).join(', ')}</span>
|
|
216
|
+
{/if}
|
|
217
|
+
<span class="ms-auto shrink-0 text-faint" aria-hidden="true">
|
|
218
|
+
<svg
|
|
219
|
+
width="12"
|
|
220
|
+
height="12"
|
|
221
|
+
viewBox="0 0 12 12"
|
|
222
|
+
fill="none"
|
|
223
|
+
class="transition-transform duration-base {box.open ? 'rotate-180' : ''}"
|
|
201
224
|
>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
exception, and this one sits inside the trigger. The padding is
|
|
216
|
-
taken straight back off as margin, so the chip is laid out
|
|
217
|
-
exactly as it was and only the reachable area grew. The glyph
|
|
218
|
-
is the same 12px cross Autocomplete clears its value with. It
|
|
219
|
-
was drawn a pixel smaller here for no reason anyone recorded.
|
|
220
|
-
-->
|
|
221
|
-
<svg
|
|
222
|
-
width="12"
|
|
223
|
-
height="12"
|
|
224
|
-
viewBox="0 0 24 24"
|
|
225
|
-
fill="none"
|
|
226
|
-
stroke="currentColor"
|
|
227
|
-
stroke-width="2.5"
|
|
228
|
-
stroke-linecap="round"
|
|
229
|
-
aria-hidden="true"
|
|
230
|
-
>
|
|
231
|
-
<path d="M18 6L6 18M6 6l12 12" />
|
|
232
|
-
</svg>
|
|
233
|
-
</button>
|
|
234
|
-
</span>
|
|
235
|
-
{/each}
|
|
236
|
-
{/if}
|
|
237
|
-
<span class="ms-auto shrink-0 text-faint" aria-hidden="true">
|
|
238
|
-
<svg
|
|
239
|
-
width="12"
|
|
240
|
-
height="12"
|
|
241
|
-
viewBox="0 0 12 12"
|
|
242
|
-
fill="none"
|
|
243
|
-
class="transition-transform duration-base {box.open ? 'rotate-180' : ''}"
|
|
225
|
+
<path
|
|
226
|
+
d="M2 4l4 4 4-4"
|
|
227
|
+
stroke="currentColor"
|
|
228
|
+
stroke-width="1.5"
|
|
229
|
+
stroke-linecap="round"
|
|
230
|
+
stroke-linejoin="round"
|
|
231
|
+
/>
|
|
232
|
+
</svg>
|
|
233
|
+
</span>
|
|
234
|
+
</div>
|
|
235
|
+
{#each selected as option (option.value)}
|
|
236
|
+
<span
|
|
237
|
+
class="inline-flex items-center gap-1 rounded-md bg-brand/10 px-2 py-0.5 text-xs text-brand"
|
|
244
238
|
>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
239
|
+
<!-- The combobox reads the chosen labels, so the chip's copy is
|
|
240
|
+
hidden rather than read twice. Its button names the value. -->
|
|
241
|
+
<span aria-hidden="true">{option.label}</span>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
aria-label="Remove {option.label}"
|
|
245
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
246
|
+
tabindex={disabled ? -1 : undefined}
|
|
247
|
+
onclick={(event) => {
|
|
248
|
+
event.stopPropagation();
|
|
249
|
+
remove(option.value);
|
|
250
|
+
}}
|
|
251
|
+
class="{HIT_AREA} -m-1.5 p-1.5 transition-colors hover:text-brand-light"
|
|
252
|
+
>
|
|
253
|
+
<!--
|
|
254
|
+
12px inside 6px of padding is a 24px target, which is the floor
|
|
255
|
+
SC 2.5.8 puts on a control that cannot claim the spacing
|
|
256
|
+
exception, and this one sits inside the field. The padding is
|
|
257
|
+
taken straight back off as margin, so the chip is laid out
|
|
258
|
+
exactly as it was and only the reachable area grew. The glyph
|
|
259
|
+
is the same 12px cross Autocomplete clears its value with. It
|
|
260
|
+
was drawn a pixel smaller here for no reason anyone recorded.
|
|
261
|
+
-->
|
|
262
|
+
<svg
|
|
263
|
+
width="12"
|
|
264
|
+
height="12"
|
|
265
|
+
viewBox="0 0 24 24"
|
|
266
|
+
fill="none"
|
|
267
|
+
stroke="currentColor"
|
|
268
|
+
stroke-width="2.5"
|
|
269
|
+
stroke-linecap="round"
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
>
|
|
272
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
273
|
+
</svg>
|
|
274
|
+
</button>
|
|
275
|
+
</span>
|
|
276
|
+
{/each}
|
|
254
277
|
</div>
|
|
255
278
|
|
|
256
279
|
{#if box.open}
|
|
@@ -271,7 +294,7 @@
|
|
|
271
294
|
{...box.triggerAttrs}
|
|
272
295
|
class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
|
|
273
296
|
text-sm text-fg outline-none transition-colors
|
|
274
|
-
placeholder:text-faint focus:border-brand"
|
|
297
|
+
placeholder:text-faint focus:border-brand focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
275
298
|
/>
|
|
276
299
|
</div>
|
|
277
300
|
{/if}
|
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
127
127
|
class="min-w-0 flex-1 border-y bg-surface-2 text-center text-sm text-fg
|
|
128
128
|
outline-none transition-colors
|
|
129
|
+
focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
|
|
129
130
|
disabled:cursor-not-allowed disabled:opacity-50
|
|
130
131
|
[appearance:textfield]
|
|
131
132
|
[&::-webkit-inner-spin-button]:appearance-none
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
130
130
|
</legend>
|
|
131
131
|
|
|
132
|
-
<div class={choiceGroupList(orientation)}>
|
|
132
|
+
<div class={choiceGroupList(orientation, size, variant)}>
|
|
133
133
|
{#each options as option (option.value)}
|
|
134
134
|
<!--
|
|
135
135
|
The option renders through Radio rather than through a copy of it. The
|
|
@@ -373,7 +373,7 @@
|
|
|
373
373
|
{...box.triggerAttrs}
|
|
374
374
|
class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
|
|
375
375
|
text-sm text-fg outline-none transition-colors
|
|
376
|
-
placeholder:text-faint focus:border-brand"
|
|
376
|
+
placeholder:text-faint focus:border-brand focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
377
377
|
/>
|
|
378
378
|
</div>
|
|
379
379
|
{/if}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { activeTrail, type NavNode, type NavTree } from '../internal/nav-tree.js';
|
|
3
3
|
import { TOUCH_GROW } from '../internal/touch.js';
|
|
4
4
|
import { createNavExpansion } from '../internal/nav-expansion.svelte.js';
|
|
5
|
-
import type
|
|
5
|
+
import { ROW_CHIP_TONE, type AccentTone } from '../internal/tone.js';
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
8
8
|
items: NavTree;
|
|
@@ -107,11 +107,7 @@
|
|
|
107
107
|
|
|
108
108
|
const BADGE_TONE: Record<AccentTone, string> = {
|
|
109
109
|
neutral: 'bg-surface-2 text-faint',
|
|
110
|
-
|
|
111
|
-
success: 'bg-success/15 text-success',
|
|
112
|
-
warn: 'bg-warn/15 text-warn',
|
|
113
|
-
danger: 'bg-danger/15 text-danger',
|
|
114
|
-
violet: 'bg-violet/15 text-violet',
|
|
110
|
+
...ROW_CHIP_TONE,
|
|
115
111
|
};
|
|
116
112
|
|
|
117
113
|
const BADGE = 'ms-auto shrink-0 rounded-full px-1.5 py-0.5 text-xs font-medium';
|
|
@@ -108,11 +108,13 @@
|
|
|
108
108
|
{#if tab.count !== undefined}
|
|
109
109
|
<!-- text-xs, not 10px: the count was the one text on a measured
|
|
110
110
|
page under the 12px floor, and it is the one figure a tab
|
|
111
|
-
carries.
|
|
111
|
+
carries. The active tint is the 10 percent every brand chip
|
|
112
|
+
uses: at 40 percent it dragged the ground toward the text and
|
|
113
|
+
the count read 3.28:1. -->
|
|
112
114
|
<span
|
|
113
115
|
class="ms-1.5 inline-flex items-center justify-center min-w-4.5 h-4.5 px-1
|
|
114
116
|
rounded-full text-xs font-semibold
|
|
115
|
-
{active === tab.id ? 'bg-brand/
|
|
117
|
+
{active === tab.id ? 'bg-brand/10 text-brand' : 'bg-surface-2 text-faint'}"
|
|
116
118
|
>
|
|
117
119
|
{tab.count}
|
|
118
120
|
</span>
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
toISOTime,
|
|
20
20
|
} from '../internal/time.js';
|
|
21
21
|
import type { TimeParts, TimeSegment } from '../internal/time.js';
|
|
22
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* A time field built from one input per segment, and deliberately not from a
|
|
@@ -386,6 +387,46 @@
|
|
|
386
387
|
commit();
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
/** Whether the press now under way started inside a segment. */
|
|
391
|
+
let pressInSegment = false;
|
|
392
|
+
|
|
393
|
+
function onGroupPointerdown(e: PointerEvent): void {
|
|
394
|
+
pressInSegment = !!(e.target as Element | null)?.closest('[data-segment]');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A press on the field's padding, on a colon or on a segment's hit box
|
|
399
|
+
* focuses the nearest segment, as a native time input does. The group is
|
|
400
|
+
* 44px tall under a finger and its segments are 20px, so without this a tap
|
|
401
|
+
* on most of the field focused nothing. A mouse gets the same, because the
|
|
402
|
+
* box it clicks is the box that looks like the field.
|
|
403
|
+
*/
|
|
404
|
+
function onGroupClick(e: MouseEvent): void {
|
|
405
|
+
// A drag that selects digits in a segment and ends on the padding fires
|
|
406
|
+
// its click on the group, the nearest element both ends share. Where the
|
|
407
|
+
// press started tells that drag from a press on the padding. Which
|
|
408
|
+
// element holds focus does not: iOS Safari keeps a focused input focused
|
|
409
|
+
// when a finger taps something that cannot take focus, so a guard on
|
|
410
|
+
// focus would refuse a tap on the next segment's box. A click from the
|
|
411
|
+
// keyboard has no press before it, and the flag is reset by every click.
|
|
412
|
+
const startedInSegment = pressInSegment;
|
|
413
|
+
pressInSegment = false;
|
|
414
|
+
if (disabled || !group) return;
|
|
415
|
+
if ((e.target as Element | null)?.closest('[data-segment]')) return;
|
|
416
|
+
if (startedInSegment) return;
|
|
417
|
+
let nearest: HTMLElement | null = null;
|
|
418
|
+
let best = Infinity;
|
|
419
|
+
for (const el of group.querySelectorAll<HTMLElement>('[data-segment]')) {
|
|
420
|
+
const box = el.getBoundingClientRect();
|
|
421
|
+
const distance = Math.max(box.left - e.clientX, 0, e.clientX - box.right);
|
|
422
|
+
if (distance < best) {
|
|
423
|
+
best = distance;
|
|
424
|
+
nearest = el;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
nearest?.focus();
|
|
428
|
+
}
|
|
429
|
+
|
|
389
430
|
function onMeridiemKeydown(e: KeyboardEvent): void {
|
|
390
431
|
// Left and right belong to the field: they walk the segments. Up and down
|
|
391
432
|
// step the value as they do on every other segment, and the first letter
|
|
@@ -414,44 +455,63 @@
|
|
|
414
455
|
</label>
|
|
415
456
|
{/if}
|
|
416
457
|
|
|
458
|
+
<!--
|
|
459
|
+
Under a finger each segment takes a 44px hit box round its 28px or 32px
|
|
460
|
+
visual. The segments spread apart there so their centers sit 44px apart
|
|
461
|
+
and no box takes its neighbor's: a 4px gap each side of a colon held at
|
|
462
|
+
8px, and 10px before AM or PM. The field grows 8px wider, more with
|
|
463
|
+
seconds or AM and PM, and a mouse sees it as it was.
|
|
464
|
+
|
|
465
|
+
The press and the click on the group only move focus to a segment, which
|
|
466
|
+
Tab and the label already reach, so they need no key handler of their own.
|
|
467
|
+
-->
|
|
468
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
417
469
|
<div
|
|
418
470
|
bind:this={group}
|
|
419
471
|
role="group"
|
|
420
472
|
aria-labelledby={label ? `${fieldId}-label` : undefined}
|
|
421
473
|
aria-label={label ? undefined : 'Time'}
|
|
422
474
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
423
|
-
|
|
475
|
+
onpointerdown={onGroupPointerdown}
|
|
476
|
+
onclick={onGroupClick}
|
|
477
|
+
class="{CONTROL_SEGMENTED} coarse:gap-1 {segmentedBorder(!!error)} {disabled
|
|
424
478
|
? 'cursor-not-allowed opacity-50'
|
|
425
479
|
: ''}"
|
|
426
480
|
>
|
|
427
481
|
{#each digitSegments as segment, i (segment)}
|
|
428
482
|
{#if i > 0}
|
|
429
|
-
<span aria-hidden="true" class="text-faint">:</span>
|
|
483
|
+
<span aria-hidden="true" class="text-faint coarse:w-2 coarse:text-center">:</span>
|
|
430
484
|
{/if}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
485
|
+
<!-- An input draws no pseudo-element, so the hit box sits on a span
|
|
486
|
+
round it, and the group's click handler focuses the input a tap on
|
|
487
|
+
the box lands beside. A label would do it without script, but it
|
|
488
|
+
would be a second label in the field for every segment. -->
|
|
489
|
+
<span data-segment-box class="{HIT_AREA} inline-flex">
|
|
490
|
+
<input
|
|
491
|
+
data-segment={segment}
|
|
492
|
+
id="{fieldId}-{segment}"
|
|
493
|
+
type="text"
|
|
494
|
+
inputmode="numeric"
|
|
495
|
+
role="spinbutton"
|
|
496
|
+
autocomplete="off"
|
|
497
|
+
spellcheck="false"
|
|
498
|
+
maxlength="2"
|
|
499
|
+
{disabled}
|
|
500
|
+
value={shown(segment)}
|
|
501
|
+
aria-label={SEGMENT_LABEL[segment]}
|
|
502
|
+
aria-valuenow={shownNow(segment)}
|
|
503
|
+
aria-valuemin={segmentMin(segment)}
|
|
504
|
+
aria-valuemax={segmentMax(segment)}
|
|
505
|
+
aria-valuetext={valueText(segment)}
|
|
506
|
+
aria-invalid={error ? 'true' : undefined}
|
|
507
|
+
aria-required={required ? 'true' : undefined}
|
|
508
|
+
onkeydown={(e) => onSegmentKeydown(segment, e)}
|
|
509
|
+
oninput={(e) => onSegmentInput(segment, e)}
|
|
510
|
+
onblur={() => onSegmentBlur(segment)}
|
|
511
|
+
onfocus={(e) => e.currentTarget.select()}
|
|
512
|
+
class="{CONTROL_SEGMENT} w-7"
|
|
513
|
+
/>
|
|
514
|
+
</span>
|
|
455
515
|
{/each}
|
|
456
516
|
|
|
457
517
|
{#if hour12}
|
|
@@ -473,7 +533,7 @@
|
|
|
473
533
|
aria-required={required ? 'true' : undefined}
|
|
474
534
|
onclick={() => setMeridiem(meridiem === 'AM' ? 'PM' : 'AM')}
|
|
475
535
|
onkeydown={onMeridiemKeydown}
|
|
476
|
-
class="{CONTROL_SEGMENT} ms-1 w-8 cursor-pointer"
|
|
536
|
+
class="{HIT_AREA} {CONTROL_SEGMENT} ms-1 coarse:ms-2.5 w-8 cursor-pointer"
|
|
477
537
|
>
|
|
478
538
|
{meridiem}
|
|
479
539
|
</button>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
type TreeNode,
|
|
10
10
|
type TriState,
|
|
11
11
|
} from '../internal/tree.js';
|
|
12
|
+
import { TOUCH_GROW } from '../internal/touch.js';
|
|
12
13
|
|
|
13
14
|
interface Props {
|
|
14
15
|
nodes: TreeNode<T>[];
|
|
@@ -42,6 +43,17 @@
|
|
|
42
43
|
let activeId: string | undefined = $state(undefined);
|
|
43
44
|
|
|
44
45
|
const expandedSet = $derived(new Set(expanded));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Branches whose children have been shown once. They stay mounted after a
|
|
49
|
+
* collapse so the collapse can animate. A branch nobody opened mounts
|
|
50
|
+
* nothing, so a tree of thousands of nodes renders what a reader opened and
|
|
51
|
+
* not the whole tree.
|
|
52
|
+
*/
|
|
53
|
+
const shown = $state<Record<string, boolean>>({});
|
|
54
|
+
$effect.pre(() => {
|
|
55
|
+
for (const id of expandedSet) shown[id] = true;
|
|
56
|
+
});
|
|
45
57
|
const checkedSet = $derived(new Set(checked));
|
|
46
58
|
|
|
47
59
|
/**
|
|
@@ -267,10 +279,13 @@
|
|
|
267
279
|
<!--
|
|
268
280
|
Indentation is a style attribute reading the spacing tokens. A class
|
|
269
281
|
built by interpolating the depth matches no candidate in Tailwind's
|
|
270
|
-
scan, so no rule is generated and every row draws flush left.
|
|
282
|
+
scan, so no rule is generated and every row draws flush left. Under a
|
|
283
|
+
finger the 32px row grows to the control height, as a SidebarNav row
|
|
284
|
+
does, and drops its padding, which would stand a row with a 44px
|
|
285
|
+
check slot 12px taller than the rest.
|
|
271
286
|
-->
|
|
272
287
|
<div
|
|
273
|
-
class="flex w-full items-center gap-2 rounded-md py-1.5 pe-2 text-sm transition-colors {rowPaint(
|
|
288
|
+
class="{TOUCH_GROW} flex w-full items-center gap-2 rounded-md py-1.5 pe-2 text-sm coarse:py-0 transition-colors {rowPaint(
|
|
274
289
|
selected === node.id,
|
|
275
290
|
!!node.disabled,
|
|
276
291
|
)}"
|
|
@@ -295,8 +310,11 @@
|
|
|
295
310
|
A painted box rather than an input: the treeitem itself carries
|
|
296
311
|
aria-checked, and a focusable control inside the row would put a
|
|
297
312
|
second tab stop in a widget whose whole point is that it has one.
|
|
313
|
+
Under a finger the slot grows to a 44px square around the 14px
|
|
314
|
+
box. A press inside it checks, and the rest of the row activates,
|
|
315
|
+
so the two targets sit side by side and neither covers the other.
|
|
298
316
|
-->
|
|
299
|
-
<span data-tree-check class="flex shrink-0 items-center">
|
|
317
|
+
<span data-tree-check class="{TOUCH_GROW} flex shrink-0 items-center justify-center">
|
|
300
318
|
<span class={choiceBox('checkbox', 'sm', state === 'all', state === 'some')}>
|
|
301
319
|
{#if state !== 'none'}
|
|
302
320
|
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
|
|
@@ -325,10 +343,26 @@
|
|
|
325
343
|
{/if}
|
|
326
344
|
</div>
|
|
327
345
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
346
|
+
<!--
|
|
347
|
+
A branch opens and shuts on grid-template-rows, the AccordionItem
|
|
348
|
+
collapse, so its children slide rather than appear in one frame. Shut,
|
|
349
|
+
they are inert: out of the tab order and the accessibility tree while
|
|
350
|
+
they stay mounted for the collapse to play.
|
|
351
|
+
-->
|
|
352
|
+
{#if isBranch(node)}
|
|
353
|
+
<div
|
|
354
|
+
class="grid transition-[grid-template-rows] duration-base {open
|
|
355
|
+
? 'grid-rows-[1fr]'
|
|
356
|
+
: 'grid-rows-[0fr]'}"
|
|
357
|
+
>
|
|
358
|
+
<div class="overflow-hidden">
|
|
359
|
+
{#if open || shown[node.id]}
|
|
360
|
+
<ul role="group" inert={!open}>
|
|
361
|
+
{@render branch(node.children ?? [], depth + 1, node.id)}
|
|
362
|
+
</ul>
|
|
363
|
+
{/if}
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
332
366
|
{/if}
|
|
333
367
|
</li>
|
|
334
368
|
{/each}
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
<div
|
|
79
79
|
class="flex items-center justify-center w-10 h-10 rounded-full bg-warn/10 text-warn shrink-0"
|
|
80
80
|
>
|
|
81
|
-
<TriangleAlert
|
|
81
|
+
<TriangleAlert size={20} />
|
|
82
82
|
</div>
|
|
83
83
|
<div class="flex-1 min-w-0">
|
|
84
84
|
<p class="text-sm text-fg font-medium">{title}</p>
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { DialogEntry } from './types.js';
|
|
15
15
|
import { sizeClass } from './types.js';
|
|
16
16
|
import { overlay } from '../../internal/overlay.js';
|
|
17
|
+
import { HIT_AREA } from '../../internal/touch.js';
|
|
17
18
|
import * as motion from '../../motion.js';
|
|
18
19
|
import { closeDialog, dismissDialog } from './dialog-manager.svelte';
|
|
19
20
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
@@ -139,13 +140,16 @@
|
|
|
139
140
|
</div>
|
|
140
141
|
|
|
141
142
|
{#if !entry.options.persistent}
|
|
143
|
+
<!-- The 32px face keeps its 44px hit box under a finger, as Modal's
|
|
144
|
+
and Drawer's close crosses do. -->
|
|
142
145
|
<button
|
|
143
|
-
|
|
146
|
+
type="button"
|
|
147
|
+
class="{HIT_AREA} inline-flex items-center justify-center w-8 h-8 -me-2 rounded-lg
|
|
144
148
|
text-muted hover:text-fg hover:bg-surface-2 transition-colors shrink-0"
|
|
145
149
|
onclick={() => handleClose()}
|
|
146
150
|
aria-label="Close"
|
|
147
151
|
>
|
|
148
|
-
<X
|
|
152
|
+
<X size={16} />
|
|
149
153
|
</button>
|
|
150
154
|
{/if}
|
|
151
155
|
</div>
|
package/dist/index.d.ts
CHANGED
|
@@ -103,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
|
|
|
103
103
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
104
104
|
export * as motion from './motion.js';
|
|
105
105
|
export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
|
|
106
|
-
export declare const VERSION = "0.38.
|
|
106
|
+
export declare const VERSION = "0.38.4";
|
package/dist/index.js
CHANGED
|
@@ -116,4 +116,4 @@ export * as motion from './motion.js';
|
|
|
116
116
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
117
117
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
118
118
|
// this line. The build and the test suite fail when the two disagree.
|
|
119
|
-
export const VERSION = '0.38.
|
|
119
|
+
export const VERSION = '0.38.4';
|
|
@@ -127,8 +127,13 @@ export declare const CHOICE_LEGEND = "text-sm font-medium text-fg mb-1.5";
|
|
|
127
127
|
* A horizontal group gets a wider inline gap than block gap: options read as
|
|
128
128
|
* separate choices across a row and as one list down a column, and an equal gap
|
|
129
129
|
* in both axes makes a wrapped row look like a grid.
|
|
130
|
+
*
|
|
131
|
+
* Under a finger the rows of an inline group move apart until their 44px hit
|
|
132
|
+
* boxes meet. At the 8px a mouse keeps, each box covered a third of the row
|
|
133
|
+
* below it, and the later row takes an overlap, so a tap on the bottom of one
|
|
134
|
+
* option picked the next one. A card is taller than the box and keeps its gap.
|
|
130
135
|
*/
|
|
131
|
-
export declare function choiceGroupList(orientation: ChoiceOrientation): string;
|
|
136
|
+
export declare function choiceGroupList(orientation: ChoiceOrientation, size: ChoiceSize, variant: ChoiceVariant): string;
|
|
132
137
|
/**
|
|
133
138
|
* The check and the mixed marks, as SVG path data on the box's own grid.
|
|
134
139
|
*
|
package/dist/internal/choice.js
CHANGED
|
@@ -173,12 +173,28 @@ export const CHOICE_LEGEND = `${FIELD_LABEL} mb-1.5`;
|
|
|
173
173
|
* A horizontal group gets a wider inline gap than block gap: options read as
|
|
174
174
|
* separate choices across a row and as one list down a column, and an equal gap
|
|
175
175
|
* in both axes makes a wrapped row look like a grid.
|
|
176
|
+
*
|
|
177
|
+
* Under a finger the rows of an inline group move apart until their 44px hit
|
|
178
|
+
* boxes meet. At the 8px a mouse keeps, each box covered a third of the row
|
|
179
|
+
* below it, and the later row takes an overlap, so a tap on the bottom of one
|
|
180
|
+
* option picked the next one. A card is taller than the box and keeps its gap.
|
|
176
181
|
*/
|
|
177
|
-
export function choiceGroupList(orientation) {
|
|
178
|
-
|
|
182
|
+
export function choiceGroupList(orientation, size, variant) {
|
|
183
|
+
const list = orientation === 'horizontal'
|
|
179
184
|
? 'flex flex-row flex-wrap gap-x-5 gap-y-2'
|
|
180
185
|
: 'flex flex-col gap-2';
|
|
186
|
+
return variant === 'inline' ? `${list} ${COARSE_ROW_GAP[size]}` : list;
|
|
181
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* 44px less the label line an inline row is as tall as: 16, 20 or 24px.
|
|
190
|
+
* Spelled out per size because Tailwind generates only the class names it
|
|
191
|
+
* finds written whole.
|
|
192
|
+
*/
|
|
193
|
+
const COARSE_ROW_GAP = {
|
|
194
|
+
sm: 'coarse:gap-y-7',
|
|
195
|
+
md: 'coarse:gap-y-6',
|
|
196
|
+
lg: 'coarse:gap-y-5',
|
|
197
|
+
};
|
|
182
198
|
/**
|
|
183
199
|
* The check and the mixed marks, as SVG path data on the box's own grid.
|
|
184
200
|
*
|
package/dist/internal/panel.d.ts
CHANGED
|
@@ -70,7 +70,8 @@ export declare const PANEL_LIST = "max-h-panel-max overflow-y-auto overscroll-co
|
|
|
70
70
|
* No background and no text color of its own: it inherits both from the
|
|
71
71
|
* surface, which leaves each state below a single utility to override.
|
|
72
72
|
* rounded-lg, one step inside the surface's rounded-xl, so the hover tint and
|
|
73
|
-
* the active ring follow the row's own corners.
|
|
73
|
+
* the active ring follow the row's own corners. Under a finger the 36px row
|
|
74
|
+
* grows to the control height, as Dropdown's menu item, the same row, does.
|
|
74
75
|
*/
|
|
75
76
|
export declare const PANEL_OPTION: string;
|
|
76
77
|
/**
|
|
@@ -157,6 +158,10 @@ export declare const PANEL_ABOVE = "bottom-full mb-1 origin-bottom";
|
|
|
157
158
|
* the cap without this code knowing its value. A region marked uncapped takes
|
|
158
159
|
* the room alone.
|
|
159
160
|
*
|
|
161
|
+
* A panel that would run past the left or the right edge of the clipping
|
|
162
|
+
* ancestor or the viewport slides back inside both, and one wider than that
|
|
163
|
+
* room keeps its start edge in view.
|
|
164
|
+
*
|
|
160
165
|
* Measured on mount, on resize and on scroll of the clipping ancestor, since
|
|
161
166
|
* each moves the anchor relative to the edge. Scroll does not bubble, so the
|
|
162
167
|
* list's own scrolling never re-measures. A server render has no window and
|
package/dist/internal/panel.js
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Not exported from the package entry point: this is an implementation detail.
|
|
40
40
|
*/
|
|
41
|
+
import { TOUCH_GROW } from './touch.js';
|
|
41
42
|
/**
|
|
42
43
|
* The floating surface every popover paints.
|
|
43
44
|
*
|
|
@@ -70,9 +71,10 @@ export const PANEL_LIST = 'max-h-panel-max overflow-y-auto overscroll-contain px
|
|
|
70
71
|
* No background and no text color of its own: it inherits both from the
|
|
71
72
|
* surface, which leaves each state below a single utility to override.
|
|
72
73
|
* rounded-lg, one step inside the surface's rounded-xl, so the hover tint and
|
|
73
|
-
* the active ring follow the row's own corners.
|
|
74
|
+
* the active ring follow the row's own corners. Under a finger the 36px row
|
|
75
|
+
* grows to the control height, as Dropdown's menu item, the same row, does.
|
|
74
76
|
*/
|
|
75
|
-
export const PANEL_OPTION =
|
|
77
|
+
export const PANEL_OPTION = `${TOUCH_GROW} flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-start text-sm ` +
|
|
76
78
|
'transition-colors outline-none hover:bg-surface-2 ' +
|
|
77
79
|
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
|
|
78
80
|
/**
|
|
@@ -195,6 +197,10 @@ function swap(node, remove, add) {
|
|
|
195
197
|
* the cap without this code knowing its value. A region marked uncapped takes
|
|
196
198
|
* the room alone.
|
|
197
199
|
*
|
|
200
|
+
* A panel that would run past the left or the right edge of the clipping
|
|
201
|
+
* ancestor or the viewport slides back inside both, and one wider than that
|
|
202
|
+
* room keeps its start edge in view.
|
|
203
|
+
*
|
|
198
204
|
* Measured on mount, on resize and on scroll of the clipping ancestor, since
|
|
199
205
|
* each moves the anchor relative to the edge. Scroll does not bubble, so the
|
|
200
206
|
* list's own scrolling never re-measures. A server render has no window and
|
|
@@ -215,8 +221,9 @@ export function placePanel(node) {
|
|
|
215
221
|
if (list !== null)
|
|
216
222
|
list.style.maxHeight = '';
|
|
217
223
|
const rect = anchor.getBoundingClientRect();
|
|
218
|
-
const
|
|
219
|
-
const
|
|
224
|
+
const clipBox = clip === null ? null : clip.getBoundingClientRect();
|
|
225
|
+
const clipTop = clipBox?.top ?? 0;
|
|
226
|
+
const clipBottom = clipBox?.bottom ?? window.innerHeight;
|
|
220
227
|
const below = clipBottom - rect.bottom - PANEL_EDGE_GAP;
|
|
221
228
|
const above = rect.top - clipTop - PANEL_EDGE_GAP;
|
|
222
229
|
const natural = node.scrollHeight;
|
|
@@ -225,6 +232,28 @@ export function placePanel(node) {
|
|
|
225
232
|
swap(node, PANEL_BELOW, PANEL_ABOVE);
|
|
226
233
|
else
|
|
227
234
|
swap(node, PANEL_ABOVE, PANEL_BELOW);
|
|
235
|
+
// A panel wider than the room beside its anchor slides back inside what
|
|
236
|
+
// the reader can see, which is the clip and the viewport both: the
|
|
237
|
+
// calendar under a finger is as wide as a 320px phone, and its field
|
|
238
|
+
// starts at the page gutter or inside a modal body's padding. Where it is
|
|
239
|
+
// wider than that room it keeps its start edge in view. Read from the
|
|
240
|
+
// layout box, which the entrance preset's scale does not shrink.
|
|
241
|
+
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
|
242
|
+
const lo = Math.max(0, clipBox?.left ?? 0);
|
|
243
|
+
const hi = Math.min(viewport, clipBox?.right ?? viewport);
|
|
244
|
+
const left = rect.left + node.offsetLeft;
|
|
245
|
+
const right = left + node.offsetWidth;
|
|
246
|
+
let shift = 0;
|
|
247
|
+
if (node.offsetWidth > hi - lo) {
|
|
248
|
+
shift = getComputedStyle(node).direction === 'rtl' ? hi - right : lo - left;
|
|
249
|
+
}
|
|
250
|
+
else if (right > hi) {
|
|
251
|
+
shift = hi - right;
|
|
252
|
+
}
|
|
253
|
+
else if (left < lo) {
|
|
254
|
+
shift = lo - left;
|
|
255
|
+
}
|
|
256
|
+
node.style.translate = shift === 0 ? '' : `${shift}px 0`;
|
|
228
257
|
if (list === null)
|
|
229
258
|
return;
|
|
230
259
|
// What the surface adds around the list, a search field for instance, is
|
package/dist/internal/tone.d.ts
CHANGED
|
@@ -25,3 +25,14 @@ export declare function statusTone(tone: StatusToneInput): StatusTone;
|
|
|
25
25
|
* are stroked SVG.
|
|
26
26
|
*/
|
|
27
27
|
export declare const TONE_GLYPH: Record<StatusTone, string>;
|
|
28
|
+
/**
|
|
29
|
+
* A tinted chip that sits on a row which paints its own highlight: a
|
|
30
|
+
* SidebarNav badge, the AccountMenu initials.
|
|
31
|
+
*
|
|
32
|
+
* A translucent tint lets the row show through, so the 15 percent chip that
|
|
33
|
+
* read 4.5:1 on a resting row dropped to 3.69:1 on a current row under the
|
|
34
|
+
* pointer, where the row turns to line. Mixed into surface instead, the chip
|
|
35
|
+
* is opaque and paints the same on every row state. On a resting row, which
|
|
36
|
+
* is surface, it is the color the translucent tint was.
|
|
37
|
+
*/
|
|
38
|
+
export declare const ROW_CHIP_TONE: Record<Exclude<AccentTone, 'neutral'>, string>;
|
package/dist/internal/tone.js
CHANGED
|
@@ -27,3 +27,20 @@ export const TONE_GLYPH = {
|
|
|
27
27
|
warn: 'M12 8v5M12 17h.01',
|
|
28
28
|
danger: 'M18 6L6 18M6 6l12 12',
|
|
29
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* A tinted chip that sits on a row which paints its own highlight: a
|
|
32
|
+
* SidebarNav badge, the AccountMenu initials.
|
|
33
|
+
*
|
|
34
|
+
* A translucent tint lets the row show through, so the 15 percent chip that
|
|
35
|
+
* read 4.5:1 on a resting row dropped to 3.69:1 on a current row under the
|
|
36
|
+
* pointer, where the row turns to line. Mixed into surface instead, the chip
|
|
37
|
+
* is opaque and paints the same on every row state. On a resting row, which
|
|
38
|
+
* is surface, it is the color the translucent tint was.
|
|
39
|
+
*/
|
|
40
|
+
export const ROW_CHIP_TONE = {
|
|
41
|
+
brand: 'bg-[color-mix(in_srgb,var(--color-brand)_15%,var(--color-surface))] text-brand',
|
|
42
|
+
success: 'bg-[color-mix(in_srgb,var(--color-success)_15%,var(--color-surface))] text-success',
|
|
43
|
+
warn: 'bg-[color-mix(in_srgb,var(--color-warn)_15%,var(--color-surface))] text-warn',
|
|
44
|
+
danger: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,var(--color-surface))] text-danger',
|
|
45
|
+
violet: 'bg-[color-mix(in_srgb,var(--color-violet)_15%,var(--color-surface))] text-violet',
|
|
46
|
+
};
|
package/dist/styles/theme.css
CHANGED
|
@@ -356,8 +356,9 @@
|
|
|
356
356
|
* be positioned (`relative`, or `absolute` where it already is) for the box
|
|
357
357
|
* to center on it. The utility does not set position itself, because a
|
|
358
358
|
* position it set would fight the `absolute` an input's clear button relies
|
|
359
|
-
* on.
|
|
360
|
-
*
|
|
359
|
+
* on. Neighbors are spaced so their boxes meet rather than overlap: the
|
|
360
|
+
* later one in document order takes an overlap, so a tap near the edge of
|
|
361
|
+
* one control lands on the next.
|
|
361
362
|
*/
|
|
362
363
|
@custom-variant coarse (@media (pointer: coarse));
|
|
363
364
|
|
package/package.json
CHANGED
package/src/lib/styles/theme.css
CHANGED
|
@@ -356,8 +356,9 @@
|
|
|
356
356
|
* be positioned (`relative`, or `absolute` where it already is) for the box
|
|
357
357
|
* to center on it. The utility does not set position itself, because a
|
|
358
358
|
* position it set would fight the `absolute` an input's clear button relies
|
|
359
|
-
* on.
|
|
360
|
-
*
|
|
359
|
+
* on. Neighbors are spaced so their boxes meet rather than overlap: the
|
|
360
|
+
* later one in document order takes an overlap, so a tap near the edge of
|
|
361
|
+
* one control lands on the next.
|
|
361
362
|
*/
|
|
362
363
|
@custom-variant coarse (@media (pointer: coarse));
|
|
363
364
|
|