@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.
- package/dist/FOCUS_STATE_CONTRACT.md +12 -0
- package/dist/calendar/body-cell/README.md +15 -0
- package/dist/calendar/grid/README.md +13 -0
- package/dist/calendar/grid-body/README.md +13 -0
- package/dist/calendar/grid-header/README.md +13 -0
- package/dist/calendar/header-cell/README.md +14 -0
- package/dist/calendar/heading/README.md +13 -0
- package/dist/calendar/root/README.md +24 -0
- package/dist/calendar/trigger-next/README.md +14 -0
- package/dist/calendar/trigger-previous/README.md +14 -0
- package/dist/clock/README.md +75 -0
- package/dist/clock/axis/README.md +24 -0
- package/dist/clock/axis/clock-axis.svelte +37 -0
- package/dist/clock/axis/clock-axis.svelte.d.ts +8 -0
- package/dist/clock/hooks/use-wheel-scroll.svelte.d.ts +16 -0
- package/dist/clock/hooks/use-wheel-scroll.svelte.js +336 -0
- package/dist/clock/index.d.ts +10 -0
- package/dist/clock/index.js +10 -0
- package/dist/clock/index.parts.d.ts +4 -0
- package/dist/clock/index.parts.js +4 -0
- package/dist/clock/root/README.md +38 -0
- package/dist/clock/root/clock-root-test.svelte +62 -0
- package/dist/clock/root/clock-root-test.svelte.d.ts +14 -0
- package/dist/clock/root/clock-root.svelte +329 -0
- package/dist/clock/root/clock-root.svelte.d.ts +25 -0
- package/dist/clock/root/context.d.ts +22 -0
- package/dist/clock/root/context.js +15 -0
- package/dist/clock/root/resolve-visible-columns.d.ts +7 -0
- package/dist/clock/root/resolve-visible-columns.js +16 -0
- package/dist/clock/root/time-utils.d.ts +48 -0
- package/dist/clock/root/time-utils.js +314 -0
- package/dist/clock/root/wheel-options.d.ts +17 -0
- package/dist/clock/root/wheel-options.js +63 -0
- package/dist/clock/wheel-column/README.md +25 -0
- package/dist/clock/wheel-column/clock-wheel-column-bindable-test.svelte +16 -0
- package/dist/clock/wheel-column/clock-wheel-column-bindable-test.svelte.d.ts +3 -0
- package/dist/clock/wheel-column/clock-wheel-column-custom-snippet-test.svelte +29 -0
- package/dist/clock/wheel-column/clock-wheel-column-custom-snippet-test.svelte.d.ts +6 -0
- package/dist/clock/wheel-column/clock-wheel-column-default-height-test.svelte +11 -0
- package/dist/clock/wheel-column/clock-wheel-column-default-height-test.svelte.d.ts +3 -0
- package/dist/clock/wheel-column/clock-wheel-column-test.svelte +38 -0
- package/dist/clock/wheel-column/clock-wheel-column-test.svelte.d.ts +12 -0
- package/dist/clock/wheel-column/clock-wheel-column-tp-test.svelte +38 -0
- package/dist/clock/wheel-column/clock-wheel-column-tp-test.svelte.d.ts +12 -0
- package/dist/clock/wheel-column/clock-wheel-column-untagged-snippet-test.svelte +29 -0
- package/dist/clock/wheel-column/clock-wheel-column-untagged-snippet-test.svelte.d.ts +6 -0
- package/dist/clock/wheel-column/clock-wheel-column.svelte +499 -0
- package/dist/clock/wheel-column/clock-wheel-column.svelte.d.ts +17 -0
- package/dist/clock/wheel-item/README.md +17 -0
- package/dist/clock/wheel-item/clock-wheel-item.svelte +49 -0
- package/dist/clock/wheel-item/clock-wheel-item.svelte.d.ts +17 -0
- package/dist/datepicker/TODO.md +2 -2
- package/dist/datepicker/calendar/README.md +19 -0
- package/dist/datepicker/input/README.md +15 -0
- package/dist/datepicker/popover/README.md +20 -0
- package/dist/datepicker/root/README.md +38 -0
- package/dist/datepicker/segment/README.md +14 -0
- package/dist/datepicker/trigger/README.md +14 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/primitives/focus-trap.js +11 -12
- package/dist/primitives/input-modality.js +10 -1
- package/dist/timepicker/IMPLEMENTATION_PLAN.md +254 -0
- package/dist/timepicker/README.md +97 -0
- package/dist/timepicker/TODO.md +86 -0
- package/dist/timepicker/clock/README.md +14 -0
- package/dist/timepicker/clock/time-picker-clock-test.svelte +45 -0
- package/dist/timepicker/clock/time-picker-clock-test.svelte.d.ts +11 -0
- package/dist/timepicker/clock/time-picker-clock.svelte +65 -0
- package/dist/timepicker/clock/time-picker-clock.svelte.d.ts +10 -0
- package/dist/timepicker/index.d.ts +14 -0
- package/dist/timepicker/index.js +14 -0
- package/dist/timepicker/index.parts.d.ts +8 -0
- package/dist/timepicker/index.parts.js +8 -0
- package/dist/timepicker/input/README.md +15 -0
- package/dist/timepicker/input/time-picker-input-forwarding-test.svelte +40 -0
- package/dist/timepicker/input/time-picker-input-forwarding-test.svelte.d.ts +3 -0
- package/dist/timepicker/input/time-picker-input.svelte +109 -0
- package/dist/timepicker/input/time-picker-input.svelte.d.ts +11 -0
- package/dist/timepicker/internal/strict-props.d.ts +4 -0
- package/dist/timepicker/internal/strict-props.js +51 -0
- package/dist/timepicker/popover/README.md +20 -0
- package/dist/timepicker/popover/time-picker-popover-unsafe-props-test.svelte +22 -0
- package/dist/timepicker/popover/time-picker-popover-unsafe-props-test.svelte.d.ts +3 -0
- package/dist/timepicker/popover/time-picker-popover.svelte +89 -0
- package/dist/timepicker/popover/time-picker-popover.svelte.d.ts +7 -0
- package/dist/timepicker/root/README.md +42 -0
- package/dist/timepicker/root/context.d.ts +51 -0
- package/dist/timepicker/root/context.js +15 -0
- package/dist/timepicker/root/time-picker-12h-test.svelte +22 -0
- package/dist/timepicker/root/time-picker-12h-test.svelte.d.ts +3 -0
- package/dist/timepicker/root/time-picker-bindable-test.svelte +25 -0
- package/dist/timepicker/root/time-picker-bindable-test.svelte.d.ts +3 -0
- package/dist/timepicker/root/time-picker-empty-test.svelte +20 -0
- package/dist/timepicker/root/time-picker-empty-test.svelte.d.ts +3 -0
- package/dist/timepicker/root/time-picker-root.svelte +625 -0
- package/dist/timepicker/root/time-picker-root.svelte.d.ts +28 -0
- package/dist/timepicker/root/time-picker-test.svelte +72 -0
- package/dist/timepicker/root/time-picker-test.svelte.d.ts +15 -0
- package/dist/timepicker/root/time-utils.d.ts +1 -0
- package/dist/timepicker/root/time-utils.js +3 -0
- package/dist/timepicker/segment/README.md +14 -0
- package/dist/timepicker/segment/time-picker-segment.svelte +365 -0
- package/dist/timepicker/segment/time-picker-segment.svelte.d.ts +9 -0
- package/dist/timepicker/trigger/README.md +14 -0
- package/dist/timepicker/trigger/time-picker-trigger-forwarding-test.svelte +35 -0
- package/dist/timepicker/trigger/time-picker-trigger-forwarding-test.svelte.d.ts +3 -0
- package/dist/timepicker/trigger/time-picker-trigger.svelte +122 -0
- package/dist/timepicker/trigger/time-picker-trigger.svelte.d.ts +9 -0
- package/package.json +11 -1
|
@@ -49,3 +49,15 @@ On overlay/popover close, transient trigger state is allowed:
|
|
|
49
49
|
## Operational Template
|
|
50
50
|
|
|
51
51
|
- Use `FOCUS_STATE_REVIEW_TEMPLATE.md` for PR/release reviews (modality matrix + component status + checklist).
|
|
52
|
+
|
|
53
|
+
## Component Coverage
|
|
54
|
+
|
|
55
|
+
The following components implement this contract:
|
|
56
|
+
|
|
57
|
+
- **Popover** — trigger + content, restore focus on close.
|
|
58
|
+
- **Dialog** — trigger + overlay/content, nested stack support.
|
|
59
|
+
- **DatePicker** — segment spinbuttons, trigger, popover (calendar).
|
|
60
|
+
- **TimePicker** — segment spinbuttons, trigger, popover (scrollable columns). Follows the same contract as DatePicker.
|
|
61
|
+
- **Calendar** — grid cells with roving tabindex.
|
|
62
|
+
- **ComboBox** — input + listbox with virtual focus.
|
|
63
|
+
- **ListBox** — items with roving tabindex.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Calendar BodyCell
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.BodyCell
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.BodyCell`
|
|
8
|
+
Description: Wrapper part for date grid cells inside `Calendar.GridBody`.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| -------------- | -------------------------------------- | ----------- | --------------------------------------------------------------- |
|
|
12
|
+
| `date` | `string` | `required` | Calendar date value rendered by the cell (`YYYY-MM-DD`). |
|
|
13
|
+
| `children` | `Snippet<[string]>` | `undefined` | Optional custom renderer receiving the day label text. |
|
|
14
|
+
| `class` | `string` | `''` | CSS class names for the inner gridcell element. |
|
|
15
|
+
| `...restProps` | `HTMLAttributes<HTMLTableCellElement>` | `-` | Additional attributes forwarded to the outer table cell (`td`). |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Calendar Grid
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.Grid
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.Grid`
|
|
8
|
+
Description: Calendar month table container rendered by `Calendar.Root`.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| ---------- | --------- | ----------- | --------------------------------------------- |
|
|
12
|
+
| `children` | `Snippet` | `undefined` | Optional custom month-grid content. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the outer grid container. |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Calendar GridBody
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.GridBody
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.GridBody`
|
|
8
|
+
Description: TBody container for week rows in `Calendar.Grid`.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| ---------- | ------------------- | ----------- | -------------------------------------------------------- |
|
|
12
|
+
| `children` | `Snippet<[string]>` | `undefined` | Optional custom renderer receiving each day date string. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the `tbody` element. |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Calendar GridHeader
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.GridHeader
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.GridHeader`
|
|
8
|
+
Description: Header row group for weekday labels in `Calendar.Grid`.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| ---------- | ------------------- | ----------- | ---------------------------------------------------------------- |
|
|
12
|
+
| `children` | `Snippet<[string]>` | `undefined` | Optional custom renderer receiving each localized weekday label. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the `thead` element. |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Calendar HeaderCell
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.HeaderCell
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.HeaderCell`
|
|
8
|
+
Description: Weekday heading cell part used inside `Calendar.GridHeader`.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| -------------- | -------------------------------------- | ----------- | -------------------------------------------------- |
|
|
12
|
+
| `children` | `Snippet` | `undefined` | Optional custom header cell content. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the header cell element. |
|
|
14
|
+
| `...restProps` | `HTMLAttributes<HTMLTableCellElement>` | `-` | Additional attributes forwarded to the table cell. |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Calendar Heading
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.Heading
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.Heading`
|
|
8
|
+
Description: Label part that displays the currently visible calendar period.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| -------------- | ------------------------------------ | ------- | ------------------------------------------------------- |
|
|
12
|
+
| `class` | `string` | `''` | CSS class names for the heading element (`h2`). |
|
|
13
|
+
| `...restProps` | `HTMLAttributes<HTMLHeadingElement>` | `-` | Additional attributes forwarded to the heading element. |
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Calendar Root
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.Root
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.Root`
|
|
8
|
+
Description: Root state container for date grid rendering, navigation, selection, and locale-aware formatting.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| ------------------- | ---------------------------------------------------------- | ----------- | ------------------------------------------------------------------- |
|
|
12
|
+
| `selectionMode` | `'single' \| 'range'` | `'single'` | Selection behavior and value shape. |
|
|
13
|
+
| `value` | `CalendarDateValue \| CalendarRangeValue \| undefined` | `bindable` | Controlled selected value (single date or `{ start, end }`). |
|
|
14
|
+
| `defaultValue` | `CalendarDateValue \| CalendarRangeValue \| undefined` | `undefined` | Initial value for uncontrolled mode. |
|
|
15
|
+
| `onChange` | `(value: CalendarDateValue \| CalendarRangeValue) => void` | `undefined` | Called when selection changes. |
|
|
16
|
+
| `visibleMonths` | `number` | `1` | Number of month grids rendered simultaneously. |
|
|
17
|
+
| `showOutsideDays` | `boolean` | `false` | Whether days outside the visible month remain rendered/interactive. |
|
|
18
|
+
| `isDateUnavailable` | `(date: string) => boolean` | `undefined` | Marks specific dates as unavailable. |
|
|
19
|
+
| `isDisabled` | `boolean` | `false` | Disables interaction and navigation. |
|
|
20
|
+
| `isReadOnly` | `boolean` | `false` | Keeps navigation while preventing selection updates. |
|
|
21
|
+
| `children` | `Snippet` | `undefined` | Composed `Calendar` parts. |
|
|
22
|
+
| `class` | `string` | `''` | CSS class names for the root wrapper. |
|
|
23
|
+
| `id` | `string` | `undefined` | Optional root id. |
|
|
24
|
+
| `aria-label` | `string` | `undefined` | Accessible label for the root wrapper. |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Calendar TriggerNext
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.TriggerNext
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.TriggerNext`
|
|
8
|
+
Description: Button part that advances the visible calendar period.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| -------------- | ---------------------- | ----------- | ------------------------------------------------------ |
|
|
12
|
+
| `children` | `Snippet` | `undefined` | Optional trigger content. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the trigger button. |
|
|
14
|
+
| `...restProps` | `HTMLButtonAttributes` | `-` | Additional button attributes forwarded to the trigger. |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Calendar TriggerPrevious
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Calendar.TriggerPrevious
|
|
6
|
+
|
|
7
|
+
Name: `Calendar.TriggerPrevious`
|
|
8
|
+
Description: Button part that moves the visible calendar period backward.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| -------------- | ---------------------- | ----------- | ------------------------------------------------------ |
|
|
12
|
+
| `children` | `Snippet` | `undefined` | Optional trigger content. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the trigger button. |
|
|
14
|
+
| `...restProps` | `HTMLButtonAttributes` | `-` | Additional button attributes forwarded to the trigger. |
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Clock
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
`Clock` provides a standalone wheel-based time picker with spinbutton columns for hour, minute, second, and day-period selection. It can be used independently or composed inside `TimePicker` via `TimePicker.Clock`.
|
|
6
|
+
|
|
7
|
+
## Anatomy
|
|
8
|
+
|
|
9
|
+
- `Clock.Root`
|
|
10
|
+
- `Clock.Axis`
|
|
11
|
+
- `Clock.WheelColumn`
|
|
12
|
+
- `Clock.WheelItem`
|
|
13
|
+
|
|
14
|
+
```svelte
|
|
15
|
+
<Clock.Root value="14:30" granularity="minute" hourCycle={24} class="flex gap-2">
|
|
16
|
+
{#snippet column(col)}
|
|
17
|
+
<Clock.WheelColumn type={col.type} class="h-44 w-16">
|
|
18
|
+
{#snippet children(option)}
|
|
19
|
+
<Clock.WheelItem type={col.type} {option} class="..." />
|
|
20
|
+
{/snippet}
|
|
21
|
+
</Clock.WheelColumn>
|
|
22
|
+
{/snippet}
|
|
23
|
+
<Clock.Axis class="rounded-md ring-1 ring-inset" />
|
|
24
|
+
</Clock.Root>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Default columns are rendered automatically when no `column` snippet is provided:
|
|
28
|
+
|
|
29
|
+
```svelte
|
|
30
|
+
<Clock.Root value="09:00" granularity="minute" hourCycle={24} />
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Root API
|
|
34
|
+
|
|
35
|
+
- `value?: string | null` (`HH:mm` or `HH:mm:ss`)
|
|
36
|
+
- `defaultValue?: string | null` (`HH:mm` or `HH:mm:ss`)
|
|
37
|
+
- `onChange?: (value: string | null) => void`
|
|
38
|
+
- `minValue?: string`
|
|
39
|
+
- `maxValue?: string`
|
|
40
|
+
- `hourCycle?: 12 | 24` (defaults to locale)
|
|
41
|
+
- `granularity?: 'hour' | 'minute' | 'second'` (defaults to `'minute'`)
|
|
42
|
+
- `hourStep?: number`
|
|
43
|
+
- `minuteStep?: number`
|
|
44
|
+
- `secondStep?: number`
|
|
45
|
+
- `isDisabled?: boolean`
|
|
46
|
+
- `column?: Snippet<[ClockColumnInfo]>` — custom per-column rendering
|
|
47
|
+
- `children?: Snippet` — arbitrary children. When `column` is used, children render after columns (useful for overlays like `Clock.Axis`).
|
|
48
|
+
- `class?: string`
|
|
49
|
+
- `aria-label?: string`
|
|
50
|
+
|
|
51
|
+
Visible columns are resolved automatically in stable order: `hour → minute? → second? → dayPeriod?`.
|
|
52
|
+
|
|
53
|
+
## Wheel API
|
|
54
|
+
|
|
55
|
+
- `Clock.Axis` renders a root-level visual overlay (for example, a central selection band) across all columns.
|
|
56
|
+
- `Clock.WheelColumn` renders one wheel (`role="spinbutton"`) for one editable segment (`hour`, `minute`, `second`, or `dayPeriod`).
|
|
57
|
+
- `Clock.WheelItem` is headless: it renders one item (`data-wheel-item`) with state attributes (`data-selected`, `data-disabled`, `data-centered`) and leaves all visual styling to consumers.
|
|
58
|
+
- `ClockColumnInfo` shape:
|
|
59
|
+
- `type: 'hour' | 'minute' | 'second' | 'dayPeriod'`
|
|
60
|
+
- `label?: string`
|
|
61
|
+
|
|
62
|
+
## Accessibility
|
|
63
|
+
|
|
64
|
+
- Each wheel column exposes `role="spinbutton"` with `aria-valuenow`, `aria-valuetext`, `aria-valuemin`, and `aria-valuemax`.
|
|
65
|
+
- `ArrowUp/ArrowDown`: change value by one step.
|
|
66
|
+
- `ArrowLeft/ArrowRight`: move focus between columns.
|
|
67
|
+
- `Home/End`: jump to first/last value in the column.
|
|
68
|
+
|
|
69
|
+
## Notes
|
|
70
|
+
|
|
71
|
+
- Locale is read from `LocaleProvider` when available.
|
|
72
|
+
- Internally, values are normalized to 24-hour representation; 12-hour rendering only affects UI segments.
|
|
73
|
+
- `granularity='hour'` emits `HH:00` values.
|
|
74
|
+
- Min/max comparisons do not support midnight-wrapping ranges.
|
|
75
|
+
- Wheel selection commits immediately on snap.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Clock Axis
|
|
2
|
+
|
|
3
|
+
## API reference
|
|
4
|
+
|
|
5
|
+
### Clock.Axis
|
|
6
|
+
|
|
7
|
+
Name: `Clock.Axis`
|
|
8
|
+
Description: Visual overlay band for `Clock.Root`; it is presentational (`aria-hidden`) and does not manage selection.
|
|
9
|
+
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| -------------- | -------------------------------- | ----------- | ---------------------------------------------------- |
|
|
12
|
+
| `height` | `number` | `undefined` | Optional overlay height in pixels. |
|
|
13
|
+
| `class` | `string` | `''` | CSS class names for the overlay element. |
|
|
14
|
+
| `style` | `string` | `''` | Inline styles merged with the optional height style. |
|
|
15
|
+
| `...restProps` | `HTMLAttributes<HTMLDivElement>` | `-` | Additional attributes forwarded to the overlay div. |
|
|
16
|
+
|
|
17
|
+
### Context utilities
|
|
18
|
+
|
|
19
|
+
Name: `useClockContext`
|
|
20
|
+
Description: Ensures `Clock.Axis` is used within `Clock.Root`.
|
|
21
|
+
|
|
22
|
+
| Prop | Type | Default | Description |
|
|
23
|
+
| ----------------- | -------------------- | ------- | ------------------------------------------------ |
|
|
24
|
+
| `useClockContext` | `() => ClockContext` | `-` | Returns context and throws outside `Clock.Root`. |
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { useClockContext } from '../root/context';
|
|
4
|
+
|
|
5
|
+
type ClockAxisProps = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children'> & {
|
|
6
|
+
class?: string;
|
|
7
|
+
height?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
class: className = '',
|
|
12
|
+
height,
|
|
13
|
+
style: styleProp = '',
|
|
14
|
+
...restProps
|
|
15
|
+
}: ClockAxisProps = $props();
|
|
16
|
+
|
|
17
|
+
const resolvedStyle = $derived.by(() => {
|
|
18
|
+
const base = styleProp?.trim() ?? '';
|
|
19
|
+
if (height === undefined || !Number.isFinite(height) || height <= 0) {
|
|
20
|
+
return base.length > 0 ? base : undefined;
|
|
21
|
+
}
|
|
22
|
+
const axisHeight = `height:${height}px`;
|
|
23
|
+
if (base.length === 0) return axisHeight;
|
|
24
|
+
const withSemicolon = /;\s*$/.test(base) ? base : `${base};`;
|
|
25
|
+
return `${withSemicolon}${axisHeight}`;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
useClockContext();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<div
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
data-clock-axis
|
|
34
|
+
class={`pointer-events-none absolute top-1/2 left-0 w-full -translate-y-1/2 ${className}`}
|
|
35
|
+
style={resolvedStyle}
|
|
36
|
+
{...restProps}
|
|
37
|
+
></div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
type ClockAxisProps = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children'> & {
|
|
3
|
+
class?: string;
|
|
4
|
+
height?: number;
|
|
5
|
+
};
|
|
6
|
+
declare const ClockAxis: import("svelte").Component<ClockAxisProps, {}, "">;
|
|
7
|
+
type ClockAxis = ReturnType<typeof ClockAxis>;
|
|
8
|
+
export default ClockAxis;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type WheelScrollBehavior = 'smooth' | 'instant';
|
|
2
|
+
export type WheelScrollApi = {
|
|
3
|
+
scrollToIndex: (index: number, behavior?: WheelScrollBehavior, options?: {
|
|
4
|
+
silent?: boolean;
|
|
5
|
+
}) => void;
|
|
6
|
+
destroy: () => void;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Manages scroll-based item selection for a wheel column.
|
|
10
|
+
*
|
|
11
|
+
* All snapping is handled in JavaScript (no CSS `scroll-snap-type`).
|
|
12
|
+
* After scrolling settles (either short inactivity in `scroll` events
|
|
13
|
+
* or browser `scrollend`), we find the nearest centered item and run
|
|
14
|
+
* a fast 120 ms ease-out animation to align it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useWheelScroll(container: HTMLElement, onSnap: (centeredIndex: number) => number | null | void): WheelScrollApi;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages scroll-based item selection for a wheel column.
|
|
3
|
+
*
|
|
4
|
+
* All snapping is handled in JavaScript (no CSS `scroll-snap-type`).
|
|
5
|
+
* After scrolling settles (either short inactivity in `scroll` events
|
|
6
|
+
* or browser `scrollend`), we find the nearest centered item and run
|
|
7
|
+
* a fast 120 ms ease-out animation to align it.
|
|
8
|
+
*/
|
|
9
|
+
export function useWheelScroll(container, onSnap) {
|
|
10
|
+
let scrollEndTimer = null;
|
|
11
|
+
let silentScrollTimer = null;
|
|
12
|
+
let snapRafId = null;
|
|
13
|
+
let releaseSnapRafId = null;
|
|
14
|
+
let instantReleaseRafId = null;
|
|
15
|
+
let isSnapping = false;
|
|
16
|
+
let isSilentScroll = false;
|
|
17
|
+
let isPointerInteracting = false;
|
|
18
|
+
let hasPendingPointerReleaseSnap = false;
|
|
19
|
+
const supportsScrollEnd = 'onscrollend' in window;
|
|
20
|
+
const supportsPointerEvents = 'onpointerdown' in window;
|
|
21
|
+
const wheelDebugWindow = window;
|
|
22
|
+
let lastScrollAt = 0;
|
|
23
|
+
let gestureStartAt = 0;
|
|
24
|
+
let scrollEventsInGesture = 0;
|
|
25
|
+
let currentGestureId = 0;
|
|
26
|
+
function isDebugEnabled() {
|
|
27
|
+
return (wheelDebugWindow.__HK_CLOCK_WHEEL_DEBUG__ === true || container.dataset.wheelDebug === 'true');
|
|
28
|
+
}
|
|
29
|
+
function debugLog(event, details) {
|
|
30
|
+
if (!isDebugEnabled())
|
|
31
|
+
return;
|
|
32
|
+
const timestamp = Number(performance.now().toFixed(1));
|
|
33
|
+
console.info('[Clock.WheelDebug]', {
|
|
34
|
+
event,
|
|
35
|
+
t: timestamp,
|
|
36
|
+
gestureId: currentGestureId,
|
|
37
|
+
supportsScrollEnd,
|
|
38
|
+
...details
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function clearSilentScroll() {
|
|
42
|
+
isSilentScroll = false;
|
|
43
|
+
if (silentScrollTimer) {
|
|
44
|
+
clearTimeout(silentScrollTimer);
|
|
45
|
+
silentScrollTimer = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function startSilentScrollWindow() {
|
|
49
|
+
isSilentScroll = true;
|
|
50
|
+
if (silentScrollTimer) {
|
|
51
|
+
clearTimeout(silentScrollTimer);
|
|
52
|
+
}
|
|
53
|
+
silentScrollTimer = setTimeout(() => {
|
|
54
|
+
clearSilentScroll();
|
|
55
|
+
}, 600);
|
|
56
|
+
}
|
|
57
|
+
function getItemElements() {
|
|
58
|
+
const taggedItems = Array.from(container.querySelectorAll('[data-wheel-item]'));
|
|
59
|
+
if (taggedItems.length > 0)
|
|
60
|
+
return taggedItems;
|
|
61
|
+
return Array.from(container.children).filter((child) => {
|
|
62
|
+
if (!(child instanceof HTMLElement))
|
|
63
|
+
return false;
|
|
64
|
+
if (child.matches('[data-wheel-spacer], [data-wheel-highlight], [role="status"], .sr-only')) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function getCenteredIndex() {
|
|
71
|
+
const items = getItemElements();
|
|
72
|
+
if (items.length === 0)
|
|
73
|
+
return -1;
|
|
74
|
+
const centerLine = container.scrollTop + container.clientHeight / 2;
|
|
75
|
+
let closestIndex = -1;
|
|
76
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
77
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
78
|
+
const item = items[index];
|
|
79
|
+
const itemCenter = item.offsetTop + item.offsetHeight / 2;
|
|
80
|
+
const distance = Math.abs(itemCenter - centerLine);
|
|
81
|
+
if (distance < closestDistance) {
|
|
82
|
+
closestDistance = distance;
|
|
83
|
+
closestIndex = index;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return closestIndex;
|
|
87
|
+
}
|
|
88
|
+
/* ── snap animation ─────────────────────────────────────────────── */
|
|
89
|
+
function cancelSnap() {
|
|
90
|
+
if (snapRafId !== null) {
|
|
91
|
+
cancelAnimationFrame(snapRafId);
|
|
92
|
+
snapRafId = null;
|
|
93
|
+
}
|
|
94
|
+
if (releaseSnapRafId !== null) {
|
|
95
|
+
cancelAnimationFrame(releaseSnapRafId);
|
|
96
|
+
releaseSnapRafId = null;
|
|
97
|
+
}
|
|
98
|
+
if (instantReleaseRafId !== null) {
|
|
99
|
+
cancelAnimationFrame(instantReleaseRafId);
|
|
100
|
+
instantReleaseRafId = null;
|
|
101
|
+
}
|
|
102
|
+
isSnapping = false;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Run a 120 ms ease-out-cubic animation from the current scrollTop to
|
|
106
|
+
* `target`. During the animation `isSnapping` is true so incoming
|
|
107
|
+
* scroll / scrollend events are ignored.
|
|
108
|
+
*/
|
|
109
|
+
function animateSnapTo(target) {
|
|
110
|
+
cancelSnap();
|
|
111
|
+
const start = container.scrollTop;
|
|
112
|
+
const distance = target - start;
|
|
113
|
+
if (Math.abs(distance) < 1)
|
|
114
|
+
return;
|
|
115
|
+
isSnapping = true;
|
|
116
|
+
const duration = 120;
|
|
117
|
+
const t0 = performance.now();
|
|
118
|
+
debugLog('snap-animation-start', {
|
|
119
|
+
start: Number(start.toFixed(2)),
|
|
120
|
+
target: Number(target.toFixed(2)),
|
|
121
|
+
distance: Number(distance.toFixed(2)),
|
|
122
|
+
durationMs: duration
|
|
123
|
+
});
|
|
124
|
+
function frame(now) {
|
|
125
|
+
const elapsed = now - t0;
|
|
126
|
+
const t = Math.min(elapsed / duration, 1);
|
|
127
|
+
const eased = 1 - (1 - t) ** 3; // ease-out cubic
|
|
128
|
+
container.scrollTop = start + distance * eased;
|
|
129
|
+
if (t < 1) {
|
|
130
|
+
snapRafId = requestAnimationFrame(frame);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
snapRafId = null;
|
|
134
|
+
debugLog('snap-animation-end', {
|
|
135
|
+
elapsedMs: Number((performance.now() - t0).toFixed(1)),
|
|
136
|
+
finalScrollTop: Number(container.scrollTop.toFixed(2))
|
|
137
|
+
});
|
|
138
|
+
// Keep the flag only until next frame so trailing animation
|
|
139
|
+
// events are ignored without creating a visible dead-zone.
|
|
140
|
+
releaseSnapRafId = requestAnimationFrame(() => {
|
|
141
|
+
releaseSnapRafId = null;
|
|
142
|
+
isSnapping = false;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
snapRafId = requestAnimationFrame(frame);
|
|
147
|
+
}
|
|
148
|
+
/* ── scroll settle ──────────────────────────────────────────────── */
|
|
149
|
+
/**
|
|
150
|
+
* Called when the user's scroll gesture has finished.
|
|
151
|
+
* 1. Identify the closest item to the viewport centre.
|
|
152
|
+
* 2. Emit `onSnap` so the consumer can update the selected value.
|
|
153
|
+
* 3. Animate to perfectly centre the item (120 ms).
|
|
154
|
+
*/
|
|
155
|
+
function snapToCenter(source) {
|
|
156
|
+
if (isSnapping || isSilentScroll)
|
|
157
|
+
return;
|
|
158
|
+
const now = performance.now();
|
|
159
|
+
const idleBeforeSnapMs = lastScrollAt > 0 ? now - lastScrollAt : null;
|
|
160
|
+
debugLog('snap-evaluate', {
|
|
161
|
+
source,
|
|
162
|
+
idleBeforeSnapMs: idleBeforeSnapMs === null ? null : Number(idleBeforeSnapMs.toFixed(1)),
|
|
163
|
+
scrollEventsInGesture
|
|
164
|
+
});
|
|
165
|
+
const centeredIndex = getCenteredIndex();
|
|
166
|
+
if (centeredIndex < 0)
|
|
167
|
+
return;
|
|
168
|
+
const resolvedIndex = onSnap(centeredIndex);
|
|
169
|
+
const snapIndex = typeof resolvedIndex === 'number' ? resolvedIndex : centeredIndex;
|
|
170
|
+
if (!Number.isInteger(snapIndex) || snapIndex < 0)
|
|
171
|
+
return;
|
|
172
|
+
const items = getItemElements();
|
|
173
|
+
const target = items[snapIndex];
|
|
174
|
+
if (!target)
|
|
175
|
+
return;
|
|
176
|
+
const idealScrollTop = target.offsetTop - (container.clientHeight - target.offsetHeight) / 2;
|
|
177
|
+
const clamped = Math.max(0, idealScrollTop);
|
|
178
|
+
const diff = Math.abs(container.scrollTop - clamped);
|
|
179
|
+
debugLog('snap-target', {
|
|
180
|
+
centeredIndex,
|
|
181
|
+
snapIndex,
|
|
182
|
+
diffPx: Number(diff.toFixed(2)),
|
|
183
|
+
currentScrollTop: Number(container.scrollTop.toFixed(2)),
|
|
184
|
+
targetScrollTop: Number(clamped.toFixed(2))
|
|
185
|
+
});
|
|
186
|
+
scrollEventsInGesture = 0;
|
|
187
|
+
if (diff >= 2) {
|
|
188
|
+
animateSnapTo(clamped);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/* ── event wiring ───────────────────────────────────────────────── */
|
|
192
|
+
function clearScrollEndTimer() {
|
|
193
|
+
if (scrollEndTimer) {
|
|
194
|
+
clearTimeout(scrollEndTimer);
|
|
195
|
+
scrollEndTimer = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function handleScroll() {
|
|
199
|
+
const now = performance.now();
|
|
200
|
+
if (scrollEventsInGesture === 0) {
|
|
201
|
+
currentGestureId += 1;
|
|
202
|
+
gestureStartAt = now;
|
|
203
|
+
debugLog('gesture-start', {
|
|
204
|
+
scrollTop: Number(container.scrollTop.toFixed(2))
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
scrollEventsInGesture += 1;
|
|
208
|
+
lastScrollAt = now;
|
|
209
|
+
if (isSnapping) {
|
|
210
|
+
debugLog('scroll-ignored-while-snapping');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (isSilentScroll) {
|
|
214
|
+
debugLog('scroll-ignored-while-silent-scroll');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (isPointerInteracting) {
|
|
218
|
+
hasPendingPointerReleaseSnap = true;
|
|
219
|
+
clearScrollEndTimer();
|
|
220
|
+
debugLog('scroll-deferred-until-pointer-release');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Always debounce by scroll inactivity so we don't rely solely on
|
|
224
|
+
// potentially late `scrollend` dispatch.
|
|
225
|
+
clearScrollEndTimer();
|
|
226
|
+
scrollEndTimer = setTimeout(() => snapToCenter(supportsScrollEnd ? 'inactivity-timeout' : 'fallback-timeout'), 120);
|
|
227
|
+
}
|
|
228
|
+
function handleScrollEnd() {
|
|
229
|
+
const now = performance.now();
|
|
230
|
+
if (isSnapping) {
|
|
231
|
+
debugLog('scrollend-ignored-while-snapping');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (isSilentScroll) {
|
|
235
|
+
debugLog('scrollend-ignored-while-silent-scroll');
|
|
236
|
+
clearSilentScroll();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
debugLog('scrollend-received', {
|
|
240
|
+
sinceLastScrollMs: lastScrollAt > 0 ? Number((now - lastScrollAt).toFixed(1)) : null,
|
|
241
|
+
gestureDurationMs: gestureStartAt > 0 ? Number((now - gestureStartAt).toFixed(1)) : null,
|
|
242
|
+
scrollEventsInGesture
|
|
243
|
+
});
|
|
244
|
+
clearScrollEndTimer();
|
|
245
|
+
snapToCenter('scrollend');
|
|
246
|
+
}
|
|
247
|
+
function handlePointerDown(event) {
|
|
248
|
+
if ('pointerType' in event && event.pointerType === 'mouse' && event.button !== 0) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (!('pointerType' in event) && event.button !== 0) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
isPointerInteracting = true;
|
|
255
|
+
hasPendingPointerReleaseSnap = false;
|
|
256
|
+
}
|
|
257
|
+
function handlePointerRelease() {
|
|
258
|
+
if (!isPointerInteracting)
|
|
259
|
+
return;
|
|
260
|
+
isPointerInteracting = false;
|
|
261
|
+
if (hasPendingPointerReleaseSnap) {
|
|
262
|
+
hasPendingPointerReleaseSnap = false;
|
|
263
|
+
clearScrollEndTimer();
|
|
264
|
+
snapToCenter('pointer-release');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
container.addEventListener('scroll', handleScroll, { passive: true });
|
|
268
|
+
if (supportsPointerEvents) {
|
|
269
|
+
container.addEventListener('pointerdown', handlePointerDown, {
|
|
270
|
+
passive: true
|
|
271
|
+
});
|
|
272
|
+
window.addEventListener('pointerup', handlePointerRelease, { passive: true });
|
|
273
|
+
window.addEventListener('pointercancel', handlePointerRelease, { passive: true });
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
container.addEventListener('mousedown', handlePointerDown);
|
|
277
|
+
window.addEventListener('mouseup', handlePointerRelease);
|
|
278
|
+
}
|
|
279
|
+
if (supportsScrollEnd) {
|
|
280
|
+
container.addEventListener('scrollend', handleScrollEnd);
|
|
281
|
+
}
|
|
282
|
+
/* ── public API ─────────────────────────────────────────────────── */
|
|
283
|
+
function scrollToIndex(index, behavior = 'smooth', options) {
|
|
284
|
+
cancelSnap();
|
|
285
|
+
clearScrollEndTimer();
|
|
286
|
+
const shouldUseSilentScroll = options?.silent === true && behavior === 'smooth';
|
|
287
|
+
if (shouldUseSilentScroll) {
|
|
288
|
+
startSilentScrollWindow();
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
clearSilentScroll();
|
|
292
|
+
}
|
|
293
|
+
const items = getItemElements();
|
|
294
|
+
if (index < 0 || index >= items.length)
|
|
295
|
+
return;
|
|
296
|
+
const target = items[index];
|
|
297
|
+
const idealScrollTop = target.offsetTop - (container.clientHeight - target.offsetHeight) / 2;
|
|
298
|
+
if (behavior === 'instant') {
|
|
299
|
+
// Direct assignment – no animation. Suppress the ensuing scrollend
|
|
300
|
+
// so it doesn't trigger an unnecessary snapToCenter cycle.
|
|
301
|
+
isSnapping = true;
|
|
302
|
+
container.scrollTop = Math.max(0, idealScrollTop);
|
|
303
|
+
instantReleaseRafId = requestAnimationFrame(() => {
|
|
304
|
+
instantReleaseRafId = null;
|
|
305
|
+
isSnapping = false;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
container.scrollTo({
|
|
310
|
+
top: Math.max(0, idealScrollTop),
|
|
311
|
+
behavior: 'smooth'
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
scrollToIndex,
|
|
317
|
+
destroy: () => {
|
|
318
|
+
cancelSnap();
|
|
319
|
+
clearScrollEndTimer();
|
|
320
|
+
clearSilentScroll();
|
|
321
|
+
container.removeEventListener('scroll', handleScroll);
|
|
322
|
+
if (supportsPointerEvents) {
|
|
323
|
+
container.removeEventListener('pointerdown', handlePointerDown);
|
|
324
|
+
window.removeEventListener('pointerup', handlePointerRelease);
|
|
325
|
+
window.removeEventListener('pointercancel', handlePointerRelease);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
container.removeEventListener('mousedown', handlePointerDown);
|
|
329
|
+
window.removeEventListener('mouseup', handlePointerRelease);
|
|
330
|
+
}
|
|
331
|
+
if (supportsScrollEnd) {
|
|
332
|
+
container.removeEventListener('scrollend', handleScrollEnd);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|