@urbicon-ui/blocks 6.36.0 → 6.37.1
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.
- package/dist/components/PinInput/PinInput.svelte +325 -0
- package/dist/components/PinInput/PinInput.svelte.d.ts +4 -0
- package/dist/components/PinInput/index.d.ts +98 -0
- package/dist/components/PinInput/index.js +2 -0
- package/dist/components/PinInput/pin-input.variants.d.ts +96 -0
- package/dist/components/PinInput/pin-input.variants.js +111 -0
- package/dist/components/QRCode/QRCode.svelte +107 -0
- package/dist/components/QRCode/QRCode.svelte.d.ts +4 -0
- package/dist/components/QRCode/index.d.ts +74 -0
- package/dist/components/QRCode/index.js +3 -0
- package/dist/components/QRCode/qr-code.variants.d.ts +25 -0
- package/dist/components/QRCode/qr-code.variants.js +30 -0
- package/dist/components/QRCode/qr-encode.d.ts +22 -0
- package/dist/components/QRCode/qr-encode.js +528 -0
- package/dist/components/TimeInput/TimeInput.svelte +536 -0
- package/dist/components/TimeInput/TimeInput.svelte.d.ts +4 -0
- package/dist/components/TimeInput/index.d.ts +93 -0
- package/dist/components/TimeInput/index.js +2 -0
- package/dist/components/TimeInput/time-input.variants.d.ts +131 -0
- package/dist/components/TimeInput/time-input.variants.js +121 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +3 -0
- package/dist/i18n/index.d.ts +18 -0
- package/dist/translations/de.d.ts +6 -0
- package/dist/translations/de.js +6 -0
- package/dist/translations/en.d.ts +6 -0
- package/dist/translations/en.js +6 -0
- package/package.json +3 -3
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { useBlocksI18n } from '../..';
|
|
4
|
+
import { getBlocksConfig, resolveSlotClasses } from '../../provider';
|
|
5
|
+
import { getTierContext } from '../../utils';
|
|
6
|
+
import { resolveIcon } from '../../icons';
|
|
7
|
+
import ClockIconDefault from '../../icons/ClockIcon.svelte';
|
|
8
|
+
import type { TimeInputProps } from './index';
|
|
9
|
+
import { timeInputVariants, type TimeInputVariants } from './time-input.variants';
|
|
10
|
+
|
|
11
|
+
const bt = useBlocksI18n();
|
|
12
|
+
const ClockIcon = resolveIcon('clock', ClockIconDefault);
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
value = $bindable(null),
|
|
16
|
+
format = '24h',
|
|
17
|
+
withSeconds = false,
|
|
18
|
+
min,
|
|
19
|
+
max,
|
|
20
|
+
tier,
|
|
21
|
+
variant = 'outlined',
|
|
22
|
+
size = 'md',
|
|
23
|
+
intent = 'default',
|
|
24
|
+
disabled = false,
|
|
25
|
+
readonly = false,
|
|
26
|
+
required = false,
|
|
27
|
+
fullWidth = false,
|
|
28
|
+
showIcon = true,
|
|
29
|
+
label,
|
|
30
|
+
helper,
|
|
31
|
+
error,
|
|
32
|
+
icon,
|
|
33
|
+
onValueChange,
|
|
34
|
+
name,
|
|
35
|
+
class: className = '',
|
|
36
|
+
unstyled: unstyledProp = false,
|
|
37
|
+
slotClasses: slotClassesProp = {},
|
|
38
|
+
preset,
|
|
39
|
+
id: idProp,
|
|
40
|
+
'aria-label': ariaLabel
|
|
41
|
+
}: TimeInputProps = $props();
|
|
42
|
+
|
|
43
|
+
const tierCtx = getTierContext();
|
|
44
|
+
const effectiveTier = $derived(tier ?? tierCtx?.tier ?? 'modify');
|
|
45
|
+
|
|
46
|
+
const blocksConfig = getBlocksConfig();
|
|
47
|
+
const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
|
|
48
|
+
|
|
49
|
+
const propsId = $props.id();
|
|
50
|
+
const fieldId = $derived(idProp ?? `timeinput-${propsId}`);
|
|
51
|
+
const labelId = $derived(`${fieldId}-label`);
|
|
52
|
+
const messageId = $derived(`${fieldId}-message`);
|
|
53
|
+
const describedBy = $derived(error || helper ? messageId : undefined);
|
|
54
|
+
|
|
55
|
+
const hourMax = $derived(format === '12h' ? 12 : 23);
|
|
56
|
+
const hourMin = $derived(format === '12h' ? 1 : 0);
|
|
57
|
+
|
|
58
|
+
// Segment display strings — the DOM source of truth. Kept unpadded while a
|
|
59
|
+
// single digit is mid-entry, padded once the segment commits, so typing "9"
|
|
60
|
+
// in the hour shows "9" (not "09") until the field advances.
|
|
61
|
+
let hourStr = $state('');
|
|
62
|
+
let minuteStr = $state('');
|
|
63
|
+
let secondStr = $state('');
|
|
64
|
+
let meridiem = $state<'AM' | 'PM'>('AM');
|
|
65
|
+
|
|
66
|
+
let hourEl = $state<HTMLInputElement>();
|
|
67
|
+
let minuteEl = $state<HTMLInputElement>();
|
|
68
|
+
let secondEl = $state<HTMLInputElement>();
|
|
69
|
+
let meridiemEl = $state<HTMLSpanElement>();
|
|
70
|
+
|
|
71
|
+
function pad(n: number): string {
|
|
72
|
+
return String(n).padStart(2, '0');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function num(str: string): number | null {
|
|
76
|
+
if (str === '') return null;
|
|
77
|
+
const n = Number.parseInt(str, 10);
|
|
78
|
+
return Number.isNaN(n) ? null : n;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function to24(h12: number, mer: 'AM' | 'PM'): number {
|
|
82
|
+
if (mer === 'AM') return h12 === 12 ? 0 : h12;
|
|
83
|
+
return h12 === 12 ? 12 : h12 + 12;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Canonical 24-hour value the current segments describe, or null when any
|
|
87
|
+
// required segment is empty.
|
|
88
|
+
function canonicalFromSegments(): string | null {
|
|
89
|
+
const h = num(hourStr);
|
|
90
|
+
const m = num(minuteStr);
|
|
91
|
+
const s = withSeconds ? num(secondStr) : 0;
|
|
92
|
+
if (h === null || m === null || (withSeconds && s === null)) return null;
|
|
93
|
+
// A provisional "0" in a 12-hour hour maps to 12 (midnight/noon); the clamp
|
|
94
|
+
// to [1,12] is belt-and-suspenders so an out-of-range hour (e.g. a stale "13"
|
|
95
|
+
// left over from a runtime format flip) can never produce a 25:xx value.
|
|
96
|
+
const H = format === '12h' ? to24(Math.min(Math.max(h === 0 ? 12 : h, 1), 12), meridiem) : h;
|
|
97
|
+
const parts = [pad(H), pad(m)];
|
|
98
|
+
if (withSeconds) parts.push(pad(s as number));
|
|
99
|
+
return parts.join(':');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setValue(next: string | null) {
|
|
103
|
+
if (next !== value) {
|
|
104
|
+
value = next;
|
|
105
|
+
onValueChange?.(next);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function syncFromValue(v: string | null) {
|
|
110
|
+
if (!v) {
|
|
111
|
+
hourStr = '';
|
|
112
|
+
minuteStr = '';
|
|
113
|
+
secondStr = '';
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const [hh = '', mm = '', ss = ''] = v.split(':');
|
|
117
|
+
const H = num(hh) ?? 0;
|
|
118
|
+
const m = num(mm) ?? 0;
|
|
119
|
+
if (format === '12h') {
|
|
120
|
+
meridiem = H < 12 ? 'AM' : 'PM';
|
|
121
|
+
const h12 = H % 12 === 0 ? 12 : H % 12;
|
|
122
|
+
hourStr = pad(h12);
|
|
123
|
+
} else {
|
|
124
|
+
hourStr = pad(H);
|
|
125
|
+
}
|
|
126
|
+
minuteStr = pad(m);
|
|
127
|
+
secondStr = withSeconds ? pad(num(ss) ?? 0) : '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Re-seed the segments from `value` on any external change. `format` and
|
|
131
|
+
// `withSeconds` are read (via `void`) purely to register them as dependencies:
|
|
132
|
+
// without that, a runtime 24h→12h flip would leave stale segments (e.g. an hour
|
|
133
|
+
// of "13" beside an AM button — a state the next meridiem toggle would turn into
|
|
134
|
+
// "25:30"). The `incoming !== current` guard both skips a local edit (which
|
|
135
|
+
// already set `value`) and fires the re-seed on a format switch, because the
|
|
136
|
+
// canonical computed under the new format no longer matches the raw value.
|
|
137
|
+
$effect(() => {
|
|
138
|
+
const incoming = value ?? null;
|
|
139
|
+
void format;
|
|
140
|
+
void withSeconds;
|
|
141
|
+
const current = untrack(() => canonicalFromSegments());
|
|
142
|
+
if (incoming !== current) {
|
|
143
|
+
untrack(() => syncFromValue(incoming));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const variantProps: TimeInputVariants = $derived({
|
|
148
|
+
tier: effectiveTier,
|
|
149
|
+
variant,
|
|
150
|
+
size,
|
|
151
|
+
intent,
|
|
152
|
+
disabled: disabled || undefined,
|
|
153
|
+
readonly: readonly || undefined,
|
|
154
|
+
error: !!error || undefined,
|
|
155
|
+
required: required || undefined,
|
|
156
|
+
fullWidth: fullWidth || undefined,
|
|
157
|
+
messageType: error ? 'error' : 'helper'
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const styles = $derived(timeInputVariants(variantProps));
|
|
161
|
+
const slotClasses = $derived(
|
|
162
|
+
resolveSlotClasses(blocksConfig, 'TimeInput', preset, variantProps, slotClassesProp)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
function segmentClass() {
|
|
166
|
+
return unstyled
|
|
167
|
+
? (slotClasses?.segment ?? '')
|
|
168
|
+
: styles.segment({ class: slotClasses?.segment });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const order = $derived(
|
|
172
|
+
[
|
|
173
|
+
hourEl,
|
|
174
|
+
minuteEl,
|
|
175
|
+
withSeconds ? secondEl : undefined,
|
|
176
|
+
format === '12h' ? meridiemEl : undefined
|
|
177
|
+
].filter(Boolean) as (HTMLInputElement | HTMLSpanElement)[]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
function advanceFrom(el: HTMLElement) {
|
|
181
|
+
const idx = order.indexOf(el as HTMLInputElement);
|
|
182
|
+
const next = order[idx + 1];
|
|
183
|
+
if (next) {
|
|
184
|
+
next.focus();
|
|
185
|
+
if (next instanceof HTMLInputElement) next.select();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stepBy(str: string, delta: number, segMin: number, segMax: number): string {
|
|
190
|
+
const cur = num(str);
|
|
191
|
+
const span = segMax - segMin + 1;
|
|
192
|
+
if (cur === null) return pad(delta > 0 ? segMin : segMax);
|
|
193
|
+
const next = ((((cur - segMin + delta) % span) + span) % span) + segMin;
|
|
194
|
+
return pad(next);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Digit-entry state machine shared by the three numeric segments. Returns the
|
|
198
|
+
// new display string and whether the segment is complete (→ advance focus).
|
|
199
|
+
function applyDigit(el: HTMLInputElement, segMax: number): { next: string; complete: boolean } {
|
|
200
|
+
const digits = el.value.replace(/\D/g, '');
|
|
201
|
+
if (digits === '') return { next: '', complete: false };
|
|
202
|
+
const two = digits.slice(-2);
|
|
203
|
+
if (two.length === 2) {
|
|
204
|
+
const n = Number.parseInt(two, 10);
|
|
205
|
+
if (n > segMax) {
|
|
206
|
+
// second digit overflowed — treat it as a fresh single-digit entry
|
|
207
|
+
const last = Number.parseInt(two.slice(-1), 10);
|
|
208
|
+
return { next: String(last), complete: last * 10 > segMax };
|
|
209
|
+
}
|
|
210
|
+
return { next: pad(Math.max(n, 0)), complete: true };
|
|
211
|
+
}
|
|
212
|
+
const n = Number.parseInt(two, 10);
|
|
213
|
+
// No valid second digit is possible (e.g. hour "3" in 24h) — commit now.
|
|
214
|
+
if (n * 10 > segMax) return { next: pad(n), complete: true };
|
|
215
|
+
return { next: String(n), complete: false };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleHourInput(e: Event) {
|
|
219
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
220
|
+
const { next, complete } = applyDigit(el, hourMax);
|
|
221
|
+
hourStr = next;
|
|
222
|
+
el.value = next;
|
|
223
|
+
setValue(canonicalFromSegments());
|
|
224
|
+
if (complete) advanceFrom(el);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleMinuteInput(e: Event) {
|
|
228
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
229
|
+
const { next, complete } = applyDigit(el, 59);
|
|
230
|
+
minuteStr = next;
|
|
231
|
+
el.value = next;
|
|
232
|
+
setValue(canonicalFromSegments());
|
|
233
|
+
if (complete) advanceFrom(el);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function handleSecondInput(e: Event) {
|
|
237
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
238
|
+
const { next, complete } = applyDigit(el, 59);
|
|
239
|
+
secondStr = next;
|
|
240
|
+
el.value = next;
|
|
241
|
+
setValue(canonicalFromSegments());
|
|
242
|
+
if (complete) advanceFrom(el);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function commitSegment(which: 'hour' | 'minute' | 'second') {
|
|
246
|
+
// Pad a provisional single digit once the segment loses the caret.
|
|
247
|
+
if (which === 'hour' && hourStr) hourStr = pad(num(hourStr) ?? 0);
|
|
248
|
+
if (which === 'minute' && minuteStr) minuteStr = pad(num(minuteStr) ?? 0);
|
|
249
|
+
if (which === 'second' && secondStr) secondStr = pad(num(secondStr) ?? 0);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type SegName = 'hour' | 'minute' | 'second';
|
|
253
|
+
|
|
254
|
+
function handleSegmentKeydown(seg: SegName, e: KeyboardEvent) {
|
|
255
|
+
if (disabled) return;
|
|
256
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
257
|
+
const segMin = seg === 'hour' ? hourMin : 0;
|
|
258
|
+
const segMax = seg === 'hour' ? hourMax : 59;
|
|
259
|
+
const strOf = seg === 'hour' ? hourStr : seg === 'minute' ? minuteStr : secondStr;
|
|
260
|
+
const setStr = (v: string) => {
|
|
261
|
+
if (seg === 'hour') hourStr = v;
|
|
262
|
+
else if (seg === 'minute') minuteStr = v;
|
|
263
|
+
else secondStr = v;
|
|
264
|
+
};
|
|
265
|
+
switch (e.key) {
|
|
266
|
+
case 'ArrowUp':
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
if (readonly) break;
|
|
269
|
+
setStr(stepBy(strOf, 1, segMin, segMax));
|
|
270
|
+
setValue(canonicalFromSegments());
|
|
271
|
+
break;
|
|
272
|
+
case 'ArrowDown':
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
if (readonly) break;
|
|
275
|
+
setStr(stepBy(strOf, -1, segMin, segMax));
|
|
276
|
+
setValue(canonicalFromSegments());
|
|
277
|
+
break;
|
|
278
|
+
case 'ArrowLeft': {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
const idx = order.indexOf(el);
|
|
281
|
+
if (idx > 0) order[idx - 1].focus();
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'ArrowRight':
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
advanceFrom(el);
|
|
287
|
+
break;
|
|
288
|
+
case 'Backspace':
|
|
289
|
+
if (readonly) break;
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
if (strOf) {
|
|
292
|
+
setStr('');
|
|
293
|
+
el.value = '';
|
|
294
|
+
setValue(canonicalFromSegments());
|
|
295
|
+
} else {
|
|
296
|
+
const idx = order.indexOf(el);
|
|
297
|
+
if (idx > 0) order[idx - 1].focus();
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function handleSegmentFocus(e: FocusEvent) {
|
|
304
|
+
(e.currentTarget as HTMLInputElement).select();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function toggleMeridiem(next?: 'AM' | 'PM') {
|
|
308
|
+
if (disabled || readonly) return;
|
|
309
|
+
meridiem = next ?? (meridiem === 'AM' ? 'PM' : 'AM');
|
|
310
|
+
setValue(canonicalFromSegments());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handleMeridiemKeydown(e: KeyboardEvent) {
|
|
314
|
+
if (disabled) return;
|
|
315
|
+
switch (e.key) {
|
|
316
|
+
case 'ArrowUp':
|
|
317
|
+
case 'ArrowDown':
|
|
318
|
+
// The segment is a spinbutton on a span (html-aria forbids the role on
|
|
319
|
+
// <button>), so Enter/Space activation is wired manually.
|
|
320
|
+
case 'Enter':
|
|
321
|
+
case ' ':
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
toggleMeridiem();
|
|
324
|
+
break;
|
|
325
|
+
case 'a':
|
|
326
|
+
case 'A':
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
toggleMeridiem('AM');
|
|
329
|
+
break;
|
|
330
|
+
case 'p':
|
|
331
|
+
case 'P':
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
toggleMeridiem('PM');
|
|
334
|
+
break;
|
|
335
|
+
case 'ArrowLeft':
|
|
336
|
+
case 'Backspace': {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
const idx = order.indexOf(e.currentTarget as HTMLSpanElement);
|
|
339
|
+
if (idx > 0) order[idx - 1].focus();
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Clamp to [min, max] once focus leaves the whole field. Canonical HH:MM(:SS)
|
|
346
|
+
// strings are zero-padded, so lexical comparison is chronological.
|
|
347
|
+
function handleFocusOut(e: FocusEvent) {
|
|
348
|
+
const nextTarget = e.relatedTarget as Node | null;
|
|
349
|
+
if (nextTarget && (e.currentTarget as HTMLElement).contains(nextTarget)) return;
|
|
350
|
+
commitSegment('hour');
|
|
351
|
+
commitSegment('minute');
|
|
352
|
+
commitSegment('second');
|
|
353
|
+
let canonical = canonicalFromSegments();
|
|
354
|
+
if (canonical !== null) {
|
|
355
|
+
if (min && canonical < min) canonical = min;
|
|
356
|
+
if (max && canonical > max) canonical = max;
|
|
357
|
+
if (canonical !== value) {
|
|
358
|
+
setValue(canonical);
|
|
359
|
+
syncFromValue(canonical);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
</script>
|
|
364
|
+
|
|
365
|
+
<div
|
|
366
|
+
class={unstyled
|
|
367
|
+
? [slotClasses?.wrapper, className].filter(Boolean).join(' ')
|
|
368
|
+
: styles.wrapper({ class: [slotClasses?.wrapper, className] })}
|
|
369
|
+
>
|
|
370
|
+
{#if label}
|
|
371
|
+
<span
|
|
372
|
+
id={labelId}
|
|
373
|
+
class={unstyled ? (slotClasses?.label ?? '') : styles.label({ class: slotClasses?.label })}
|
|
374
|
+
>
|
|
375
|
+
{label}
|
|
376
|
+
</span>
|
|
377
|
+
{/if}
|
|
378
|
+
|
|
379
|
+
<div
|
|
380
|
+
role="group"
|
|
381
|
+
aria-labelledby={label ? labelId : undefined}
|
|
382
|
+
aria-label={label ? undefined : ariaLabel}
|
|
383
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
384
|
+
class={unstyled ? (slotClasses?.field ?? '') : styles.field({ class: slotClasses?.field })}
|
|
385
|
+
onfocusout={handleFocusOut}
|
|
386
|
+
>
|
|
387
|
+
{#if showIcon}
|
|
388
|
+
<span
|
|
389
|
+
class={unstyled ? (slotClasses?.icon ?? '') : styles.icon({ class: slotClasses?.icon })}
|
|
390
|
+
>
|
|
391
|
+
{#if icon}
|
|
392
|
+
{@render icon()}
|
|
393
|
+
{:else}
|
|
394
|
+
<ClockIcon />
|
|
395
|
+
{/if}
|
|
396
|
+
</span>
|
|
397
|
+
{/if}
|
|
398
|
+
|
|
399
|
+
<input
|
|
400
|
+
bind:this={hourEl}
|
|
401
|
+
id={fieldId}
|
|
402
|
+
value={hourStr}
|
|
403
|
+
type="text"
|
|
404
|
+
inputmode="numeric"
|
|
405
|
+
maxlength="2"
|
|
406
|
+
placeholder="--"
|
|
407
|
+
autocomplete="off"
|
|
408
|
+
{disabled}
|
|
409
|
+
{readonly}
|
|
410
|
+
role="spinbutton"
|
|
411
|
+
aria-label={bt('accessibility.timeHours')}
|
|
412
|
+
aria-valuemin={hourMin}
|
|
413
|
+
aria-valuemax={hourMax}
|
|
414
|
+
aria-valuenow={num(hourStr) ?? undefined}
|
|
415
|
+
aria-invalid={error ? 'true' : undefined}
|
|
416
|
+
aria-describedby={describedBy}
|
|
417
|
+
class={segmentClass()}
|
|
418
|
+
oninput={handleHourInput}
|
|
419
|
+
onkeydown={(e) => handleSegmentKeydown('hour', e)}
|
|
420
|
+
onfocus={handleSegmentFocus}
|
|
421
|
+
/>
|
|
422
|
+
<span
|
|
423
|
+
aria-hidden="true"
|
|
424
|
+
class={unstyled
|
|
425
|
+
? (slotClasses?.separator ?? '')
|
|
426
|
+
: styles.separator({ class: slotClasses?.separator })}
|
|
427
|
+
>
|
|
428
|
+
:
|
|
429
|
+
</span>
|
|
430
|
+
<input
|
|
431
|
+
bind:this={minuteEl}
|
|
432
|
+
value={minuteStr}
|
|
433
|
+
type="text"
|
|
434
|
+
inputmode="numeric"
|
|
435
|
+
maxlength="2"
|
|
436
|
+
placeholder="--"
|
|
437
|
+
autocomplete="off"
|
|
438
|
+
{disabled}
|
|
439
|
+
{readonly}
|
|
440
|
+
role="spinbutton"
|
|
441
|
+
aria-label={bt('accessibility.timeMinutes')}
|
|
442
|
+
aria-valuemin={0}
|
|
443
|
+
aria-valuemax={59}
|
|
444
|
+
aria-valuenow={num(minuteStr) ?? undefined}
|
|
445
|
+
aria-invalid={error ? 'true' : undefined}
|
|
446
|
+
aria-describedby={describedBy}
|
|
447
|
+
class={segmentClass()}
|
|
448
|
+
oninput={handleMinuteInput}
|
|
449
|
+
onkeydown={(e) => handleSegmentKeydown('minute', e)}
|
|
450
|
+
onfocus={handleSegmentFocus}
|
|
451
|
+
/>
|
|
452
|
+
{#if withSeconds}
|
|
453
|
+
<span
|
|
454
|
+
aria-hidden="true"
|
|
455
|
+
class={unstyled
|
|
456
|
+
? (slotClasses?.separator ?? '')
|
|
457
|
+
: styles.separator({ class: slotClasses?.separator })}
|
|
458
|
+
>
|
|
459
|
+
:
|
|
460
|
+
</span>
|
|
461
|
+
<input
|
|
462
|
+
bind:this={secondEl}
|
|
463
|
+
value={secondStr}
|
|
464
|
+
type="text"
|
|
465
|
+
inputmode="numeric"
|
|
466
|
+
maxlength="2"
|
|
467
|
+
placeholder="--"
|
|
468
|
+
autocomplete="off"
|
|
469
|
+
{disabled}
|
|
470
|
+
{readonly}
|
|
471
|
+
role="spinbutton"
|
|
472
|
+
aria-label={bt('accessibility.timeSeconds')}
|
|
473
|
+
aria-valuemin={0}
|
|
474
|
+
aria-valuemax={59}
|
|
475
|
+
aria-valuenow={num(secondStr) ?? undefined}
|
|
476
|
+
aria-invalid={error ? 'true' : undefined}
|
|
477
|
+
aria-describedby={describedBy}
|
|
478
|
+
class={segmentClass()}
|
|
479
|
+
oninput={handleSecondInput}
|
|
480
|
+
onkeydown={(e) => handleSegmentKeydown('second', e)}
|
|
481
|
+
onfocus={handleSegmentFocus}
|
|
482
|
+
/>
|
|
483
|
+
{/if}
|
|
484
|
+
{#if format === '12h'}
|
|
485
|
+
<!-- A spinbutton (not a button): aria-label on a button would OVERRIDE its
|
|
486
|
+
AM/PM content, so the current state was never announced. As a
|
|
487
|
+
spinbutton the state travels via aria-valuetext — same semantics as
|
|
488
|
+
the sibling segments — and html-aria only permits the role on a
|
|
489
|
+
non-button host, hence the span with manual focus/activation. -->
|
|
490
|
+
<span
|
|
491
|
+
bind:this={meridiemEl}
|
|
492
|
+
role="spinbutton"
|
|
493
|
+
tabindex={disabled ? -1 : 0}
|
|
494
|
+
aria-label={bt('accessibility.timeMeridiem')}
|
|
495
|
+
aria-valuemin={0}
|
|
496
|
+
aria-valuemax={1}
|
|
497
|
+
aria-valuenow={meridiem === 'AM' ? 0 : 1}
|
|
498
|
+
aria-valuetext={meridiem}
|
|
499
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
500
|
+
aria-readonly={readonly ? 'true' : undefined}
|
|
501
|
+
class={unstyled
|
|
502
|
+
? (slotClasses?.meridiem ?? '')
|
|
503
|
+
: styles.meridiem({ class: slotClasses?.meridiem })}
|
|
504
|
+
onclick={() => toggleMeridiem()}
|
|
505
|
+
onkeydown={handleMeridiemKeydown}
|
|
506
|
+
>
|
|
507
|
+
{meridiem}
|
|
508
|
+
</span>
|
|
509
|
+
{/if}
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
{#if error}
|
|
513
|
+
<div
|
|
514
|
+
id={messageId}
|
|
515
|
+
role="alert"
|
|
516
|
+
class={unstyled
|
|
517
|
+
? (slotClasses?.message ?? '')
|
|
518
|
+
: styles.message({ class: slotClasses?.message })}
|
|
519
|
+
>
|
|
520
|
+
{error}
|
|
521
|
+
</div>
|
|
522
|
+
{:else if helper}
|
|
523
|
+
<div
|
|
524
|
+
id={messageId}
|
|
525
|
+
class={unstyled
|
|
526
|
+
? (slotClasses?.message ?? '')
|
|
527
|
+
: styles.message({ class: slotClasses?.message })}
|
|
528
|
+
>
|
|
529
|
+
{helper}
|
|
530
|
+
</div>
|
|
531
|
+
{/if}
|
|
532
|
+
|
|
533
|
+
{#if name}
|
|
534
|
+
<input type="hidden" {name} value={value ?? ''} />
|
|
535
|
+
{/if}
|
|
536
|
+
</div>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { TimeInputSlots, TimeInputVariants } from './time-input.variants.js';
|
|
3
|
+
/**
|
|
4
|
+
* @description Segmented time-of-day field — hour / minute (/ second) cells in a
|
|
5
|
+
* single unified control, with per-segment Arrow-key stepping, digit auto-advance,
|
|
6
|
+
* and 12- or 24-hour display. Fills the last form-family gap (Calendar, DatePicker
|
|
7
|
+
* and DateRangePicker cover dates; this covers time). The value is always a
|
|
8
|
+
* canonical 24-hour `HH:MM` (or `HH:MM:SS`) string regardless of display format,
|
|
9
|
+
* and `null` when empty.
|
|
10
|
+
*
|
|
11
|
+
* @tag form
|
|
12
|
+
* @related DatePicker
|
|
13
|
+
* @related NumberInput
|
|
14
|
+
* @related Input
|
|
15
|
+
* @stability beta
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```svelte
|
|
19
|
+
* <script>
|
|
20
|
+
* let time = $state('09:30');
|
|
21
|
+
* </script>
|
|
22
|
+
* <TimeInput label="Start" bind:value={time} />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example 12-hour display with seconds and bounds
|
|
26
|
+
* ```svelte
|
|
27
|
+
* <TimeInput format="12h" withSeconds min="08:00" max="18:00" bind:value={time} />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export interface TimeInputProps extends Omit<TimeInputVariants, 'error'> {
|
|
31
|
+
/**
|
|
32
|
+
* Current time as a canonical 24-hour `HH:MM` / `HH:MM:SS` string; `null` when
|
|
33
|
+
* empty. The stored format never changes with `format`. Supports `bind:value`.
|
|
34
|
+
*/
|
|
35
|
+
value?: string | null;
|
|
36
|
+
/** Display the hour as 12-hour with an AM/PM segment. The value stays 24-hour. @default '24h' */
|
|
37
|
+
format?: '12h' | '24h';
|
|
38
|
+
/** Add a seconds segment. @default false */
|
|
39
|
+
withSeconds?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Earliest allowed time, canonical 24-hour `HH:MM`(`:SS`). Values below it are
|
|
42
|
+
* clamped up on blur.
|
|
43
|
+
*/
|
|
44
|
+
min?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Latest allowed time, canonical 24-hour `HH:MM`(`:SS`). Values above it are
|
|
47
|
+
* clamped down on blur.
|
|
48
|
+
*/
|
|
49
|
+
max?: string;
|
|
50
|
+
/** @default false */
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
/** @default false */
|
|
53
|
+
readonly?: boolean;
|
|
54
|
+
/** Adds a required asterisk to the label. @default false */
|
|
55
|
+
required?: boolean;
|
|
56
|
+
/** Stretch the field to the full width of its container. @default false */
|
|
57
|
+
fullWidth?: boolean;
|
|
58
|
+
/** Show the leading clock icon. @default true */
|
|
59
|
+
showIcon?: boolean;
|
|
60
|
+
/** Label text displayed above the field, linked via `aria-labelledby`. */
|
|
61
|
+
label?: string;
|
|
62
|
+
/** Helper text below the field — hidden when `error` is present. */
|
|
63
|
+
helper?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Error message below the field. When set it overrides `helper`, colours the
|
|
66
|
+
* field danger, and marks the segments `aria-invalid`.
|
|
67
|
+
*/
|
|
68
|
+
error?: string;
|
|
69
|
+
/** A custom leading icon; replaces the default clock. */
|
|
70
|
+
icon?: Snippet;
|
|
71
|
+
/** Fires after any change with the canonical 24-hour value (or `null`). */
|
|
72
|
+
onValueChange?: (value: string | null) => void;
|
|
73
|
+
/** Name for a hidden input carrying the canonical value, for native form submission. */
|
|
74
|
+
name?: string;
|
|
75
|
+
/** Extra classes merged onto the root wrapper. */
|
|
76
|
+
class?: string;
|
|
77
|
+
/** Remove all default tv() classes — only user-provided classes apply. */
|
|
78
|
+
unstyled?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Per-slot class overrides merged with tv() styles. Slots: wrapper (what
|
|
81
|
+
* `class` also targets) | label | field | icon | segment | separator |
|
|
82
|
+
* meridiem | message.
|
|
83
|
+
*/
|
|
84
|
+
slotClasses?: Partial<Record<TimeInputSlots, string>>;
|
|
85
|
+
/** Apply a named preset registered via `<BlocksProvider presets={{ TimeInput: {...} }}>`. */
|
|
86
|
+
preset?: string;
|
|
87
|
+
/** Accessible name for the field group when no visible `label` is set. */
|
|
88
|
+
'aria-label'?: string;
|
|
89
|
+
/** Root id; the segments derive their ids and ARIA wiring from it. */
|
|
90
|
+
id?: string;
|
|
91
|
+
}
|
|
92
|
+
export { default as TimeInput } from './TimeInput.svelte';
|
|
93
|
+
export { type TimeInputVariants, timeInputVariants } from './time-input.variants.js';
|