@lyeve-labs/ui-kit 0.38.4 → 0.39.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/dist/components/AppShell.svelte +1 -1
- package/dist/components/AuthShell.svelte +5 -1
- package/dist/components/CopyField.svelte +12 -7
- package/dist/components/DatePicker.svelte +24 -8
- package/dist/components/Drawer.svelte +15 -1
- package/dist/components/Field.svelte +23 -3
- package/dist/components/Field.svelte.d.ts +12 -0
- package/dist/components/Input.svelte +7 -3
- package/dist/components/MultiSelect.svelte +17 -6
- package/dist/components/NumberInput.svelte +15 -7
- package/dist/components/PasswordInput.svelte +6 -4
- package/dist/components/SearchInput.svelte +5 -1
- package/dist/components/SegmentedControl.svelte +11 -4
- package/dist/components/SegmentedControl.svelte.d.ts +5 -0
- package/dist/components/Select.svelte +3 -6
- package/dist/components/Tag.svelte +11 -2
- package/dist/components/Textarea.svelte +7 -3
- package/dist/components/TreeView.svelte +11 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/panel.d.ts +11 -3
- package/dist/internal/panel.js +75 -12
- package/dist/styles/theme.css +5 -2
- package/package.json +1 -1
- package/src/lib/styles/theme.css +5 -2
|
@@ -316,7 +316,7 @@
|
|
|
316
316
|
aria-expanded={!collapsed}
|
|
317
317
|
onclick={() => (collapsed = !collapsed)}
|
|
318
318
|
>
|
|
319
|
-
{#if collapsed}<PanelLeft size={
|
|
319
|
+
{#if collapsed}<PanelLeft size={20} />{:else}<PanelLeftClose size={20} />{/if}
|
|
320
320
|
</button>
|
|
321
321
|
{/if}
|
|
322
322
|
{#if section}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
3
4
|
import { cn } from '../utils/cn.js';
|
|
4
5
|
import Card from './Card.svelte';
|
|
5
6
|
import Logo from './Logo.svelte';
|
|
@@ -67,9 +68,12 @@
|
|
|
67
68
|
|
|
68
69
|
<div class="mb-8 text-center">
|
|
69
70
|
{#if href}
|
|
71
|
+
<!-- The lockup is 36px tall. Under a finger the link takes a 44px
|
|
72
|
+
hit box around it rather than growing, so a mouse sees the
|
|
73
|
+
heading where it was. -->
|
|
70
74
|
<a
|
|
71
75
|
{href}
|
|
72
|
-
class="mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
76
|
+
class="{HIT_AREA} mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
73
77
|
>
|
|
74
78
|
{#if lockup}
|
|
75
79
|
{@render lockup()}
|
|
@@ -99,9 +99,14 @@
|
|
|
99
99
|
inside it, so the caller's actions, the reveal and the copy all fit
|
|
100
100
|
without the value running under them and without measuring anything.
|
|
101
101
|
The frame takes the focus ring and the brand border from the input or
|
|
102
|
-
the textarea inside it. A focused button draws its own ring and not the frame's.
|
|
102
|
+
the textarea inside it. A focused button draws its own ring and not the frame's.
|
|
103
|
+
Under a finger each 27px button takes a 44px box centered on it, so the
|
|
104
|
+
row opens 17px between its items: neighboring boxes meet instead of
|
|
105
|
+
overlapping, and the first box stops short of the value. The buttons
|
|
106
|
+
hold their 27px around a 14px icon, the rung of the text beside it, so
|
|
107
|
+
those boxes stay where they are. -->
|
|
103
108
|
<div
|
|
104
|
-
class="flex w-full gap-0.5 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong {wrapped
|
|
109
|
+
class="flex w-full gap-0.5 coarse:gap-4.25 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong {wrapped
|
|
105
110
|
? 'min-h-control items-start py-1 has-[textarea:focus]:border-brand has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-inset has-[textarea:focus-visible]:ring-brand'
|
|
106
111
|
: 'h-control items-center has-[input:focus]:border-brand has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-inset has-[input:focus-visible]:ring-brand'}"
|
|
107
112
|
>
|
|
@@ -154,12 +159,12 @@
|
|
|
154
159
|
aria-pressed={revealed}
|
|
155
160
|
aria-controls={fieldId}
|
|
156
161
|
aria-label={revealed ? 'Hide value' : 'Show value'}
|
|
157
|
-
class="{HIT_AREA} flex shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
162
|
+
class="{HIT_AREA} flex size-6.75 shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
158
163
|
>
|
|
159
164
|
{#if revealed}
|
|
160
|
-
<EyeOff size={
|
|
165
|
+
<EyeOff size={14} aria-hidden="true" />
|
|
161
166
|
{:else}
|
|
162
|
-
<Eye size={
|
|
167
|
+
<Eye size={14} aria-hidden="true" />
|
|
163
168
|
{/if}
|
|
164
169
|
</button>
|
|
165
170
|
{/if}
|
|
@@ -167,9 +172,9 @@
|
|
|
167
172
|
type="button"
|
|
168
173
|
aria-label={buttonName}
|
|
169
174
|
onclick={() => copier.copy(value)}
|
|
170
|
-
class="{HIT_AREA} flex shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
175
|
+
class="{HIT_AREA} flex size-6.75 shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
171
176
|
>
|
|
172
|
-
<CopyGlyph copied={copier.status === 'copied'} size={
|
|
177
|
+
<CopyGlyph copied={copier.status === 'copied'} size={14} />
|
|
173
178
|
</button>
|
|
174
179
|
</div>
|
|
175
180
|
|
|
@@ -246,15 +246,18 @@
|
|
|
246
246
|
the part of the scroll region nobody could reach and was cut below the
|
|
247
247
|
weekday row. The width stays here, sized to seven columns. The region
|
|
248
248
|
inside is capped by the room and not by the list token, which is shorter
|
|
249
|
-
than six weeks of days,
|
|
250
|
-
|
|
249
|
+
than six weeks of days, and never below the whole grid: in a modal body
|
|
250
|
+
too short for it on either side, placePanel makes room under the field.
|
|
251
251
|
|
|
252
252
|
Under a finger each day keeps its 32px face and a 44px hit box, so the
|
|
253
253
|
days have to sit 44px apart or each box covers a third of its
|
|
254
254
|
neighbors'. Seven 44px columns are 308px. Inside a 1px border and 6px
|
|
255
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.
|
|
257
|
-
|
|
256
|
+
and placePanel slides it back on screen from the page gutter. In a
|
|
257
|
+
modal body narrower than that, placePanel shrinks it to the body and
|
|
258
|
+
the columns close up, but never below the grid: seven 32px days, six
|
|
259
|
+
2px gaps, the padding and the border, or the days would overflow and
|
|
260
|
+
the calendar scroll sideways inside itself. The rows open a 12px gap for the same reason.
|
|
258
261
|
-->
|
|
259
262
|
<div
|
|
260
263
|
id={calendarId}
|
|
@@ -262,14 +265,17 @@
|
|
|
262
265
|
aria-label="Choose date"
|
|
263
266
|
use:placePanel
|
|
264
267
|
transition:motion.popover|global
|
|
265
|
-
class="{PANEL_SURFACE} w-68 coarse:w-80"
|
|
268
|
+
class="{PANEL_SURFACE} w-68 min-w-65.5 coarse:w-80 coarse:min-w-62.5"
|
|
266
269
|
>
|
|
267
270
|
<div
|
|
268
271
|
class="overflow-y-auto overscroll-contain p-3 coarse:p-1.5"
|
|
269
272
|
data-panel-list={PANEL_LIST_UNCAPPED}
|
|
270
273
|
>
|
|
271
|
-
<!-- Header
|
|
272
|
-
|
|
274
|
+
<!-- Header. Under a finger each 26px month step takes a 44px box,
|
|
275
|
+
9px past it, and the region's padding is 6px, so the header
|
|
276
|
+
insets itself by the other 3px at the sides and the top. The
|
|
277
|
+
boxes stay whole inside the calendar, which no longer pans. -->
|
|
278
|
+
<div class="flex items-center justify-between mb-2 coarse:px-0.75 coarse:pt-0.75">
|
|
273
279
|
<button
|
|
274
280
|
type="button"
|
|
275
281
|
aria-label="Previous month"
|
|
@@ -313,7 +319,17 @@
|
|
|
313
319
|
</div>
|
|
314
320
|
|
|
315
321
|
<!-- Day grid -->
|
|
316
|
-
|
|
322
|
+
<!--
|
|
323
|
+
Shrunk into a narrow modal, the columns close below 44px and the
|
|
324
|
+
day boxes would overlap. The grid is a container, so each box
|
|
325
|
+
takes the smaller of the control size and one column's pitch, the
|
|
326
|
+
grid's width and one gap over seven, and keeps its height. The
|
|
327
|
+
boxes meet at any width.
|
|
328
|
+
-->
|
|
329
|
+
<div
|
|
330
|
+
class="@container grid grid-cols-7 gap-0.5 coarse:gap-y-3 coarse:[--hit-width:min(var(--spacing-control),calc((100cqw_+_0.125rem)_/_7))]"
|
|
331
|
+
data-day-grid
|
|
332
|
+
>
|
|
317
333
|
{#each cells as d}
|
|
318
334
|
{#if d === null}
|
|
319
335
|
<span></span>
|
|
@@ -66,6 +66,19 @@
|
|
|
66
66
|
|
|
67
67
|
const rung = $derived(size === 'auto' ? fitOverlay(fields) : size);
|
|
68
68
|
|
|
69
|
+
let frameEl = $state<HTMLDivElement | undefined>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The edge the panel slides from. It docks by the start and end of the
|
|
73
|
+
* line, so `right` sits at the left edge of a right-to-left page, and a
|
|
74
|
+
* slide from the right would cross the whole screen to get there. Read when
|
|
75
|
+
* the panel moves, as AppShell reads its own drawer's.
|
|
76
|
+
*/
|
|
77
|
+
function edge(): Side {
|
|
78
|
+
const rtl = frameEl !== undefined && getComputedStyle(frameEl).direction === 'rtl';
|
|
79
|
+
return (side === 'right') !== rtl ? 'right' : 'left';
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
const headingId = $props.id();
|
|
70
83
|
const descriptionId = `${headingId}-description`;
|
|
71
84
|
|
|
@@ -86,6 +99,7 @@
|
|
|
86
99
|
|
|
87
100
|
{#if open}
|
|
88
101
|
<div
|
|
102
|
+
bind:this={frameEl}
|
|
89
103
|
data-print="hide"
|
|
90
104
|
class="fixed inset-0 z-drawer flex {side === 'right' ? 'justify-end' : 'justify-start'}"
|
|
91
105
|
>
|
|
@@ -100,7 +114,7 @@
|
|
|
100
114
|
|
|
101
115
|
<div
|
|
102
116
|
use:overlay
|
|
103
|
-
transition:motion.drawer|global={{ side }}
|
|
117
|
+
transition:motion.drawer|global={{ side: edge() }}
|
|
104
118
|
class="relative flex h-full w-full flex-col {OVERLAY_WIDTH[rung]} bg-surface shadow-2xl
|
|
105
119
|
{side === 'right' ? 'border-s' : 'border-e'} border-line"
|
|
106
120
|
role="dialog"
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
describedBy: string | undefined;
|
|
22
22
|
/** True while an error is showing, for the control's `aria-invalid`. */
|
|
23
23
|
invalid: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The id of the caption, for a group's `aria-labelledby`. Set only when
|
|
26
|
+
* the field is a `group` with a label.
|
|
27
|
+
*/
|
|
28
|
+
labelledBy: string | undefined;
|
|
24
29
|
}
|
|
25
30
|
</script>
|
|
26
31
|
|
|
@@ -40,6 +45,13 @@
|
|
|
40
45
|
label?: string;
|
|
41
46
|
/** Screen-reader-only label, for a control whose visible name is a table column header. */
|
|
42
47
|
labelHidden?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The control is a group, a `SegmentedControl`, `FilterChips` or a row of
|
|
50
|
+
* your own. A label's `for` reaches one form control and never a group, so
|
|
51
|
+
* the caption is drawn as text with an id instead, and the group takes its
|
|
52
|
+
* name from it through `labelledBy`.
|
|
53
|
+
*/
|
|
54
|
+
group?: boolean;
|
|
43
55
|
hint?: string;
|
|
44
56
|
/** Replaces the hint while it is set, and marks the wiring invalid. */
|
|
45
57
|
error?: string;
|
|
@@ -58,6 +70,7 @@
|
|
|
58
70
|
id = undefined,
|
|
59
71
|
label = undefined,
|
|
60
72
|
labelHidden = false,
|
|
73
|
+
group = false,
|
|
61
74
|
hint = undefined,
|
|
62
75
|
error = undefined,
|
|
63
76
|
required = false,
|
|
@@ -78,6 +91,7 @@
|
|
|
78
91
|
id: fieldId,
|
|
79
92
|
describedBy: describedBy(fieldId, error, hint),
|
|
80
93
|
invalid: !!error,
|
|
94
|
+
labelledBy: group && label ? `${fieldId}-label` : undefined,
|
|
81
95
|
});
|
|
82
96
|
</script>
|
|
83
97
|
|
|
@@ -89,9 +103,15 @@
|
|
|
89
103
|
the reading order, so a reader moving through a row of them would meet
|
|
90
104
|
the controls with nothing ahead of them saying what each one is.
|
|
91
105
|
-->
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
{#if group}
|
|
107
|
+
<span id="{fieldId}-label" class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
108
|
+
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
109
|
+
</span>
|
|
110
|
+
{:else}
|
|
111
|
+
<label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
112
|
+
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
113
|
+
</label>
|
|
114
|
+
{/if}
|
|
95
115
|
{/if}
|
|
96
116
|
|
|
97
117
|
{@render children(wiring)}
|
|
@@ -20,6 +20,11 @@ export interface FieldWiring {
|
|
|
20
20
|
describedBy: string | undefined;
|
|
21
21
|
/** True while an error is showing, for the control's `aria-invalid`. */
|
|
22
22
|
invalid: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* The id of the caption, for a group's `aria-labelledby`. Set only when
|
|
25
|
+
* the field is a `group` with a label.
|
|
26
|
+
*/
|
|
27
|
+
labelledBy: string | undefined;
|
|
23
28
|
}
|
|
24
29
|
import type { Snippet } from 'svelte';
|
|
25
30
|
interface Props {
|
|
@@ -28,6 +33,13 @@ interface Props {
|
|
|
28
33
|
label?: string;
|
|
29
34
|
/** Screen-reader-only label, for a control whose visible name is a table column header. */
|
|
30
35
|
labelHidden?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The control is a group, a `SegmentedControl`, `FilterChips` or a row of
|
|
38
|
+
* your own. A label's `for` reaches one form control and never a group, so
|
|
39
|
+
* the caption is drawn as text with an id instead, and the group takes its
|
|
40
|
+
* name from it through `labelledBy`.
|
|
41
|
+
*/
|
|
42
|
+
group?: boolean;
|
|
31
43
|
hint?: string;
|
|
32
44
|
/** Replaces the hint while it is set, and marks the wiring invalid. */
|
|
33
45
|
error?: string;
|
|
@@ -63,7 +63,11 @@
|
|
|
63
63
|
...rest
|
|
64
64
|
}: Props = $props();
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// $props.id() and not the label: two fields under one label on a page took
|
|
67
|
+
// one id, and both labels named the first control. It also matches between
|
|
68
|
+
// the server render and hydration, which a random string would not.
|
|
69
|
+
const uid = $props.id();
|
|
70
|
+
const fieldId = $derived(id ?? uid);
|
|
67
71
|
</script>
|
|
68
72
|
|
|
69
73
|
<div data-field class="{FIELD_WRAP} {klass}">
|
|
@@ -87,8 +91,8 @@
|
|
|
87
91
|
/>
|
|
88
92
|
|
|
89
93
|
{#if error}
|
|
90
|
-
<p id={fieldId
|
|
94
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
91
95
|
{:else if hint}
|
|
92
|
-
<p id={fieldId
|
|
96
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
93
97
|
{/if}
|
|
94
98
|
</div>
|
|
@@ -178,12 +178,20 @@
|
|
|
178
178
|
which a screen reader reads as one widget and a keyboard cannot sort
|
|
179
179
|
out. A press anywhere on the frame that is not a remove button opens the
|
|
180
180
|
list from the combobox, as a press on the field always did.
|
|
181
|
+
|
|
182
|
+
Under a finger each remove cross takes a 44px box on a 20px chip, which
|
|
183
|
+
reaches 12px above and below the chip and 8px past its end. The chips
|
|
184
|
+
sit 8px apart there and their rows 24px apart, so no box covers the
|
|
185
|
+
next chip or the row below, and a tap meant to open the list lands on
|
|
186
|
+
the field. The frame pads its chips 11px from its border there, so the
|
|
187
|
+
boxes of the top and bottom rows stay inside the field, and one row
|
|
188
|
+
still fills the 44px control height.
|
|
181
189
|
-->
|
|
182
190
|
<div
|
|
183
191
|
role="presentation"
|
|
184
192
|
onclick={onFrameClick}
|
|
185
|
-
class="flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-lg
|
|
186
|
-
border bg-surface-2
|
|
193
|
+
class="relative flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 coarse:gap-x-2 coarse:gap-y-6 rounded-lg
|
|
194
|
+
border bg-surface-2 py-1.5 coarse:py-2.75 ps-2.5 pe-7 text-start text-sm transition-colors
|
|
187
195
|
has-[[role=combobox]:focus-visible]:ring-2 has-[[role=combobox]:focus-visible]:ring-inset
|
|
188
196
|
has-[[role=combobox]:focus-visible]:ring-brand
|
|
189
197
|
{disabled ? 'cursor-not-allowed opacity-50' : ''}
|
|
@@ -193,8 +201,11 @@
|
|
|
193
201
|
A div and not an input: there is nothing to type here. The labels of
|
|
194
202
|
the chosen values are its text, so focusing it reads the label and
|
|
195
203
|
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
|
-
|
|
204
|
+
the markup, so Tab reaches the field before any remove button, and it
|
|
205
|
+
spans the frame as the field itself, so it comes first on screen too.
|
|
206
|
+
A press on a chip lands on it. The remove buttons are positioned and
|
|
207
|
+
later in the markup, so they sit above it and take their own presses.
|
|
208
|
+
The frame keeps room at its end for the chevron.
|
|
198
209
|
-->
|
|
199
210
|
<div
|
|
200
211
|
bind:this={triggerEl}
|
|
@@ -207,10 +218,10 @@
|
|
|
207
218
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
208
219
|
onkeydown={onTriggerKeydown}
|
|
209
220
|
{...box.triggerAttrs}
|
|
210
|
-
class="
|
|
221
|
+
class="absolute inset-0 flex items-center gap-1.5 rounded-lg px-2.5 outline-none"
|
|
211
222
|
>
|
|
212
223
|
{#if selected.length === 0}
|
|
213
|
-
<span class="px-0.5 text-faint">{placeholder}</span>
|
|
224
|
+
<span class="min-w-0 truncate px-0.5 text-faint">{placeholder}</span>
|
|
214
225
|
{:else}
|
|
215
226
|
<span class="sr-only">{selected.map((option) => option.label).join(', ')}</span>
|
|
216
227
|
{/if}
|
|
@@ -41,7 +41,11 @@
|
|
|
41
41
|
onchange = undefined,
|
|
42
42
|
}: Props = $props();
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// $props.id() and not the label: two fields under one label on a page took
|
|
45
|
+
// one id, and both labels named the first control. It also matches between
|
|
46
|
+
// the server render and hydration, which a random string would not.
|
|
47
|
+
const uid = $props.id();
|
|
48
|
+
const fieldId = $derived(id ?? uid);
|
|
45
49
|
|
|
46
50
|
function dec() {
|
|
47
51
|
const next = value - step;
|
|
@@ -75,11 +79,15 @@
|
|
|
75
79
|
// Under a finger the control token grows to 44px, and steppers as wide as the
|
|
76
80
|
// token left the number 40px inside a 128px field. They narrow instead and
|
|
77
81
|
// take a hit box of the control size, so the number keeps its room and each
|
|
78
|
-
// stepper is still a 44px target.
|
|
82
|
+
// stepper is still a 44px target. A box centered on a 32px stepper reaches
|
|
83
|
+
// 6px past each side, into the number on one of them, so each box is pinned
|
|
84
|
+
// to the edge the stepper shares with the number and grows outward instead.
|
|
85
|
+
// The sides are logical, so a right-to-left page mirrors them.
|
|
79
86
|
const step_ =
|
|
80
87
|
`${HIT_AREA} flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted coarse:w-8 ` +
|
|
81
88
|
'transition-colors hover:bg-line hover:text-fg ' +
|
|
82
|
-
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
89
|
+
'disabled:opacity-50 disabled:cursor-not-allowed ' +
|
|
90
|
+
'coarse:before:translate-x-0 coarse:before:-translate-y-1/2';
|
|
83
91
|
</script>
|
|
84
92
|
|
|
85
93
|
<div data-field class="{FIELD_WRAP} {cls}">
|
|
@@ -95,7 +103,7 @@
|
|
|
95
103
|
onclick={dec}
|
|
96
104
|
disabled={!canDec}
|
|
97
105
|
aria-label="Decrease"
|
|
98
|
-
class="{step_} rounded-s-lg border border-e-0"
|
|
106
|
+
class="{step_} rounded-s-lg border border-e-0 coarse:before:start-auto coarse:before:end-0"
|
|
99
107
|
>
|
|
100
108
|
<svg
|
|
101
109
|
width="14"
|
|
@@ -139,7 +147,7 @@
|
|
|
139
147
|
onclick={inc}
|
|
140
148
|
disabled={!canInc}
|
|
141
149
|
aria-label="Increase"
|
|
142
|
-
class="{step_} rounded-e-lg border border-s-0"
|
|
150
|
+
class="{step_} rounded-e-lg border border-s-0 coarse:before:start-0 coarse:before:end-auto"
|
|
143
151
|
>
|
|
144
152
|
<svg
|
|
145
153
|
width="14"
|
|
@@ -157,8 +165,8 @@
|
|
|
157
165
|
</div>
|
|
158
166
|
|
|
159
167
|
{#if error}
|
|
160
|
-
<p id={fieldId
|
|
168
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
161
169
|
{:else if hint}
|
|
162
|
-
<p id={fieldId
|
|
170
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
163
171
|
{/if}
|
|
164
172
|
</div>
|
|
@@ -117,19 +117,21 @@
|
|
|
117
117
|
{#if showToggle}
|
|
118
118
|
<!-- type="button" is load bearing. A button inside a form defaults to
|
|
119
119
|
type="submit", so a reveal control on a login form posted the form
|
|
120
|
-
on the first click and the user never saw what they had typed.
|
|
120
|
+
on the first click and the user never saw what they had typed.
|
|
121
|
+
The button holds its 27px around a 14px icon, the rung of the
|
|
122
|
+
text it sits on, so its touch target stays where it was. -->
|
|
121
123
|
<button
|
|
122
124
|
type="button"
|
|
123
125
|
onclick={() => (revealed = !revealed)}
|
|
124
126
|
aria-pressed={revealed}
|
|
125
127
|
aria-controls={fieldId}
|
|
126
128
|
aria-label={revealed ? 'Hide password' : 'Show password'}
|
|
127
|
-
class="{HIT_AREA_POSITIONED} absolute top-1/2 end-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
129
|
+
class="{HIT_AREA_POSITIONED} absolute top-1/2 end-1 flex size-6.75 -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
128
130
|
>
|
|
129
131
|
{#if revealed}
|
|
130
|
-
<EyeOff size={
|
|
132
|
+
<EyeOff size={14} aria-hidden="true" />
|
|
131
133
|
{:else}
|
|
132
|
-
<Eye size={
|
|
134
|
+
<Eye size={14} aria-hidden="true" />
|
|
133
135
|
{/if}
|
|
134
136
|
</button>
|
|
135
137
|
{/if}
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
oninput = undefined,
|
|
25
25
|
}: Props = $props();
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// $props.id() and not the label: two fields under one label on a page took
|
|
28
|
+
// one id, and both labels named the first control. It also matches between
|
|
29
|
+
// the server render and hydration, which a random string would not.
|
|
30
|
+
const uid = $props.id();
|
|
31
|
+
const fieldId = $derived(id ?? uid);
|
|
28
32
|
|
|
29
33
|
// bind:value keeps what was typed before hydration. A one-way value
|
|
30
34
|
// cleared it. The binding has updated value by the time this runs.
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
* own hint or error.
|
|
65
65
|
*/
|
|
66
66
|
describedBy?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The id of a caption drawn outside the row, a `Field` one for instance.
|
|
69
|
+
* The group takes its name from it in place of `label`.
|
|
70
|
+
*/
|
|
71
|
+
labelledBy?: string;
|
|
67
72
|
/** Submits the value with the surrounding form, through a hidden input. */
|
|
68
73
|
name?: string;
|
|
69
74
|
size?: Size;
|
|
@@ -82,6 +87,7 @@
|
|
|
82
87
|
hint = undefined,
|
|
83
88
|
error = undefined,
|
|
84
89
|
describedBy: callerIds = undefined,
|
|
90
|
+
labelledBy = undefined,
|
|
85
91
|
name = undefined,
|
|
86
92
|
size = 'md',
|
|
87
93
|
disabled = false,
|
|
@@ -222,9 +228,9 @@
|
|
|
222
228
|
|
|
223
229
|
<div data-field class="{FIELD_WRAP} items-start {klass}">
|
|
224
230
|
{#if !labelHidden}
|
|
225
|
-
<!-- A caption, not a label element. The group is named by aria-label,
|
|
226
|
-
label has nothing to point at
|
|
227
|
-
control that `for` can name. -->
|
|
231
|
+
<!-- A caption, not a label element. The group is named by aria-label, or
|
|
232
|
+
by the caption it is handed, and a label has nothing to point at
|
|
233
|
+
here: role="radiogroup" is not a form control that `for` can name. -->
|
|
228
234
|
<span class={FIELD_LABEL}>{label}</span>
|
|
229
235
|
{/if}
|
|
230
236
|
|
|
@@ -246,7 +252,8 @@
|
|
|
246
252
|
bind:this={rootEl}
|
|
247
253
|
{id}
|
|
248
254
|
role={linked ? 'group' : 'radiogroup'}
|
|
249
|
-
aria-label={label}
|
|
255
|
+
aria-label={labelledBy ? undefined : label}
|
|
256
|
+
aria-labelledby={labelledBy}
|
|
250
257
|
aria-invalid={error && !linked ? 'true' : undefined}
|
|
251
258
|
aria-describedby={describedByIds}
|
|
252
259
|
onfocusout={linked ? onGroupFocusOut : undefined}
|
|
@@ -50,6 +50,11 @@ declare function $$render<T extends string>(): {
|
|
|
50
50
|
* own hint or error.
|
|
51
51
|
*/
|
|
52
52
|
describedBy?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The id of a caption drawn outside the row, a `Field` one for instance.
|
|
55
|
+
* The group takes its name from it in place of `label`.
|
|
56
|
+
*/
|
|
57
|
+
labelledBy?: string;
|
|
53
58
|
/** Submits the value with the surrounding form, through a hidden input. */
|
|
54
59
|
name?: string;
|
|
55
60
|
size?: "sm" | "md";
|
|
@@ -124,14 +124,11 @@
|
|
|
124
124
|
* render and hydration, and both the label association and every idref the
|
|
125
125
|
* listbox emits are built from this one.
|
|
126
126
|
*
|
|
127
|
-
* The
|
|
128
|
-
*
|
|
129
|
-
* which left an error message with no id and the control with no
|
|
130
|
-
* aria-describedby, so an unlabeled field announced its value and never the
|
|
131
|
-
* reason it was rejected.
|
|
127
|
+
* The id is never made from the label: two fields under one label on a
|
|
128
|
+
* page took one id, and both labels named the first control.
|
|
132
129
|
*/
|
|
133
130
|
const uid = $props.id();
|
|
134
|
-
const fieldId = $derived(id ??
|
|
131
|
+
const fieldId = $derived(id ?? uid);
|
|
135
132
|
|
|
136
133
|
let query = $state('');
|
|
137
134
|
let triggerEl = $state<HTMLButtonElement | undefined>();
|
|
@@ -28,9 +28,18 @@
|
|
|
28
28
|
};
|
|
29
29
|
</script>
|
|
30
30
|
|
|
31
|
+
<!--
|
|
32
|
+
Under a finger the remove cross takes a 44px box on a 22px tag, which
|
|
33
|
+
reaches 11px above and below the tag and 8px past its end. A removable tag
|
|
34
|
+
claims that room as margin there, so the boxes in a row of tags meet
|
|
35
|
+
without overlapping, wrapped or not, whatever gap the row sets. That
|
|
36
|
+
margin makes a removable tag 44px tall in a row, so every tag centers
|
|
37
|
+
itself, and a plain tag beside it neither stretches to 44px nor sits at
|
|
38
|
+
the top of the line, whatever the row aligns.
|
|
39
|
+
-->
|
|
31
40
|
<span
|
|
32
|
-
class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5
|
|
33
|
-
text-xs font-medium {tones[tone]} {cls}"
|
|
41
|
+
class="inline-flex items-center self-center gap-1 rounded-md border px-2 py-0.5
|
|
42
|
+
text-xs font-medium {removable ? 'coarse:my-2.75 coarse:me-2' : ''} {tones[tone]} {cls}"
|
|
34
43
|
>
|
|
35
44
|
{label}
|
|
36
45
|
{#if removable}
|
|
@@ -55,7 +55,11 @@
|
|
|
55
55
|
onblur?: (e: FocusEvent & { currentTarget: TA }) => void;
|
|
56
56
|
} = $props();
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
// $props.id() and not the label: two fields under one label on a page took
|
|
59
|
+
// one id, and both labels named the first control. It also matches between
|
|
60
|
+
// the server render and hydration, which a random string would not.
|
|
61
|
+
const uid = $props.id();
|
|
62
|
+
const fieldId = $derived(id ?? uid);
|
|
59
63
|
</script>
|
|
60
64
|
|
|
61
65
|
<div data-field class="{FIELD_WRAP} {cls}">
|
|
@@ -85,8 +89,8 @@
|
|
|
85
89
|
></textarea>
|
|
86
90
|
|
|
87
91
|
{#if error}
|
|
88
|
-
<p id={fieldId
|
|
92
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
89
93
|
{:else if hint}
|
|
90
|
-
<p id={fieldId
|
|
94
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
91
95
|
{/if}
|
|
92
96
|
</div>
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
const expandedSet = $derived(new Set(expanded));
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Branches whose children
|
|
49
|
-
* collapse
|
|
50
|
-
* nothing, so a tree of thousands of nodes renders
|
|
51
|
-
* not the whole tree.
|
|
48
|
+
* Branches whose children are mounted. A branch stays mounted after a
|
|
49
|
+
* collapse until the collapse has played, and then unmounts. A branch nobody
|
|
50
|
+
* opened mounts nothing, so a tree of thousands of nodes renders the rows a
|
|
51
|
+
* reader has open and not the whole tree, nor every row it ever showed.
|
|
52
52
|
*/
|
|
53
53
|
const shown = $state<Record<string, boolean>>({});
|
|
54
54
|
$effect.pre(() => {
|
|
@@ -347,10 +347,16 @@
|
|
|
347
347
|
A branch opens and shuts on grid-template-rows, the AccordionItem
|
|
348
348
|
collapse, so its children slide rather than appear in one frame. Shut,
|
|
349
349
|
they are inert: out of the tab order and the accessibility tree while
|
|
350
|
-
they stay mounted for the collapse to play
|
|
350
|
+
they stay mounted for the collapse to play, and they unmount when it
|
|
351
|
+
ends. A nested branch's transition bubbles up here, so only this
|
|
352
|
+
wrapper's own counts.
|
|
351
353
|
-->
|
|
352
354
|
{#if isBranch(node)}
|
|
353
355
|
<div
|
|
356
|
+
ontransitionend={(event) => {
|
|
357
|
+
if (event.target !== event.currentTarget || open) return;
|
|
358
|
+
if (event.propertyName === 'grid-template-rows') shown[node.id] = false;
|
|
359
|
+
}}
|
|
354
360
|
class="grid transition-[grid-template-rows] duration-base {open
|
|
355
361
|
? 'grid-rows-[1fr]'
|
|
356
362
|
: 'grid-rows-[0fr]'}"
|
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.
|
|
106
|
+
export declare const VERSION = "0.39.0";
|
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.
|
|
119
|
+
export const VERSION = '0.39.0';
|
package/dist/internal/panel.d.ts
CHANGED
|
@@ -131,8 +131,8 @@ export declare const PANEL_LIST_ATTR = "data-panel-list";
|
|
|
131
131
|
*
|
|
132
132
|
* The token cap is sized for a list of rows, and a calendar is not one: six
|
|
133
133
|
* weeks of days stand taller than the token, so under it every month would
|
|
134
|
-
* scroll by a few rows. Such a region
|
|
135
|
-
*
|
|
134
|
+
* scroll by a few rows. Such a region takes the room as its cap, and never
|
|
135
|
+
* less than its whole height: a calendar that scrolls is one nobody can read.
|
|
136
136
|
*/
|
|
137
137
|
export declare const PANEL_LIST_UNCAPPED = "uncapped";
|
|
138
138
|
/** The classes that put the surface under its anchor. */
|
|
@@ -158,9 +158,17 @@ export declare const PANEL_ABOVE = "bottom-full mb-1 origin-bottom";
|
|
|
158
158
|
* the cap without this code knowing its value. A region marked uncapped takes
|
|
159
159
|
* the room alone.
|
|
160
160
|
*
|
|
161
|
+
* Neither side may hold even that: a one-field modal body is about 100px
|
|
162
|
+
* tall, and capped to it a calendar showed its month row and a list no whole
|
|
163
|
+
* row. When neither side has room for the region's least height, the panel
|
|
164
|
+
* opens below at its own height and the anchor takes a bottom margin of that
|
|
165
|
+
* height, so the container grows to hold it or scrolls to it, and the panel
|
|
166
|
+
* is brought into view. The room is kept until the panel closes, because the
|
|
167
|
+
* container it lengthened would otherwise read as room to spare.
|
|
168
|
+
*
|
|
161
169
|
* A panel that would run past the left or the right edge of the clipping
|
|
162
170
|
* ancestor or the viewport slides back inside both, and one wider than that
|
|
163
|
-
* room
|
|
171
|
+
* room shrinks to it.
|
|
164
172
|
*
|
|
165
173
|
* Measured on mount, on resize and on scroll of the clipping ancestor, since
|
|
166
174
|
* each moves the anchor relative to the edge. Scroll does not bubble, so the
|
package/dist/internal/panel.js
CHANGED
|
@@ -141,8 +141,8 @@ export const PANEL_LIST_ATTR = 'data-panel-list';
|
|
|
141
141
|
*
|
|
142
142
|
* The token cap is sized for a list of rows, and a calendar is not one: six
|
|
143
143
|
* weeks of days stand taller than the token, so under it every month would
|
|
144
|
-
* scroll by a few rows. Such a region
|
|
145
|
-
*
|
|
144
|
+
* scroll by a few rows. Such a region takes the room as its cap, and never
|
|
145
|
+
* less than its whole height: a calendar that scrolls is one nobody can read.
|
|
146
146
|
*/
|
|
147
147
|
export const PANEL_LIST_UNCAPPED = 'uncapped';
|
|
148
148
|
/** The classes that put the surface under its anchor. */
|
|
@@ -154,6 +154,27 @@ export const PANEL_ABOVE = 'bottom-full mb-1 origin-bottom';
|
|
|
154
154
|
* panel that fits exactly does not sit on the edge of the window or the modal.
|
|
155
155
|
*/
|
|
156
156
|
const PANEL_EDGE_GAP = 8;
|
|
157
|
+
/** The gap PANEL_BELOW opens between the anchor and the surface, mt-1. */
|
|
158
|
+
const PANEL_OFFSET = 4;
|
|
159
|
+
/**
|
|
160
|
+
* The fewest rows a list keeps in view, or all of them when it has fewer.
|
|
161
|
+
* Below that a list is a slot that scrolls one row at a time.
|
|
162
|
+
*/
|
|
163
|
+
const PANEL_MIN_ROWS = 4;
|
|
164
|
+
/**
|
|
165
|
+
* The least height a region is usable at: an uncapped region's whole height,
|
|
166
|
+
* a list's first few rows and its padding, or a short list whole.
|
|
167
|
+
*/
|
|
168
|
+
function leastHeight(list, capped) {
|
|
169
|
+
const rows = list.querySelectorAll('[role="option"], [role="menuitem"]');
|
|
170
|
+
if (!capped || rows.length <= PANEL_MIN_ROWS)
|
|
171
|
+
return list.scrollHeight;
|
|
172
|
+
const style = getComputedStyle(list);
|
|
173
|
+
let height = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
|
|
174
|
+
for (let i = 0; i < PANEL_MIN_ROWS; i++)
|
|
175
|
+
height += rows[i].offsetHeight;
|
|
176
|
+
return height;
|
|
177
|
+
}
|
|
157
178
|
/**
|
|
158
179
|
* The nearest ancestor that clips or scrolls its content.
|
|
159
180
|
*
|
|
@@ -197,9 +218,17 @@ function swap(node, remove, add) {
|
|
|
197
218
|
* the cap without this code knowing its value. A region marked uncapped takes
|
|
198
219
|
* the room alone.
|
|
199
220
|
*
|
|
221
|
+
* Neither side may hold even that: a one-field modal body is about 100px
|
|
222
|
+
* tall, and capped to it a calendar showed its month row and a list no whole
|
|
223
|
+
* row. When neither side has room for the region's least height, the panel
|
|
224
|
+
* opens below at its own height and the anchor takes a bottom margin of that
|
|
225
|
+
* height, so the container grows to hold it or scrolls to it, and the panel
|
|
226
|
+
* is brought into view. The room is kept until the panel closes, because the
|
|
227
|
+
* container it lengthened would otherwise read as room to spare.
|
|
228
|
+
*
|
|
200
229
|
* A panel that would run past the left or the right edge of the clipping
|
|
201
230
|
* ancestor or the viewport slides back inside both, and one wider than that
|
|
202
|
-
* room
|
|
231
|
+
* room shrinks to it.
|
|
203
232
|
*
|
|
204
233
|
* Measured on mount, on resize and on scroll of the clipping ancestor, since
|
|
205
234
|
* each moves the anchor relative to the edge. Scroll does not bubble, so the
|
|
@@ -212,6 +241,8 @@ export function placePanel(node) {
|
|
|
212
241
|
const anchor = node.offsetParent instanceof HTMLElement ? node.offsetParent : node.parentElement;
|
|
213
242
|
const clip = anchor === null ? null : clippingAncestor(anchor);
|
|
214
243
|
const list = node.querySelector(`[${PANEL_LIST_ATTR}]`);
|
|
244
|
+
const capped = list?.getAttribute(PANEL_LIST_ATTR) !== PANEL_LIST_UNCAPPED;
|
|
245
|
+
let reserved = false;
|
|
215
246
|
function measure() {
|
|
216
247
|
if (anchor === null)
|
|
217
248
|
return;
|
|
@@ -227,7 +258,11 @@ export function placePanel(node) {
|
|
|
227
258
|
const below = clipBottom - rect.bottom - PANEL_EDGE_GAP;
|
|
228
259
|
const above = rect.top - clipTop - PANEL_EDGE_GAP;
|
|
229
260
|
const natural = node.scrollHeight;
|
|
230
|
-
|
|
261
|
+
// What the surface adds around the list, a search field for instance, is
|
|
262
|
+
// taken out of the room so the whole panel fits and not only its list.
|
|
263
|
+
const chrome = list === null ? 0 : Math.max(0, node.offsetHeight - list.offsetHeight);
|
|
264
|
+
const reserve = reserved || (list !== null && chrome + leastHeight(list, capped) > Math.max(above, below));
|
|
265
|
+
const flip = !reserve && natural > below && above > below;
|
|
231
266
|
if (flip)
|
|
232
267
|
swap(node, PANEL_BELOW, PANEL_ABOVE);
|
|
233
268
|
else
|
|
@@ -235,12 +270,18 @@ export function placePanel(node) {
|
|
|
235
270
|
// A panel wider than the room beside its anchor slides back inside what
|
|
236
271
|
// the reader can see, which is the clip and the viewport both: the
|
|
237
272
|
// 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.
|
|
239
|
-
// wider than that room it
|
|
240
|
-
//
|
|
273
|
+
// starts at the page gutter or inside a modal body's padding. A panel
|
|
274
|
+
// wider than that room shrinks to it, since a modal body on a 320px phone
|
|
275
|
+
// is narrower than the calendar and scrolled sideways around it. Should
|
|
276
|
+
// it still be wider, it keeps its start edge in view. Read from the
|
|
277
|
+
// layout box, which the entrance preset's scale does not shrink, and from
|
|
278
|
+
// the width the class gives, not one an earlier measurement capped.
|
|
279
|
+
node.style.maxWidth = '';
|
|
241
280
|
const viewport = document.documentElement.clientWidth || window.innerWidth;
|
|
242
281
|
const lo = Math.max(0, clipBox?.left ?? 0);
|
|
243
282
|
const hi = Math.min(viewport, clipBox?.right ?? viewport);
|
|
283
|
+
if (node.offsetWidth > hi - lo)
|
|
284
|
+
node.style.maxWidth = `${hi - lo}px`;
|
|
244
285
|
const left = rect.left + node.offsetLeft;
|
|
245
286
|
const right = left + node.offsetWidth;
|
|
246
287
|
let shift = 0;
|
|
@@ -254,15 +295,35 @@ export function placePanel(node) {
|
|
|
254
295
|
shift = lo - left;
|
|
255
296
|
}
|
|
256
297
|
node.style.translate = shift === 0 ? '' : `${shift}px 0`;
|
|
257
|
-
if (list === null)
|
|
298
|
+
if (list === null || anchor === null)
|
|
258
299
|
return;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
300
|
+
if (reserve) {
|
|
301
|
+
anchor.style.marginBottom = `${node.offsetHeight + PANEL_OFFSET}px`;
|
|
302
|
+
if (!reserved)
|
|
303
|
+
reveal();
|
|
304
|
+
reserved = true;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
262
307
|
const room = Math.max(0, Math.round((flip ? above : below) - chrome));
|
|
263
|
-
const capped = list.getAttribute(PANEL_LIST_ATTR) !== PANEL_LIST_UNCAPPED;
|
|
264
308
|
list.style.maxHeight = capped ? `min(var(--spacing-panel-max), ${room}px)` : `${room}px`;
|
|
265
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Scrolls the container down until the panel's bottom is in view, or its
|
|
312
|
+
* top when it is taller than the container. Down only: scrollIntoView also
|
|
313
|
+
* scrolls sideways, and a modal body on a 320px phone took a 20px sideways
|
|
314
|
+
* scroll from the room the panel's slide back had already made up.
|
|
315
|
+
*/
|
|
316
|
+
function reveal() {
|
|
317
|
+
const target = clip ?? window;
|
|
318
|
+
if (typeof target.scrollBy !== 'function')
|
|
319
|
+
return;
|
|
320
|
+
const top = node.getBoundingClientRect().top;
|
|
321
|
+
const clipTop = clip === null ? 0 : clip.getBoundingClientRect().top;
|
|
322
|
+
const clipBottom = clip === null ? window.innerHeight : clip.getBoundingClientRect().bottom;
|
|
323
|
+
const by = Math.min(top + node.offsetHeight + PANEL_EDGE_GAP - clipBottom, top - clipTop);
|
|
324
|
+
if (by > 0)
|
|
325
|
+
target.scrollBy(0, by);
|
|
326
|
+
}
|
|
266
327
|
measure();
|
|
267
328
|
const scroller = clip ?? window;
|
|
268
329
|
window.addEventListener('resize', measure, { passive: true });
|
|
@@ -271,6 +332,8 @@ export function placePanel(node) {
|
|
|
271
332
|
destroy() {
|
|
272
333
|
window.removeEventListener('resize', measure);
|
|
273
334
|
scroller.removeEventListener('scroll', measure);
|
|
335
|
+
if (reserved && anchor !== null)
|
|
336
|
+
anchor.style.marginBottom = '';
|
|
274
337
|
},
|
|
275
338
|
};
|
|
276
339
|
}
|
package/dist/styles/theme.css
CHANGED
|
@@ -358,7 +358,10 @@
|
|
|
358
358
|
* position it set would fight the `absolute` an input's clear button relies
|
|
359
359
|
* on. Neighbors are spaced so their boxes meet rather than overlap: the
|
|
360
360
|
* later one in document order takes an overlap, so a tap near the edge of
|
|
361
|
-
* one control lands on the next.
|
|
361
|
+
* one control lands on the next. Where the room cannot hold that spacing, a
|
|
362
|
+
* calendar shrunk into a narrow modal for instance, the row sets
|
|
363
|
+
* `--hit-width` to the width each target has, and the boxes take that width
|
|
364
|
+
* and keep the control height.
|
|
362
365
|
*/
|
|
363
366
|
@custom-variant coarse (@media (pointer: coarse));
|
|
364
367
|
|
|
@@ -377,7 +380,7 @@
|
|
|
377
380
|
top: 50%;
|
|
378
381
|
width: 100%;
|
|
379
382
|
height: 100%;
|
|
380
|
-
min-width: var(--spacing-control);
|
|
383
|
+
min-width: var(--hit-width, var(--spacing-control));
|
|
381
384
|
min-height: var(--spacing-control);
|
|
382
385
|
translate: -50% -50%;
|
|
383
386
|
}
|
package/package.json
CHANGED
package/src/lib/styles/theme.css
CHANGED
|
@@ -358,7 +358,10 @@
|
|
|
358
358
|
* position it set would fight the `absolute` an input's clear button relies
|
|
359
359
|
* on. Neighbors are spaced so their boxes meet rather than overlap: the
|
|
360
360
|
* later one in document order takes an overlap, so a tap near the edge of
|
|
361
|
-
* one control lands on the next.
|
|
361
|
+
* one control lands on the next. Where the room cannot hold that spacing, a
|
|
362
|
+
* calendar shrunk into a narrow modal for instance, the row sets
|
|
363
|
+
* `--hit-width` to the width each target has, and the boxes take that width
|
|
364
|
+
* and keep the control height.
|
|
362
365
|
*/
|
|
363
366
|
@custom-variant coarse (@media (pointer: coarse));
|
|
364
367
|
|
|
@@ -377,7 +380,7 @@
|
|
|
377
380
|
top: 50%;
|
|
378
381
|
width: 100%;
|
|
379
382
|
height: 100%;
|
|
380
|
-
min-width: var(--spacing-control);
|
|
383
|
+
min-width: var(--hit-width, var(--spacing-control));
|
|
381
384
|
min-height: var(--spacing-control);
|
|
382
385
|
translate: -50% -50%;
|
|
383
386
|
}
|