@lyeve-labs/ui-kit 0.38.2 → 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.
@@ -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.2";
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.2';
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.2",
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>",