@human-kit/svelte-components 1.0.0-alpha.3 → 1.0.0-alpha.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.
Files changed (110) hide show
  1. package/dist/FOCUS_STATE_CONTRACT.md +12 -0
  2. package/dist/calendar/body-cell/README.md +15 -0
  3. package/dist/calendar/grid/README.md +13 -0
  4. package/dist/calendar/grid-body/README.md +13 -0
  5. package/dist/calendar/grid-header/README.md +13 -0
  6. package/dist/calendar/header-cell/README.md +14 -0
  7. package/dist/calendar/heading/README.md +13 -0
  8. package/dist/calendar/root/README.md +24 -0
  9. package/dist/calendar/trigger-next/README.md +14 -0
  10. package/dist/calendar/trigger-previous/README.md +14 -0
  11. package/dist/clock/README.md +75 -0
  12. package/dist/clock/axis/README.md +24 -0
  13. package/dist/clock/axis/clock-axis.svelte +37 -0
  14. package/dist/clock/axis/clock-axis.svelte.d.ts +8 -0
  15. package/dist/clock/hooks/use-wheel-scroll.svelte.d.ts +16 -0
  16. package/dist/clock/hooks/use-wheel-scroll.svelte.js +336 -0
  17. package/dist/clock/index.d.ts +10 -0
  18. package/dist/clock/index.js +10 -0
  19. package/dist/clock/index.parts.d.ts +4 -0
  20. package/dist/clock/index.parts.js +4 -0
  21. package/dist/clock/root/README.md +38 -0
  22. package/dist/clock/root/clock-root-test.svelte +62 -0
  23. package/dist/clock/root/clock-root-test.svelte.d.ts +14 -0
  24. package/dist/clock/root/clock-root.svelte +329 -0
  25. package/dist/clock/root/clock-root.svelte.d.ts +25 -0
  26. package/dist/clock/root/context.d.ts +22 -0
  27. package/dist/clock/root/context.js +15 -0
  28. package/dist/clock/root/resolve-visible-columns.d.ts +7 -0
  29. package/dist/clock/root/resolve-visible-columns.js +16 -0
  30. package/dist/clock/root/time-utils.d.ts +48 -0
  31. package/dist/clock/root/time-utils.js +314 -0
  32. package/dist/clock/root/wheel-options.d.ts +17 -0
  33. package/dist/clock/root/wheel-options.js +63 -0
  34. package/dist/clock/wheel-column/README.md +25 -0
  35. package/dist/clock/wheel-column/clock-wheel-column-bindable-test.svelte +16 -0
  36. package/dist/clock/wheel-column/clock-wheel-column-bindable-test.svelte.d.ts +3 -0
  37. package/dist/clock/wheel-column/clock-wheel-column-custom-snippet-test.svelte +29 -0
  38. package/dist/clock/wheel-column/clock-wheel-column-custom-snippet-test.svelte.d.ts +6 -0
  39. package/dist/clock/wheel-column/clock-wheel-column-default-height-test.svelte +11 -0
  40. package/dist/clock/wheel-column/clock-wheel-column-default-height-test.svelte.d.ts +3 -0
  41. package/dist/clock/wheel-column/clock-wheel-column-test.svelte +38 -0
  42. package/dist/clock/wheel-column/clock-wheel-column-test.svelte.d.ts +12 -0
  43. package/dist/clock/wheel-column/clock-wheel-column-tp-test.svelte +38 -0
  44. package/dist/clock/wheel-column/clock-wheel-column-tp-test.svelte.d.ts +12 -0
  45. package/dist/clock/wheel-column/clock-wheel-column-untagged-snippet-test.svelte +29 -0
  46. package/dist/clock/wheel-column/clock-wheel-column-untagged-snippet-test.svelte.d.ts +6 -0
  47. package/dist/clock/wheel-column/clock-wheel-column.svelte +499 -0
  48. package/dist/clock/wheel-column/clock-wheel-column.svelte.d.ts +17 -0
  49. package/dist/clock/wheel-item/README.md +17 -0
  50. package/dist/clock/wheel-item/clock-wheel-item.svelte +49 -0
  51. package/dist/clock/wheel-item/clock-wheel-item.svelte.d.ts +17 -0
  52. package/dist/datepicker/TODO.md +2 -2
  53. package/dist/datepicker/calendar/README.md +19 -0
  54. package/dist/datepicker/input/README.md +15 -0
  55. package/dist/datepicker/popover/README.md +20 -0
  56. package/dist/datepicker/root/README.md +38 -0
  57. package/dist/datepicker/segment/README.md +14 -0
  58. package/dist/datepicker/trigger/README.md +14 -0
  59. package/dist/index.d.ts +5 -0
  60. package/dist/index.js +5 -0
  61. package/dist/primitives/focus-trap.js +11 -12
  62. package/dist/primitives/input-modality.js +10 -1
  63. package/dist/timepicker/IMPLEMENTATION_PLAN.md +254 -0
  64. package/dist/timepicker/README.md +97 -0
  65. package/dist/timepicker/TODO.md +86 -0
  66. package/dist/timepicker/clock/README.md +14 -0
  67. package/dist/timepicker/clock/time-picker-clock-test.svelte +45 -0
  68. package/dist/timepicker/clock/time-picker-clock-test.svelte.d.ts +11 -0
  69. package/dist/timepicker/clock/time-picker-clock.svelte +65 -0
  70. package/dist/timepicker/clock/time-picker-clock.svelte.d.ts +10 -0
  71. package/dist/timepicker/index.d.ts +14 -0
  72. package/dist/timepicker/index.js +14 -0
  73. package/dist/timepicker/index.parts.d.ts +8 -0
  74. package/dist/timepicker/index.parts.js +8 -0
  75. package/dist/timepicker/input/README.md +15 -0
  76. package/dist/timepicker/input/time-picker-input-forwarding-test.svelte +40 -0
  77. package/dist/timepicker/input/time-picker-input-forwarding-test.svelte.d.ts +3 -0
  78. package/dist/timepicker/input/time-picker-input.svelte +109 -0
  79. package/dist/timepicker/input/time-picker-input.svelte.d.ts +11 -0
  80. package/dist/timepicker/internal/strict-props.d.ts +4 -0
  81. package/dist/timepicker/internal/strict-props.js +51 -0
  82. package/dist/timepicker/popover/README.md +20 -0
  83. package/dist/timepicker/popover/time-picker-popover-unsafe-props-test.svelte +22 -0
  84. package/dist/timepicker/popover/time-picker-popover-unsafe-props-test.svelte.d.ts +3 -0
  85. package/dist/timepicker/popover/time-picker-popover.svelte +89 -0
  86. package/dist/timepicker/popover/time-picker-popover.svelte.d.ts +7 -0
  87. package/dist/timepicker/root/README.md +42 -0
  88. package/dist/timepicker/root/context.d.ts +51 -0
  89. package/dist/timepicker/root/context.js +15 -0
  90. package/dist/timepicker/root/time-picker-12h-test.svelte +22 -0
  91. package/dist/timepicker/root/time-picker-12h-test.svelte.d.ts +3 -0
  92. package/dist/timepicker/root/time-picker-bindable-test.svelte +25 -0
  93. package/dist/timepicker/root/time-picker-bindable-test.svelte.d.ts +3 -0
  94. package/dist/timepicker/root/time-picker-empty-test.svelte +20 -0
  95. package/dist/timepicker/root/time-picker-empty-test.svelte.d.ts +3 -0
  96. package/dist/timepicker/root/time-picker-root.svelte +625 -0
  97. package/dist/timepicker/root/time-picker-root.svelte.d.ts +28 -0
  98. package/dist/timepicker/root/time-picker-test.svelte +72 -0
  99. package/dist/timepicker/root/time-picker-test.svelte.d.ts +15 -0
  100. package/dist/timepicker/root/time-utils.d.ts +1 -0
  101. package/dist/timepicker/root/time-utils.js +3 -0
  102. package/dist/timepicker/segment/README.md +14 -0
  103. package/dist/timepicker/segment/time-picker-segment.svelte +365 -0
  104. package/dist/timepicker/segment/time-picker-segment.svelte.d.ts +9 -0
  105. package/dist/timepicker/trigger/README.md +14 -0
  106. package/dist/timepicker/trigger/time-picker-trigger-forwarding-test.svelte +35 -0
  107. package/dist/timepicker/trigger/time-picker-trigger-forwarding-test.svelte.d.ts +3 -0
  108. package/dist/timepicker/trigger/time-picker-trigger.svelte +122 -0
  109. package/dist/timepicker/trigger/time-picker-trigger.svelte.d.ts +9 -0
  110. package/package.json +11 -1
@@ -0,0 +1,499 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { useClockContext, type ClockEditableSegmentType } from '../root/context';
5
+ import TimePickerWheelItem from '../wheel-item/clock-wheel-item.svelte';
6
+ import { useWheelScroll, type WheelScrollBehavior } from '../hooks/use-wheel-scroll.svelte';
7
+ import {
8
+ focusWithModality,
9
+ shouldShowFocusVisible,
10
+ trackInteractionModality
11
+ } from '../../primitives/input-modality';
12
+
13
+ type TimePickerWheelOption = {
14
+ value: string;
15
+ label: string;
16
+ disabled: boolean;
17
+ };
18
+
19
+ type TimePickerWheelColumnProps = Omit<
20
+ HTMLAttributes<HTMLDivElement>,
21
+ 'children' | 'class' | 'role' | 'tabindex' | 'aria-label' | 'onkeydown'
22
+ > & {
23
+ type: ClockEditableSegmentType;
24
+ children?: Snippet<[TimePickerWheelOption]>;
25
+ class?: string;
26
+ 'aria-label'?: string;
27
+ };
28
+
29
+ let {
30
+ type,
31
+ children,
32
+ class: className = '',
33
+ 'aria-label': ariaLabel,
34
+ ...restProps
35
+ }: TimePickerWheelColumnProps = $props();
36
+
37
+ function hasExplicitHeightClass(value: string): boolean {
38
+ return /(^|\s)(?:[\w-]+:)*(?:h-|min-h-|max-h-|size-)/.test(value);
39
+ }
40
+
41
+ const resolvedClassName = $derived.by(() => {
42
+ const trimmed = className.trim();
43
+ if (hasExplicitHeightClass(trimmed)) return trimmed;
44
+ return trimmed.length > 0 ? `${trimmed} h-55` : 'h-55';
45
+ });
46
+
47
+ const clock = useClockContext();
48
+ const options = $derived.by(() => clock.getWheelOptions(type));
49
+ const selectedValue = $derived(clock.getSelectedWheelValue(type));
50
+ const label = $derived(
51
+ ariaLabel ?? (type === 'dayPeriod' ? 'Day period' : clock.getSegmentLabel(type))
52
+ );
53
+ const wheelRoleDescription = $derived.by(() => {
54
+ const normalizedLocale = clock.locale.toLowerCase();
55
+ if (normalizedLocale.startsWith('es')) return 'selector en rueda';
56
+ if (normalizedLocale.startsWith('pt')) return 'seletor em roda';
57
+ return 'wheel picker';
58
+ });
59
+
60
+ let wheelRef: HTMLElement | null = null;
61
+ let wheelApi: ReturnType<typeof useWheelScroll> | null = null;
62
+ let resizeObserver: ResizeObserver | null = null;
63
+ let resizeFrameId: number | null = null;
64
+ let itemHeight = $state(0);
65
+ let spacerHeight = $state(0);
66
+ let focusWithin = $state(false);
67
+ let focusVisible = $state(false);
68
+ let lastCenteredIndex = -1;
69
+ let didAlignForCurrentOpen = false;
70
+ let lastOpenOptionsSignature = '';
71
+
72
+ function getWheelItemElements(): HTMLElement[] {
73
+ const taggedItems = Array.from(
74
+ wheelRef?.querySelectorAll<HTMLElement>('[data-wheel-item]') ?? []
75
+ );
76
+ if (taggedItems.length > 0) return taggedItems;
77
+ if (!wheelRef) return [];
78
+ return Array.from(wheelRef.children).filter((child): child is HTMLElement => {
79
+ if (!(child instanceof HTMLElement)) return false;
80
+ if (child.matches('[data-wheel-spacer], [data-wheel-highlight], [role="status"], .sr-only')) {
81
+ return false;
82
+ }
83
+ return true;
84
+ });
85
+ }
86
+
87
+ const enabledOptionIndexes = $derived.by(() => {
88
+ const indexes: number[] = [];
89
+ for (let index = 0; index < options.length; index += 1) {
90
+ if (!options[index].disabled) {
91
+ indexes.push(index);
92
+ }
93
+ }
94
+ return indexes;
95
+ });
96
+
97
+ const selectedIndex = $derived.by(() => {
98
+ if (selectedValue === null) return -1;
99
+ return options.findIndex((option) => option.value === selectedValue);
100
+ });
101
+
102
+ const selectedOption = $derived.by(() => {
103
+ if (selectedIndex < 0) return null;
104
+ return options[selectedIndex] ?? null;
105
+ });
106
+
107
+ const minValue = $derived.by(() => {
108
+ const firstEnabledIndex = enabledOptionIndexes[0];
109
+ if (firstEnabledIndex === undefined) return undefined;
110
+ return getAriaValueNow(options[firstEnabledIndex]?.value ?? null);
111
+ });
112
+
113
+ const maxValue = $derived.by(() => {
114
+ const lastEnabledIndex = enabledOptionIndexes[enabledOptionIndexes.length - 1];
115
+ if (lastEnabledIndex === undefined) return undefined;
116
+ return getAriaValueNow(options[lastEnabledIndex]?.value ?? null);
117
+ });
118
+
119
+ const valueNow = $derived.by(() => getAriaValueNow(selectedOption?.value ?? null));
120
+ const valueText = $derived.by(() => selectedOption?.label ?? undefined);
121
+
122
+ function getAriaValueNow(value: string | null): number | undefined {
123
+ if (!value) return undefined;
124
+ if (type === 'dayPeriod') {
125
+ const normalized = value.toUpperCase();
126
+ return normalized === 'PM' ? 1 : 0;
127
+ }
128
+ const numeric = Number(value);
129
+ if (!Number.isFinite(numeric)) return undefined;
130
+ return numeric;
131
+ }
132
+
133
+ function syncMeasurements() {
134
+ if (!wheelRef) return;
135
+ const firstItem = getWheelItemElements()[0] ?? null;
136
+ if (!firstItem) return;
137
+
138
+ const nextItemHeight = firstItem.offsetHeight;
139
+ if (nextItemHeight <= 0) return;
140
+ itemHeight = nextItemHeight;
141
+ spacerHeight = Math.max(0, Math.floor((wheelRef.clientHeight - nextItemHeight) / 2));
142
+ }
143
+
144
+ function mountWheelApi() {
145
+ if (!wheelRef) return;
146
+ wheelApi?.destroy();
147
+ wheelApi = useWheelScroll(wheelRef, handleSnapToIndex);
148
+ }
149
+
150
+ function destroyWheelApi() {
151
+ wheelApi?.destroy();
152
+ wheelApi = null;
153
+ }
154
+
155
+ function findClosestEnabledIndex(fromIndex: number, direction: 1 | -1): number {
156
+ if (enabledOptionIndexes.length === 0) return -1;
157
+
158
+ for (
159
+ let index = Math.min(options.length - 1, Math.max(0, fromIndex));
160
+ index >= 0 && index < options.length;
161
+ index += direction
162
+ ) {
163
+ if (!options[index]?.disabled) {
164
+ return index;
165
+ }
166
+ }
167
+
168
+ for (
169
+ let index = Math.min(options.length - 1, Math.max(0, fromIndex));
170
+ index >= 0 && index < options.length;
171
+ index += direction * -1
172
+ ) {
173
+ if (!options[index]?.disabled) {
174
+ return index;
175
+ }
176
+ }
177
+
178
+ return -1;
179
+ }
180
+
181
+ function handleSnapToIndex(nextIndex: number): number | null {
182
+ if (nextIndex < 0 || nextIndex >= options.length) return null;
183
+ const option = options[nextIndex];
184
+ if (!option) return null;
185
+
186
+ const direction: 1 | -1 = lastCenteredIndex >= 0 && nextIndex < lastCenteredIndex ? -1 : 1;
187
+
188
+ if (option.disabled) {
189
+ const fallbackIndex = findClosestEnabledIndex(nextIndex, direction);
190
+ if (fallbackIndex >= 0 && fallbackIndex !== nextIndex) {
191
+ lastCenteredIndex = fallbackIndex;
192
+ return fallbackIndex;
193
+ }
194
+ return null;
195
+ }
196
+
197
+ lastCenteredIndex = nextIndex;
198
+
199
+ if (selectedValue !== option.value) {
200
+ clock.selectWheelValue(type, option.value);
201
+ }
202
+
203
+ return nextIndex;
204
+ }
205
+
206
+ function scrollToSelected(behavior: 'smooth' | 'instant') {
207
+ if (!wheelApi) return;
208
+ if (options.length === 0) return;
209
+
210
+ const nextIndex =
211
+ selectedIndex >= 0
212
+ ? selectedIndex
213
+ : enabledOptionIndexes[0] !== undefined
214
+ ? enabledOptionIndexes[0]
215
+ : 0;
216
+
217
+ if (nextIndex < 0) return;
218
+ lastCenteredIndex = nextIndex;
219
+ wheelApi.scrollToIndex(nextIndex, behavior);
220
+ }
221
+
222
+ function syncFocusWithin(currentTarget: HTMLElement) {
223
+ focusWithin =
224
+ !!document.activeElement &&
225
+ (currentTarget === document.activeElement || currentTarget.contains(document.activeElement));
226
+ if (!focusWithin) {
227
+ focusVisible = false;
228
+ }
229
+ }
230
+
231
+ function focusSiblingColumn(direction: 1 | -1): boolean {
232
+ const panel = wheelRef?.closest<HTMLElement>('[data-clock="true"]');
233
+ if (!panel || !wheelRef) return false;
234
+ const columns = Array.from(panel.querySelectorAll<HTMLElement>('[role="spinbutton"]'));
235
+ const currentIndex = columns.findIndex((column) => column === wheelRef);
236
+ if (currentIndex < 0) return false;
237
+
238
+ const nextIndex = currentIndex + direction;
239
+ if (nextIndex < 0 || nextIndex >= columns.length) return false;
240
+
241
+ const nextColumn = columns[nextIndex];
242
+ focusWithModality(nextColumn, 'keyboard');
243
+ return true;
244
+ }
245
+
246
+ function moveBy(step: number, behavior: WheelScrollBehavior = 'smooth') {
247
+ if (options.length === 0) return;
248
+ // Use lastCenteredIndex so rapid key-repeat steps correctly
249
+ // instead of re-anchoring to the (potentially stale) reactive value.
250
+ const anchor =
251
+ lastCenteredIndex >= 0
252
+ ? lastCenteredIndex
253
+ : selectedIndex >= 0
254
+ ? selectedIndex
255
+ : (enabledOptionIndexes[0] ?? 0);
256
+ const target = findClosestEnabledIndex(anchor + step, step < 0 ? -1 : 1);
257
+ if (target < 0) return;
258
+ lastCenteredIndex = target;
259
+
260
+ // Immediately update value so the UI reacts without waiting for scrollend.
261
+ const option = options[target];
262
+ if (option && !option.disabled && selectedValue !== option.value) {
263
+ clock.selectWheelValue(type, option.value);
264
+ }
265
+
266
+ wheelApi?.scrollToIndex(target, behavior);
267
+ }
268
+
269
+ function moveToBoundary(boundary: 'start' | 'end', behavior: WheelScrollBehavior = 'smooth') {
270
+ if (enabledOptionIndexes.length === 0) return;
271
+ const target =
272
+ boundary === 'start'
273
+ ? enabledOptionIndexes[0]
274
+ : enabledOptionIndexes[enabledOptionIndexes.length - 1];
275
+ lastCenteredIndex = target;
276
+
277
+ const option = options[target];
278
+ if (option && !option.disabled && selectedValue !== option.value) {
279
+ clock.selectWheelValue(type, option.value);
280
+ }
281
+
282
+ wheelApi?.scrollToIndex(target, behavior);
283
+ }
284
+
285
+ function handleFocusIn(event: FocusEvent) {
286
+ focusWithin = true;
287
+ focusVisible = shouldShowFocusVisible(event.target as HTMLElement | null);
288
+ }
289
+
290
+ function handleFocusOut(event: FocusEvent) {
291
+ const currentTarget = event.currentTarget as HTMLElement;
292
+ queueMicrotask(() => syncFocusWithin(currentTarget));
293
+ }
294
+
295
+ function handleMouseDown(event: MouseEvent) {
296
+ trackInteractionModality(event, event.target as HTMLElement | null);
297
+ focusVisible = false;
298
+ }
299
+
300
+ function handleClick(event: MouseEvent) {
301
+ const target = event.target as Node | null;
302
+ if (!target) return;
303
+ const items = getWheelItemElements();
304
+ const index = items.findIndex((item) => item === target || item.contains(target));
305
+ if (index >= 0) {
306
+ handleCenterRequest(index);
307
+ }
308
+ }
309
+
310
+ function handleKeydown(event: KeyboardEvent) {
311
+ trackInteractionModality(event, event.target as HTMLElement | null);
312
+ if (focusWithin) {
313
+ focusVisible = true;
314
+ }
315
+
316
+ // When a key is held down (repeat), use instant scrolling so the column
317
+ // flies through items instead of queuing up slow smooth-scroll animations.
318
+ const kb: WheelScrollBehavior = event.repeat ? 'instant' : 'smooth';
319
+
320
+ if (event.key === 'ArrowDown') {
321
+ event.preventDefault();
322
+ moveBy(1, kb);
323
+ return;
324
+ }
325
+
326
+ if (event.key === 'ArrowUp') {
327
+ event.preventDefault();
328
+ moveBy(-1, kb);
329
+ return;
330
+ }
331
+
332
+ if (event.key === 'PageDown') {
333
+ event.preventDefault();
334
+ moveBy(type === 'dayPeriod' ? 1 : 5, kb);
335
+ return;
336
+ }
337
+
338
+ if (event.key === 'PageUp') {
339
+ event.preventDefault();
340
+ moveBy(type === 'dayPeriod' ? -1 : -5, kb);
341
+ return;
342
+ }
343
+
344
+ if (event.key === 'Home') {
345
+ event.preventDefault();
346
+ moveToBoundary('start', kb);
347
+ return;
348
+ }
349
+
350
+ if (event.key === 'End') {
351
+ event.preventDefault();
352
+ moveToBoundary('end', kb);
353
+ return;
354
+ }
355
+
356
+ if (event.key === 'ArrowRight') {
357
+ event.preventDefault();
358
+ focusSiblingColumn(1);
359
+ return;
360
+ }
361
+
362
+ if (event.key === 'ArrowLeft') {
363
+ event.preventDefault();
364
+ focusSiblingColumn(-1);
365
+ }
366
+ }
367
+
368
+ function handleCenterRequest(index: number) {
369
+ if (index < 0 || index >= options.length) return;
370
+ const option = options[index];
371
+ if (!option || option.disabled) return;
372
+
373
+ lastCenteredIndex = index;
374
+
375
+ if (selectedValue !== option.value) {
376
+ clock.selectWheelValue(type, option.value);
377
+ }
378
+
379
+ wheelApi?.scrollToIndex(index, 'smooth', { silent: true });
380
+ }
381
+
382
+ $effect(() => {
383
+ if (!wheelRef) return;
384
+
385
+ mountWheelApi();
386
+ syncMeasurements();
387
+
388
+ resizeObserver?.disconnect();
389
+ resizeObserver = new ResizeObserver(() => {
390
+ if (resizeFrameId !== null) {
391
+ cancelAnimationFrame(resizeFrameId);
392
+ }
393
+ resizeFrameId = requestAnimationFrame(() => {
394
+ resizeFrameId = null;
395
+ syncMeasurements();
396
+ });
397
+ });
398
+ resizeObserver.observe(wheelRef);
399
+
400
+ return () => {
401
+ if (resizeFrameId !== null) {
402
+ cancelAnimationFrame(resizeFrameId);
403
+ resizeFrameId = null;
404
+ }
405
+ resizeObserver?.disconnect();
406
+ resizeObserver = null;
407
+ destroyWheelApi();
408
+ };
409
+ });
410
+
411
+ $effect(() => {
412
+ if (!clock.open || !wheelApi) return;
413
+ if (selectedIndex < 0) return;
414
+ if (selectedIndex === lastCenteredIndex) return;
415
+ lastCenteredIndex = selectedIndex;
416
+ wheelApi.scrollToIndex(selectedIndex, 'instant');
417
+ });
418
+
419
+ $effect(() => {
420
+ const optionsSignature = options
421
+ .map((option) => `${option.value}:${option.disabled ? 1 : 0}`)
422
+ .join('|');
423
+ const nextSignature = `${clock.open ? 1 : 0}::${optionsSignature}`;
424
+ if (lastOpenOptionsSignature !== nextSignature) {
425
+ lastOpenOptionsSignature = nextSignature;
426
+ didAlignForCurrentOpen = false;
427
+ }
428
+ });
429
+
430
+ $effect(() => {
431
+ if (!wheelRef || !wheelApi) return;
432
+ if (!clock.open) {
433
+ didAlignForCurrentOpen = false;
434
+ return;
435
+ }
436
+ if (didAlignForCurrentOpen) return;
437
+ if (options.length === 0) return;
438
+ // Guard: wait until syncMeasurements() has run (itemHeight > 0).
439
+ // itemHeight is $state, so the effect will re-run once it's set.
440
+ if (itemHeight <= 0) return;
441
+
442
+ didAlignForCurrentOpen = true;
443
+
444
+ // Defer to rAF so the DOM has been painted with the correct spacer
445
+ // heights. Without this, offsetTop calculations are wrong because
446
+ // Svelte batches state→DOM updates and the spacers still have the
447
+ // stale height when effects run synchronously.
448
+ const rafId = requestAnimationFrame(() => {
449
+ syncMeasurements();
450
+ scrollToSelected('instant');
451
+ });
452
+
453
+ return () => cancelAnimationFrame(rafId);
454
+ });
455
+ </script>
456
+
457
+ <div
458
+ bind:this={wheelRef}
459
+ role="spinbutton"
460
+ tabindex={clock.isDisabled ? -1 : 0}
461
+ aria-label={label}
462
+ aria-valuemin={minValue}
463
+ aria-valuemax={maxValue}
464
+ aria-valuenow={valueNow}
465
+ aria-valuetext={valueText}
466
+ aria-roledescription={wheelRoleDescription}
467
+ aria-disabled={clock.isDisabled || undefined}
468
+ data-focus-within={focusWithin || undefined}
469
+ data-focus-visible={focusVisible || undefined}
470
+ class={resolvedClassName}
471
+ style="overflow-y:auto;position:relative;-webkit-overflow-scrolling:touch"
472
+ onfocusin={handleFocusIn}
473
+ onfocusout={handleFocusOut}
474
+ onmousedown={handleMouseDown}
475
+ onclick={handleClick}
476
+ onkeydown={handleKeydown}
477
+ {...restProps}
478
+ >
479
+ <div data-wheel-spacer="top" style={`height:${spacerHeight}px`}></div>
480
+ <span role="status" aria-live="polite" class="sr-only">{valueText}</span>
481
+ {#each options as option, index (option.value)}
482
+ {#if children}
483
+ {@render children(option)}
484
+ {:else}
485
+ <TimePickerWheelItem
486
+ {type}
487
+ {option}
488
+ selected={selectedValue === option.value}
489
+ onrequestcenter={() => handleCenterRequest(index)}
490
+ id={`${clock.id}-wheel-${type}-${option.value}`}
491
+ />
492
+ {/if}
493
+ {/each}
494
+ <div data-wheel-spacer="bottom" style={`height:${spacerHeight}px`}></div>
495
+ <div
496
+ data-wheel-highlight
497
+ style={`position:absolute;top:50%;transform:translateY(-50%);height:${Math.max(itemHeight, 1)}px;width:100%;pointer-events:none`}
498
+ ></div>
499
+ </div>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type ClockEditableSegmentType } from '../root/context';
4
+ type TimePickerWheelOption = {
5
+ value: string;
6
+ label: string;
7
+ disabled: boolean;
8
+ };
9
+ type TimePickerWheelColumnProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role' | 'tabindex' | 'aria-label' | 'onkeydown'> & {
10
+ type: ClockEditableSegmentType;
11
+ children?: Snippet<[TimePickerWheelOption]>;
12
+ class?: string;
13
+ 'aria-label'?: string;
14
+ };
15
+ declare const ClockWheelColumn: import("svelte").Component<TimePickerWheelColumnProps, {}, "">;
16
+ type ClockWheelColumn = ReturnType<typeof ClockWheelColumn>;
17
+ export default ClockWheelColumn;
@@ -0,0 +1,17 @@
1
+ # Clock WheelItem
2
+
3
+ ## API reference
4
+
5
+ ### Clock.WheelItem
6
+
7
+ Name: `Clock.WheelItem`
8
+ Description: Headless item renderer for wheel options used by `Clock.WheelColumn` and `TimePicker.WheelColumn`.
9
+
10
+ | Prop | Type | Default | Description |
11
+ | ----------------- | ----------------------------------------------------- | ----------- | ------------------------------------------------------------ |
12
+ | `type` | `'hour' \| 'minute' \| 'second' \| 'dayPeriod'` | `required` | Segment type associated with the option. |
13
+ | `option` | `{ value: string; label: string; disabled: boolean }` | `required` | Option payload rendered by this item. |
14
+ | `selected` | `boolean` | `false` | Marks the item as currently selected. |
15
+ | `onrequestcenter` | `() => void` | `undefined` | Callback invoked to request centering/selecting this option. |
16
+ | `class` | `string` | `''` | CSS class names for the item element. |
17
+ | `...restProps` | `HTMLAttributes<HTMLDivElement>` | `-` | Additional attributes forwarded to the item element. |
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { ClockEditableSegmentType } from '../root/context';
4
+
5
+ type TimePickerWheelOption = {
6
+ value: string;
7
+ label: string;
8
+ disabled: boolean;
9
+ };
10
+
11
+ type TimePickerWheelItemProps = Omit<
12
+ HTMLAttributes<HTMLDivElement>,
13
+ 'class' | 'children' | 'onclick'
14
+ > & {
15
+ type: ClockEditableSegmentType;
16
+ option: TimePickerWheelOption;
17
+ selected?: boolean;
18
+ onrequestcenter?: () => void;
19
+ class?: string;
20
+ };
21
+
22
+ let {
23
+ type,
24
+ option,
25
+ selected = false,
26
+ onrequestcenter,
27
+ class: className = '',
28
+ ...restProps
29
+ }: TimePickerWheelItemProps = $props();
30
+
31
+ function handleClick() {
32
+ onrequestcenter?.();
33
+ }
34
+ </script>
35
+
36
+ <div
37
+ data-wheel-item
38
+ data-type={type}
39
+ data-value={option.value}
40
+ data-disabled={option.disabled || undefined}
41
+ data-selected={selected || undefined}
42
+ data-centered={selected || undefined}
43
+ aria-hidden="true"
44
+ class={className}
45
+ onclick={handleClick}
46
+ {...restProps}
47
+ >
48
+ {option.label}
49
+ </div>
@@ -0,0 +1,17 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { ClockEditableSegmentType } from '../root/context';
3
+ type TimePickerWheelOption = {
4
+ value: string;
5
+ label: string;
6
+ disabled: boolean;
7
+ };
8
+ type TimePickerWheelItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children' | 'onclick'> & {
9
+ type: ClockEditableSegmentType;
10
+ option: TimePickerWheelOption;
11
+ selected?: boolean;
12
+ onrequestcenter?: () => void;
13
+ class?: string;
14
+ };
15
+ declare const ClockWheelItem: import("svelte").Component<TimePickerWheelItemProps, {}, "">;
16
+ type ClockWheelItem = ReturnType<typeof ClockWheelItem>;
17
+ export default ClockWheelItem;
@@ -19,9 +19,9 @@ Track DatePicker work with a single mandatory TODO format.
19
19
  - [x] [S][P1][Area: Focus][Owner: Unassigned][Target: Done] Remove DatePicker-local modality tracking in favor of shared primitive.
20
20
  - [x] [S][P1][Area: Documentation][Owner: Unassigned][Target: Done] Document focus contract updates and DatePicker rationale.
21
21
  - [ ] [S][P1][Area: Calendar][Owner: Unassigned][Target: TBD] Add multi-month calendar display.
22
- - [ ] [S][P1][Area: Time][Owner: Unassigned][Target: TBD] Add time selection integration.
23
- - [ ] [S][P1][Area: API][Owner: Unassigned][Target: TBD] Decide and implement date-range mode strategy.
24
22
  - [ ] [S][P1][Area: Accessibility][Owner: Unassigned][Target: TBD] Run deep accessibility audit (SR + keyboard edge cases).
23
+ - [ ] [C][P2][Area: Time][Owner: Unassigned][Target: TBD] Add time selection integration.
24
+ - [ ] [C][P2][Area: API][Owner: Unassigned][Target: TBD] Decide and implement date-range mode strategy.
25
25
  - [ ] [C][P2][Area: Input][Owner: Unassigned][Target: TBD] Add input mask helper utilities.
26
26
  - [ ] [C][P2][Area: Mobile][Owner: Unassigned][Target: TBD] Add mobile-optimized interaction pass.
27
27
  - [ ] [C][P2][Area: UX][Owner: Unassigned][Target: TBD] Add preset shortcuts (today, next week, custom).
@@ -0,0 +1,19 @@
1
+ # DatePicker Calendar
2
+
3
+ ## API reference
4
+
5
+ ### DatePicker.Calendar
6
+
7
+ Name: `DatePicker.Calendar`
8
+ Description: Calendar composition part connected to `DatePicker.Root` selected date and navigation state.
9
+
10
+ | Prop | Type | Default | Description |
11
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------ |
12
+ | `children` | `Snippet` | `undefined` | Optional custom calendar content. |
13
+ | `class` | `string` | `''` | CSS class names for the calendar wrapper. |
14
+ | `...restProps` | `Omit<ComponentProps<typeof Calendar.Root>, 'selectionMode' \| 'value' \| 'defaultValue' \| 'onChange' \| 'isDisabled' \| 'isReadOnly' \| 'isDateUnavailable'>` | `-` | Additional calendar root props forwarded by this part. |
15
+
16
+ ### Notes
17
+
18
+ Name: Root-controlled calendar props
19
+ Description: `selectionMode`, `value`, `defaultValue`, `onChange`, `isDisabled`, `isReadOnly`, and `isDateUnavailable` are controlled by `DatePicker.Root` and ignored when passed to this part.
@@ -0,0 +1,15 @@
1
+ # DatePicker Input
2
+
3
+ ## API reference
4
+
5
+ ### DatePicker.Input
6
+
7
+ Name: `DatePicker.Input`
8
+ Description: Segmented date input group tied to `DatePicker.Root` value and focus management.
9
+
10
+ | Prop | Type | Default | Description |
11
+ | -------------- | ---------------------------------- | ----------- | ----------------------------------------------------- |
12
+ | `children` | `Snippet<[DatePickerSegmentPart]>` | `undefined` | Optional custom renderer for each date segment part. |
13
+ | `class` | `string` | `''` | CSS class names for the input group element. |
14
+ | `aria-label` | `string` | `undefined` | Accessible label for the segmented input group. |
15
+ | `...restProps` | `HTMLAttributes<HTMLDivElement>` | `-` | Additional attributes forwarded to the group element. |
@@ -0,0 +1,20 @@
1
+ # DatePicker Popover
2
+
3
+ ## API reference
4
+
5
+ ### DatePicker.Popover
6
+
7
+ Name: `DatePicker.Popover`
8
+ Description: Popover content wrapper for calendar and optional time controls, synchronized with `DatePicker.Root` open state.
9
+
10
+ | Prop | Type | Default | Description |
11
+ | -------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------- |
12
+ | `aria-label` | `string` | `'Calendar'` | Accessible name for the popover content. |
13
+ | `initialFocus` | `() => HTMLElement \| null \| undefined` | `active day` | Optional initial focus resolver for popover open. |
14
+ | `class` | `string` | `''` | CSS class names for popover content. |
15
+ | `...restProps` | `ComponentProps<typeof Popover.Content>` | `-` | Forwarded popover content props, excluding root-controlled keys. |
16
+
17
+ ### Notes
18
+
19
+ Name: Root-controlled props
20
+ Description: `open`, `triggerRef`, `onOpenChange`, and `id` are controlled by `DatePicker.Root` and ignored when passed to this part.