@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.
@@ -316,7 +316,7 @@
316
316
  aria-expanded={!collapsed}
317
317
  onclick={() => (collapsed = !collapsed)}
318
318
  >
319
- {#if collapsed}<PanelLeft size={18} />{:else}<PanelLeftClose size={18} />{/if}
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={15} aria-hidden="true" />
165
+ <EyeOff size={14} aria-hidden="true" />
161
166
  {:else}
162
- <Eye size={15} aria-hidden="true" />
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={15} />
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, so a short modal scrolls the calendar rather
250
- than cutting it.
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. The rows
257
- open a 12px gap for the same reason.
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
- <div class="flex items-center justify-between mb-2">
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
- <div class="grid grid-cols-7 gap-0.5 coarse:gap-y-3" data-day-grid>
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
- <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
93
- {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
94
- </label>
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
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
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 ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
94
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
91
95
  {:else if hint}
92
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
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 px-2.5 py-1.5 text-start text-sm transition-colors
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
- order-last draws it after the chips.
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="order-last flex min-w-12 flex-1 items-center gap-1.5 self-stretch outline-none"
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
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
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 ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
168
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
161
169
  {:else if hint}
162
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
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={15} aria-hidden="true" />
132
+ <EyeOff size={14} aria-hidden="true" />
131
133
  {:else}
132
- <Eye size={15} aria-hidden="true" />
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
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
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, and a
226
- label has nothing to point at here: role="radiogroup" is not a form
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 label-derived id stays for a control that has a label, because call
128
- * sites point their own markup at it. The fallback used to be undefined,
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 ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : uid));
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
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
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 ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
92
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
89
93
  {:else if hint}
90
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
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 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.
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.38.4";
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.38.4';
119
+ export const VERSION = '0.39.0';
@@ -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 still takes the room as its cap, so
135
- * inside a short modal it scrolls rather than being cut.
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 keeps its start edge in view.
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
@@ -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 still takes the room as its cap, so
145
- * inside a short modal it scrolls rather than being cut.
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 keeps its start edge in view.
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
- const flip = natural > below && above > below;
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. 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.
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
- // What the surface adds around the list, a search field for instance, is
260
- // taken out of the room so the whole panel fits and not only its list.
261
- const chrome = Math.max(0, node.offsetHeight - list.offsetHeight);
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.38.4",
3
+ "version": "0.39.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",
@@ -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
  }