@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
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
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.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
273
|
-
{
|
|
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
|
|
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} {
|
|
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
|
|
356
|
+
onclick={() => onchange?.(n)}
|
|
303
357
|
aria-current={safePage === n ? 'page' : undefined}
|
|
304
|
-
class="{btnBase} {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
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.
|
|
119
|
+
export const VERSION = '0.38.3';
|
package/package.json
CHANGED