@lyeve-labs/ui-kit 0.38.1 → 0.38.3

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.
@@ -135,6 +135,10 @@
135
135
  page. The row carries the field marker while it reports a message, so a
136
136
  panel sizing itself to its form counts it as the one field it then is.
137
137
 
138
+ Under a finger each chip draws a 44px hit box round a 26px chip, 9px past
139
+ its top and bottom. Wrapped rows sit 18px apart there, so the boxes of two
140
+ rows meet without one row's taps landing on the row above.
141
+
138
142
  The row carries no aria-invalid. ARIA does not allow it on a group, or on
139
143
  the buttons and links inside one, so an error reaches a reader through
140
144
  aria-describedby, which reads the message itself.
@@ -142,7 +146,7 @@
142
146
  <div
143
147
  data-field={error || hint ? '' : undefined}
144
148
  {id}
145
- class={cn('flex flex-wrap gap-1.5', klass)}
149
+ class={cn('flex flex-wrap gap-1.5 coarse:gap-y-4.5', klass)}
146
150
  role="group"
147
151
  aria-label={label}
148
152
  aria-describedby={describedByIds}
@@ -130,15 +130,60 @@
130
130
  }
131
131
 
132
132
  /**
133
- * A page number is unique in the list, so it is its own key. Both gaps are
134
- * spelled 'gap', so they take their position as well. Keying on the index
135
- * alone made every slot change identity when a page was inserted ahead of it.
133
+ * The pages a phone held by a finger shows: the first, the last and the
134
+ * current one, with a gap for each run between them. Five slots at most, so
135
+ * Previous, Next and the five each take a 44px box on one row of a phone.
136
+ * Every page in it is also in the list above, which keeps one element per
137
+ * page when the two lists are drawn together.
136
138
  */
137
- function slotKey(slot: PageSlot, index: number): string {
138
- return slot === 'gap' ? `gap-${index}` : `page-${slot}`;
139
+ function touchPageNumbers(current: number, last: number): Set<number> {
140
+ if (last <= 5) return new Set(Array.from({ length: last }, (_, i) => i + 1));
141
+ return new Set([1, current, last]);
139
142
  }
140
143
 
141
- let nums = $derived(counted ? pageNumbers(safePage, totalPages) : []);
144
+ /**
145
+ * A slot is a page or a gap, and `only` names the one layout that draws it:
146
+ * `fine` for a page or gap the touch layout folds away, `touch` for a gap
147
+ * only the touch layout draws. Unset means both draw it.
148
+ *
149
+ * Both layouts are in the markup and the stylesheet picks one, because the
150
+ * pointer is a media query: a list chosen by script would draw the mouse
151
+ * layout on the server and switch after hydration. A gap stands where the
152
+ * run it folds starts, so a gap both layouts draw is one element, and a page
153
+ * never disappears without a gap in its place.
154
+ */
155
+ type Slot = { key: string; page: number | null; only?: 'fine' | 'touch' };
156
+
157
+ function pageSlots(current: number, last: number): Slot[] {
158
+ const fine = new Set(pageNumbers(current, last).filter((s): s is number => s !== 'gap'));
159
+ const touch = touchPageNumbers(current, last);
160
+ const starts = new Set<number>();
161
+ for (const p of fine) starts.add(p).add(p + 1);
162
+ const slots: Slot[] = [];
163
+ for (const p of [...starts].filter((p) => p <= last).sort((a, b) => a - b)) {
164
+ const fineGap = !fine.has(p) && fine.has(p - 1);
165
+ const touchGap = !touch.has(p) && touch.has(p - 1);
166
+ if (fineGap || touchGap) {
167
+ const only = fineGap && touchGap ? undefined : fineGap ? 'fine' : 'touch';
168
+ slots.push({ key: `gap-${p}`, page: null, only });
169
+ }
170
+ if (fine.has(p))
171
+ slots.push({ key: `page-${p}`, page: p, only: touch.has(p) ? undefined : 'fine' });
172
+ }
173
+ return slots;
174
+ }
175
+
176
+ /**
177
+ * The display of a slot. A touch-only gap carries no base `inline-flex`,
178
+ * which would tie with `hidden` and leave the outcome to stylesheet order.
179
+ */
180
+ function display(only: Slot['only']): string {
181
+ if (only === 'fine') return 'inline-flex max-sm:coarse:hidden';
182
+ if (only === 'touch') return 'hidden max-sm:coarse:inline-flex';
183
+ return 'inline-flex';
184
+ }
185
+
186
+ let slots = $derived(counted ? pageSlots(safePage, totalPages) : []);
142
187
 
143
188
  /**
144
189
  * The noun as it will be read.
@@ -181,9 +226,12 @@
181
226
  }
182
227
 
183
228
  /**
184
- * 28px drawn, 44px under a finger. The steps sit 2px apart, so growing the
185
- * visual would put a seven-page pager past a phone's width. The hit box
186
- * grows instead and the pager keeps its shape.
229
+ * 28px drawn, 44px under a finger. Growing the visual would push the
230
+ * neighbors apart, so the hit box grows instead. Under a finger the steps
231
+ * sit 16px apart, so each 44px box meets its neighbor's without covering
232
+ * it. Nine steps that far apart are 380px, wider than a phone, so a phone
233
+ * held by a finger shows the touch list of five pages and gaps instead, and
234
+ * from the sm width up the full list fits.
187
235
  */
188
236
  const btnBase = `${HIT_AREA} inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors`;
189
237
 
@@ -266,15 +314,19 @@
266
314
  <span class="shrink-0 text-xs text-faint">{summary}</span>
267
315
 
268
316
  {#if hasControls}
269
- <div class="ms-auto flex flex-wrap items-center gap-0.5">
317
+ <div class="ms-auto flex flex-wrap items-center gap-0.5 coarse:gap-4">
270
318
  {@render step(safePage - 1, 'Previous page', canPrev, 'M15 18l-6-6 6-6')}
271
319
 
272
- {#each nums as n, i (slotKey(n, i))}
273
- {#if n === 'gap'}
320
+ {#each slots as slot (slot.key)}
321
+ {@const n = slot.page}
322
+ {#if n === null}
274
323
  <!-- Three drawn dots, not the ellipsis character: a font glyph lands
275
324
  at a different optical weight from every other icon here, and the
276
325
  character is what the screen reader was speaking. -->
277
- <span class="inline-flex w-7 items-center justify-center text-faint" aria-hidden="true">
326
+ <span
327
+ class="{display(slot.only)} w-7 items-center justify-center text-faint"
328
+ aria-hidden="true"
329
+ >
278
330
  <svg
279
331
  width="14"
280
332
  height="14"
@@ -292,16 +344,20 @@
292
344
  <a
293
345
  href={linkTo(n, true)}
294
346
  aria-current={safePage === n ? 'page' : undefined}
295
- class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
347
+ class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
348
+ ? btnCurrent
349
+ : btnRest}"
296
350
  >
297
351
  {n}
298
352
  </a>
299
353
  {:else}
300
354
  <button
301
355
  type="button"
302
- onclick={() => onchange?.(n as number)}
356
+ onclick={() => onchange?.(n)}
303
357
  aria-current={safePage === n ? 'page' : undefined}
304
- class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
358
+ class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
359
+ ? btnCurrent
360
+ : btnRest}"
305
361
  >
306
362
  {n}
307
363
  </button>
@@ -99,6 +99,10 @@
99
99
  * Button does, and each segment keeps its visual inside the track and takes
100
100
  * a hit box of the control size. A segment that grew instead would stand
101
101
  * the track six pixels proud of the Input beside it.
102
+ *
103
+ * A segment is never narrower than the control size under a finger, so a
104
+ * short label such as "6h" takes a 44px target across as well, and the hit
105
+ * boxes of two neighbors do not overlap.
102
106
  */
103
107
  const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
104
108
  sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
@@ -228,7 +232,16 @@
228
232
  carries no aria-checked, and what marks the current one is aria-current,
229
233
  the attribute a reader already knows from navigation. ARIA allows
230
234
  aria-invalid on a radiogroup and not on a group, so a row of links
231
- reports an error through aria-describedby alone. -->
235
+ reports an error through aria-describedby alone.
236
+
237
+ Under a finger the track draws its frame as an inset outline with no
238
+ border, and its padding grows by the border's pixel. A caller that
239
+ lets a long row scroll clips the segments at the track's padding box,
240
+ which a border keeps 2px short of the outer edge, so each segment's
241
+ 44px hit box lost a pixel at the top and the bottom. The track's own
242
+ overflow does not clip its outline, and forced colors keep an outline
243
+ where they drop a shadow. The drawn frame and the segments stay where
244
+ they were. -->
232
245
  <div
233
246
  bind:this={rootEl}
234
247
  {id}
@@ -238,14 +251,16 @@
238
251
  aria-describedby={describedByIds}
239
252
  onfocusout={linked ? onGroupFocusOut : undefined}
240
253
  class="inline-flex items-stretch rounded-lg border {error
241
- ? 'border-danger'
242
- : 'border-line-strong'} bg-surface-2 p-0.5 {SIZES[size].row} {disabled ? 'opacity-50' : ''}"
254
+ ? 'border-danger coarse:outline-danger'
255
+ : 'border-line-strong coarse:outline-line-strong'} bg-surface-2 p-0.5 coarse:border-0 coarse:p-0.75 coarse:outline coarse:outline-1 coarse:-outline-offset-1 {SIZES[
256
+ size
257
+ ].row} {disabled ? 'opacity-50' : ''}"
243
258
  >
244
259
  {#each options as option, i (option.value)}
245
260
  {@const selected = option.value === value}
246
261
  {@const Icon = option.icon}
247
262
  {@const segment =
248
- `${HIT_AREA} inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
263
+ `${HIT_AREA} inline-flex coarse:min-w-control items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
249
264
  SIZES[size].segment +
250
265
  (selected
251
266
  ? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
@@ -19,6 +19,7 @@
19
19
  toISOTime,
20
20
  } from '../internal/time.js';
21
21
  import type { TimeParts, TimeSegment } from '../internal/time.js';
22
+ import { HIT_AREA } from '../internal/touch.js';
22
23
 
23
24
  /**
24
25
  * A time field built from one input per segment, and deliberately not from a
@@ -386,6 +387,46 @@
386
387
  commit();
387
388
  }
388
389
 
390
+ /** Whether the press now under way started inside a segment. */
391
+ let pressInSegment = false;
392
+
393
+ function onGroupPointerdown(e: PointerEvent): void {
394
+ pressInSegment = !!(e.target as Element | null)?.closest('[data-segment]');
395
+ }
396
+
397
+ /**
398
+ * A press on the field's padding, on a colon or on a segment's hit box
399
+ * focuses the nearest segment, as a native time input does. The group is
400
+ * 44px tall under a finger and its segments are 20px, so without this a tap
401
+ * on most of the field focused nothing. A mouse gets the same, because the
402
+ * box it clicks is the box that looks like the field.
403
+ */
404
+ function onGroupClick(e: MouseEvent): void {
405
+ // A drag that selects digits in a segment and ends on the padding fires
406
+ // its click on the group, the nearest element both ends share. Where the
407
+ // press started tells that drag from a press on the padding. Which
408
+ // element holds focus does not: iOS Safari keeps a focused input focused
409
+ // when a finger taps something that cannot take focus, so a guard on
410
+ // focus would refuse a tap on the next segment's box. A click from the
411
+ // keyboard has no press before it, and the flag is reset by every click.
412
+ const startedInSegment = pressInSegment;
413
+ pressInSegment = false;
414
+ if (disabled || !group) return;
415
+ if ((e.target as Element | null)?.closest('[data-segment]')) return;
416
+ if (startedInSegment) return;
417
+ let nearest: HTMLElement | null = null;
418
+ let best = Infinity;
419
+ for (const el of group.querySelectorAll<HTMLElement>('[data-segment]')) {
420
+ const box = el.getBoundingClientRect();
421
+ const distance = Math.max(box.left - e.clientX, 0, e.clientX - box.right);
422
+ if (distance < best) {
423
+ best = distance;
424
+ nearest = el;
425
+ }
426
+ }
427
+ nearest?.focus();
428
+ }
429
+
389
430
  function onMeridiemKeydown(e: KeyboardEvent): void {
390
431
  // Left and right belong to the field: they walk the segments. Up and down
391
432
  // step the value as they do on every other segment, and the first letter
@@ -414,44 +455,63 @@
414
455
  </label>
415
456
  {/if}
416
457
 
458
+ <!--
459
+ Under a finger each segment takes a 44px hit box round its 28px or 32px
460
+ visual. The segments spread apart there so their centers sit 44px apart
461
+ and no box takes its neighbor's: a 4px gap each side of a colon held at
462
+ 8px, and 10px before AM or PM. The field grows 8px wider, more with
463
+ seconds or AM and PM, and a mouse sees it as it was.
464
+
465
+ The press and the click on the group only move focus to a segment, which
466
+ Tab and the label already reach, so they need no key handler of their own.
467
+ -->
468
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
417
469
  <div
418
470
  bind:this={group}
419
471
  role="group"
420
472
  aria-labelledby={label ? `${fieldId}-label` : undefined}
421
473
  aria-label={label ? undefined : 'Time'}
422
474
  aria-describedby={describedBy(fieldId, error, hint)}
423
- class="{CONTROL_SEGMENTED} {segmentedBorder(!!error)} {disabled
475
+ onpointerdown={onGroupPointerdown}
476
+ onclick={onGroupClick}
477
+ class="{CONTROL_SEGMENTED} coarse:gap-1 {segmentedBorder(!!error)} {disabled
424
478
  ? 'cursor-not-allowed opacity-50'
425
479
  : ''}"
426
480
  >
427
481
  {#each digitSegments as segment, i (segment)}
428
482
  {#if i > 0}
429
- <span aria-hidden="true" class="text-faint">:</span>
483
+ <span aria-hidden="true" class="text-faint coarse:w-2 coarse:text-center">:</span>
430
484
  {/if}
431
- <input
432
- data-segment={segment}
433
- id="{fieldId}-{segment}"
434
- type="text"
435
- inputmode="numeric"
436
- role="spinbutton"
437
- autocomplete="off"
438
- spellcheck="false"
439
- maxlength="2"
440
- {disabled}
441
- value={shown(segment)}
442
- aria-label={SEGMENT_LABEL[segment]}
443
- aria-valuenow={shownNow(segment)}
444
- aria-valuemin={segmentMin(segment)}
445
- aria-valuemax={segmentMax(segment)}
446
- aria-valuetext={valueText(segment)}
447
- aria-invalid={error ? 'true' : undefined}
448
- aria-required={required ? 'true' : undefined}
449
- onkeydown={(e) => onSegmentKeydown(segment, e)}
450
- oninput={(e) => onSegmentInput(segment, e)}
451
- onblur={() => onSegmentBlur(segment)}
452
- onfocus={(e) => e.currentTarget.select()}
453
- class="{CONTROL_SEGMENT} w-7"
454
- />
485
+ <!-- An input draws no pseudo-element, so the hit box sits on a span
486
+ round it, and the group's click handler focuses the input a tap on
487
+ the box lands beside. A label would do it without script, but it
488
+ would be a second label in the field for every segment. -->
489
+ <span data-segment-box class="{HIT_AREA} inline-flex">
490
+ <input
491
+ data-segment={segment}
492
+ id="{fieldId}-{segment}"
493
+ type="text"
494
+ inputmode="numeric"
495
+ role="spinbutton"
496
+ autocomplete="off"
497
+ spellcheck="false"
498
+ maxlength="2"
499
+ {disabled}
500
+ value={shown(segment)}
501
+ aria-label={SEGMENT_LABEL[segment]}
502
+ aria-valuenow={shownNow(segment)}
503
+ aria-valuemin={segmentMin(segment)}
504
+ aria-valuemax={segmentMax(segment)}
505
+ aria-valuetext={valueText(segment)}
506
+ aria-invalid={error ? 'true' : undefined}
507
+ aria-required={required ? 'true' : undefined}
508
+ onkeydown={(e) => onSegmentKeydown(segment, e)}
509
+ oninput={(e) => onSegmentInput(segment, e)}
510
+ onblur={() => onSegmentBlur(segment)}
511
+ onfocus={(e) => e.currentTarget.select()}
512
+ class="{CONTROL_SEGMENT} w-7"
513
+ />
514
+ </span>
455
515
  {/each}
456
516
 
457
517
  {#if hour12}
@@ -473,7 +533,7 @@
473
533
  aria-required={required ? 'true' : undefined}
474
534
  onclick={() => setMeridiem(meridiem === 'AM' ? 'PM' : 'AM')}
475
535
  onkeydown={onMeridiemKeydown}
476
- class="{CONTROL_SEGMENT} ms-1 w-8 cursor-pointer"
536
+ class="{HIT_AREA} {CONTROL_SEGMENT} ms-1 coarse:ms-2.5 w-8 cursor-pointer"
477
537
  >
478
538
  {meridiem}
479
539
  </button>
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.1";
106
+ export declare const VERSION = "0.38.3";
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.1';
119
+ export const VERSION = '0.38.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.38.1",
3
+ "version": "0.38.3",
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>",