@lyeve-labs/ui-kit 0.38.5 → 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 +2 -2
- 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 +4 -2
- 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/Textarea.svelte +7 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/panel.d.ts +10 -2
- package/dist/internal/panel.js +65 -8
- package/package.json +1 -1
|
@@ -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,8 +246,8 @@
|
|
|
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
|
|
@@ -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>
|
|
@@ -183,13 +183,15 @@
|
|
|
183
183
|
reaches 12px above and below the chip and 8px past its end. The chips
|
|
184
184
|
sit 8px apart there and their rows 24px apart, so no box covers the
|
|
185
185
|
next chip or the row below, and a tap meant to open the list lands on
|
|
186
|
-
the field.
|
|
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.
|
|
187
189
|
-->
|
|
188
190
|
<div
|
|
189
191
|
role="presentation"
|
|
190
192
|
onclick={onFrameClick}
|
|
191
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
|
|
192
|
-
border bg-surface-2 py-1.5 ps-2.5 pe-7 text-start text-sm transition-colors
|
|
194
|
+
border bg-surface-2 py-1.5 coarse:py-2.75 ps-2.5 pe-7 text-start text-sm transition-colors
|
|
193
195
|
has-[[role=combobox]:focus-visible]:ring-2 has-[[role=combobox]:focus-visible]:ring-inset
|
|
194
196
|
has-[[role=combobox]:focus-visible]:ring-brand
|
|
195
197
|
{disabled ? 'cursor-not-allowed opacity-50' : ''}
|
|
@@ -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>();
|
|
@@ -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>
|
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,6 +158,14 @@ 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
171
|
* room shrinks to it.
|
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,6 +218,14 @@ 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
231
|
* room shrinks to it.
|
|
@@ -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
|
|
@@ -260,15 +295,35 @@ export function placePanel(node) {
|
|
|
260
295
|
shift = lo - left;
|
|
261
296
|
}
|
|
262
297
|
node.style.translate = shift === 0 ? '' : `${shift}px 0`;
|
|
263
|
-
if (list === null)
|
|
298
|
+
if (list === null || anchor === null)
|
|
264
299
|
return;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
300
|
+
if (reserve) {
|
|
301
|
+
anchor.style.marginBottom = `${node.offsetHeight + PANEL_OFFSET}px`;
|
|
302
|
+
if (!reserved)
|
|
303
|
+
reveal();
|
|
304
|
+
reserved = true;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
268
307
|
const room = Math.max(0, Math.round((flip ? above : below) - chrome));
|
|
269
|
-
const capped = list.getAttribute(PANEL_LIST_ATTR) !== PANEL_LIST_UNCAPPED;
|
|
270
308
|
list.style.maxHeight = capped ? `min(var(--spacing-panel-max), ${room}px)` : `${room}px`;
|
|
271
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
|
+
}
|
|
272
327
|
measure();
|
|
273
328
|
const scroller = clip ?? window;
|
|
274
329
|
window.addEventListener('resize', measure, { passive: true });
|
|
@@ -277,6 +332,8 @@ export function placePanel(node) {
|
|
|
277
332
|
destroy() {
|
|
278
333
|
window.removeEventListener('resize', measure);
|
|
279
334
|
scroller.removeEventListener('scroll', measure);
|
|
335
|
+
if (reserved && anchor !== null)
|
|
336
|
+
anchor.style.marginBottom = '';
|
|
280
337
|
},
|
|
281
338
|
};
|
|
282
339
|
}
|
package/package.json
CHANGED