@lyeve-labs/ui-kit 0.38.3 → 0.38.4

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