@svgrid/grid 1.2.23 → 1.2.25
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/SvAutoComplete.svelte +23 -34
- package/dist/SvCalendar.svelte +74 -334
- package/dist/SvCalendar.svelte.d.ts +5 -23
- package/dist/SvCheckBox.svelte +11 -10
- package/dist/SvColorInput.svelte +31 -58
- package/dist/SvComboBox.svelte +32 -54
- package/dist/SvCountryInput.svelte +31 -29
- package/dist/SvDateTimePicker.svelte +53 -110
- package/dist/SvDateTimePicker.svelte.d.ts +4 -3
- package/dist/SvDropDownList.svelte +31 -54
- package/dist/SvGauge.svelte +21 -23
- package/dist/SvGauge.svelte.d.ts +0 -5
- package/dist/SvListBox.svelte +25 -58
- package/dist/SvListBox.svelte.d.ts +3 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +2 -6
- package/dist/SvPasswordInput.svelte +18 -28
- package/dist/SvPasswordInput.svelte.d.ts +0 -4
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvRadioGroup.svelte +14 -37
- package/dist/SvRadioGroup.svelte.d.ts +6 -9
- package/dist/SvRating.svelte +22 -42
- package/dist/SvRating.svelte.d.ts +3 -5
- package/dist/SvSlider.svelte +30 -57
- package/dist/SvSlider.svelte.d.ts +0 -5
- package/dist/SvSwitchButton.svelte +10 -17
- package/dist/SvSwitchButton.svelte.d.ts +0 -5
- package/dist/SvTabs.svelte +26 -39
- package/dist/SvTabs.svelte.d.ts +5 -5
- package/dist/SvTagsInput.svelte +17 -28
- package/dist/SvTagsInput.svelte.d.ts +0 -5
- package/dist/SvTimePicker.svelte +52 -210
- package/dist/SvTimePicker.svelte.d.ts +11 -8
- package/dist/SvToggleButton.svelte +9 -9
- package/dist/SvTree.svelte +30 -109
- package/dist/SvTree.svelte.d.ts +9 -11
- package/dist/cdn/svgrid.js +7488 -5839
- package/dist/cdn/svgrid.svelte-external.js +4691 -3028
- package/dist/createAutocomplete.svelte.d.ts +77 -0
- package/dist/createAutocomplete.svelte.js +100 -0
- package/dist/createCalendar.svelte.d.ts +192 -0
- package/dist/createCalendar.svelte.js +402 -0
- package/dist/createCheckbox.svelte.d.ts +45 -0
- package/dist/createCheckbox.svelte.js +26 -0
- package/dist/createColorInput.svelte.d.ts +67 -0
- package/dist/createColorInput.svelte.js +100 -0
- package/dist/createCombobox.svelte.d.ts +102 -0
- package/dist/createCombobox.svelte.js +171 -0
- package/dist/createCountryInput.svelte.d.ts +86 -0
- package/dist/createCountryInput.svelte.js +126 -0
- package/dist/createDateTimePicker.svelte.d.ts +83 -0
- package/dist/createDateTimePicker.svelte.js +202 -0
- package/dist/createDropdownList.svelte.d.ts +78 -0
- package/dist/createDropdownList.svelte.js +117 -0
- package/dist/createGauge.svelte.d.ts +56 -0
- package/dist/createGauge.svelte.js +49 -0
- package/dist/createListbox.svelte.d.ts +79 -0
- package/dist/createListbox.svelte.js +117 -0
- package/dist/createMaskedInput.svelte.d.ts +30 -0
- package/dist/createMaskedInput.svelte.js +62 -0
- package/dist/createNumberInput.svelte.d.ts +72 -0
- package/dist/createNumberInput.svelte.js +167 -0
- package/dist/createPasswordInput.svelte.d.ts +60 -0
- package/dist/createPasswordInput.svelte.js +72 -0
- package/dist/createPhoneInput.svelte.d.ts +46 -0
- package/dist/createPhoneInput.svelte.js +105 -0
- package/dist/createRadioGroup.svelte.d.ts +64 -0
- package/dist/createRadioGroup.svelte.js +73 -0
- package/dist/createRating.svelte.d.ts +70 -0
- package/dist/createRating.svelte.js +85 -0
- package/dist/createSlider.svelte.d.ts +71 -0
- package/dist/createSlider.svelte.js +123 -0
- package/dist/createSwitch.svelte.d.ts +43 -0
- package/dist/createSwitch.svelte.js +41 -0
- package/dist/createTabs.svelte.d.ts +78 -0
- package/dist/createTabs.svelte.js +92 -0
- package/dist/createTagsInput.svelte.d.ts +65 -0
- package/dist/createTagsInput.svelte.js +93 -0
- package/dist/createTimePicker.svelte.d.ts +106 -0
- package/dist/createTimePicker.svelte.js +248 -0
- package/dist/createToggle.svelte.d.ts +43 -0
- package/dist/createToggle.svelte.js +39 -0
- package/dist/createTree.svelte.d.ts +96 -0
- package/dist/createTree.svelte.js +194 -0
- package/dist/editor-contract.d.ts +48 -0
- package/dist/editor-contract.js +26 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +31 -0
- package/package.json +1 -1
- package/src/SvAutoComplete.svelte +23 -34
- package/src/SvCalendar.svelte +74 -334
- package/src/SvCheckBox.svelte +11 -10
- package/src/SvColorInput.svelte +31 -58
- package/src/SvComboBox.svelte +32 -54
- package/src/SvCountryInput.svelte +31 -29
- package/src/SvDateTimePicker.svelte +53 -110
- package/src/SvDropDownList.svelte +31 -54
- package/src/SvGauge.svelte +21 -23
- package/src/SvListBox.svelte +25 -58
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +18 -28
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +14 -37
- package/src/SvRating.svelte +22 -42
- package/src/SvSlider.svelte +30 -57
- package/src/SvSwitchButton.svelte +10 -17
- package/src/SvTabs.svelte +26 -39
- package/src/SvTagsInput.svelte +17 -28
- package/src/SvTimePicker.svelte +52 -210
- package/src/SvToggleButton.svelte +9 -9
- package/src/SvTree.svelte +30 -109
- package/src/createAutocomplete.svelte.ts +104 -0
- package/src/createCalendar.svelte.ts +488 -0
- package/src/createCheckbox.svelte.ts +74 -0
- package/src/createColorInput.svelte.ts +115 -0
- package/src/createCombobox.svelte.ts +158 -0
- package/src/createCountryInput.svelte.ts +126 -0
- package/src/createDateTimePicker.svelte.ts +216 -0
- package/src/createDropdownList.svelte.ts +125 -0
- package/src/createGauge.svelte.ts +86 -0
- package/src/createListbox.svelte.ts +186 -0
- package/src/createMaskedInput.svelte.ts +81 -0
- package/src/createNumberInput.svelte.ts +193 -0
- package/src/createPasswordInput.svelte.ts +87 -0
- package/src/createPhoneInput.svelte.ts +119 -0
- package/src/createRadioGroup.svelte.ts +132 -0
- package/src/createRating.svelte.ts +145 -0
- package/src/createSlider.svelte.ts +160 -0
- package/src/createSwitch.svelte.ts +78 -0
- package/src/createTabs.svelte.ts +129 -0
- package/src/createTagsInput.svelte.ts +98 -0
- package/src/createTimePicker.svelte.ts +289 -0
- package/src/createToggle.svelte.ts +69 -0
- package/src/createTree.svelte.ts +216 -0
- package/src/editor-contract.test.ts +32 -0
- package/src/editor-contract.ts +60 -0
- package/src/index.ts +39 -0
- package/src/ui-inputs.test.ts +26 -0
- package/src/ui-selection.test.ts +13 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createTabs - the HEADLESS core behind <SvTabs>, in the same spirit as
|
|
3
|
+
* `createSvGrid` for the grid: a runes-based state machine (roving arrow-key
|
|
4
|
+
* focus, automatic/manual activation, full WAI-ARIA wiring) exposed as
|
|
5
|
+
* **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM
|
|
6
|
+
* assumptions - render it however you like.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createTabs } from '@svgrid/grid'
|
|
11
|
+
* let value = $state('a')
|
|
12
|
+
* const t = createTabs({ tabs: () => tabs, value: () => value, onChange: (id) => (value = id) })
|
|
13
|
+
* </script>
|
|
14
|
+
* <div {...t.tablistProps()}>
|
|
15
|
+
* {#each tabs as tab}
|
|
16
|
+
* <button {...t.tabProps(tab.id)}>{tab.label}</button>
|
|
17
|
+
* {/each}
|
|
18
|
+
* </div>
|
|
19
|
+
* <div {...t.panelProps(t.activeId)}>...</div>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The styled <SvTabs> is just one renderer over this core. DOM focus movement is
|
|
23
|
+
* a render concern, so the component watches `focusTick`/`focusId` and calls
|
|
24
|
+
* `.focus()` itself; the core only decides *which* tab should take focus.
|
|
25
|
+
*/
|
|
26
|
+
export type TabItem = { id: string; label: string; disabled?: boolean }
|
|
27
|
+
|
|
28
|
+
export type TabsOrientation = 'horizontal' | 'vertical'
|
|
29
|
+
export type TabsActivation = 'automatic' | 'manual'
|
|
30
|
+
|
|
31
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
32
|
+
export type TabsConfig = {
|
|
33
|
+
tabs: () => ReadonlyArray<TabItem>
|
|
34
|
+
value: () => string | undefined
|
|
35
|
+
onChange?: (id: string) => void
|
|
36
|
+
orientation?: () => TabsOrientation
|
|
37
|
+
activation?: () => TabsActivation
|
|
38
|
+
ariaLabel?: () => string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let uid = 0
|
|
42
|
+
|
|
43
|
+
export function createTabs(config: TabsConfig) {
|
|
44
|
+
const id = `sv-tabs-${uid++}`
|
|
45
|
+
const tabs = () => config.tabs()
|
|
46
|
+
const orientation = () => config.orientation?.() ?? 'horizontal'
|
|
47
|
+
const activation = () => config.activation?.() ?? 'automatic'
|
|
48
|
+
|
|
49
|
+
const enabled = $derived(tabs().filter((t) => !t.disabled))
|
|
50
|
+
const active = $derived(config.value() ?? tabs().find((t) => !t.disabled)?.id ?? tabs()[0]?.id ?? '')
|
|
51
|
+
|
|
52
|
+
// Roving focus target. `focusTick` bumps on every keyboard move so the styled
|
|
53
|
+
// renderer can move real DOM focus without stealing it on mount.
|
|
54
|
+
let focusId = $state('')
|
|
55
|
+
let focusTick = $state(0)
|
|
56
|
+
const requestFocus = (tid: string) => { focusId = tid; focusTick++ }
|
|
57
|
+
|
|
58
|
+
const tabId = (tid: string) => `${id}-tab-${tid}`
|
|
59
|
+
const panelId = (tid: string) => `${id}-panel-${tid}`
|
|
60
|
+
|
|
61
|
+
function select(tid: string) {
|
|
62
|
+
const t = tabs().find((x) => x.id === tid)
|
|
63
|
+
if (!t || t.disabled) return
|
|
64
|
+
config.onChange?.(tid)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onKeydown(e: KeyboardEvent) {
|
|
68
|
+
const list = enabled
|
|
69
|
+
if (!list.length) return
|
|
70
|
+
const idx = list.findIndex((t) => t.id === active)
|
|
71
|
+
const fwd = orientation() === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
|
|
72
|
+
const back = orientation() === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
|
|
73
|
+
let next: string | null = null
|
|
74
|
+
if (e.key === fwd) next = list[(idx + 1) % list.length]?.id ?? null
|
|
75
|
+
else if (e.key === back) next = list[(idx - 1 + list.length) % list.length]?.id ?? null
|
|
76
|
+
else if (e.key === 'Home') next = list[0]?.id ?? null
|
|
77
|
+
else if (e.key === 'End') next = list.at(-1)?.id ?? null
|
|
78
|
+
else if ((e.key === 'Enter' || e.key === ' ') && activation() === 'manual') { e.preventDefault(); select(active); return }
|
|
79
|
+
else return
|
|
80
|
+
if (next) {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
requestFocus(next)
|
|
83
|
+
if (activation() === 'automatic') select(next)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
/** Currently selected tab id (falls back to the first enabled tab). */
|
|
89
|
+
get activeId() { return active },
|
|
90
|
+
/** Tab id that should hold DOM focus after the latest keyboard move. */
|
|
91
|
+
get focusId() { return focusId },
|
|
92
|
+
/** Monotonic counter; changes only on keyboard navigation. */
|
|
93
|
+
get focusTick() { return focusTick },
|
|
94
|
+
isActive: (tid: string) => tid === active,
|
|
95
|
+
select,
|
|
96
|
+
onKeydown,
|
|
97
|
+
/** Spread onto the tablist container element. */
|
|
98
|
+
tablistProps: () => ({
|
|
99
|
+
role: 'tablist' as const,
|
|
100
|
+
'aria-orientation': orientation(),
|
|
101
|
+
'aria-label': config.ariaLabel?.(),
|
|
102
|
+
}),
|
|
103
|
+
/** Spread onto the tab button for `tid`. */
|
|
104
|
+
tabProps: (tid: string) => {
|
|
105
|
+
const t = tabs().find((x) => x.id === tid)
|
|
106
|
+
const isActive = tid === active
|
|
107
|
+
return {
|
|
108
|
+
role: 'tab' as const,
|
|
109
|
+
id: tabId(tid),
|
|
110
|
+
'data-tab': tid,
|
|
111
|
+
'aria-selected': isActive,
|
|
112
|
+
'aria-controls': panelId(tid),
|
|
113
|
+
tabindex: isActive ? 0 : -1,
|
|
114
|
+
disabled: t?.disabled,
|
|
115
|
+
onclick: () => select(tid),
|
|
116
|
+
onkeydown: onKeydown,
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
/** Spread onto the tabpanel element for `tid` (usually the active id). */
|
|
120
|
+
panelProps: (tid: string) => ({
|
|
121
|
+
role: 'tabpanel' as const,
|
|
122
|
+
id: panelId(tid),
|
|
123
|
+
'aria-labelledby': tabId(tid),
|
|
124
|
+
tabindex: 0,
|
|
125
|
+
}),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type Tabs = ReturnType<typeof createTabs>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createTagsInput - the HEADLESS core behind <SvTagsInput>: an editable
|
|
3
|
+
* token/chips field (type + Enter/comma to add, Backspace to remove the last,
|
|
4
|
+
* per-chip remove). Owns the draft text + add/remove behavior + keyboard,
|
|
5
|
+
* exposed as **prop-getters** you spread onto YOUR OWN markup. No styles/DOM.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createTagsInput } from '@svgrid/grid'
|
|
10
|
+
* let tags = $state<string[]>([])
|
|
11
|
+
* const ti = createTagsInput({ value: () => tags, onChange: (t) => (tags = t) })
|
|
12
|
+
* </script>
|
|
13
|
+
* <div {...ti.rootProps()}>
|
|
14
|
+
* {#each ti.tags as tag, i (tag + i)}
|
|
15
|
+
* <span {...ti.tagProps(i)}>{tag}<button {...ti.removeProps(i)}>x</button></span>
|
|
16
|
+
* {/each}
|
|
17
|
+
* <input {...ti.inputProps()} />
|
|
18
|
+
* </div>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
23
|
+
export type TagsInputConfig = {
|
|
24
|
+
value: () => string[]
|
|
25
|
+
onChange?: (tags: string[]) => void
|
|
26
|
+
disabled?: () => boolean
|
|
27
|
+
/** Reject duplicate tags. Default true. */
|
|
28
|
+
unique?: () => boolean
|
|
29
|
+
max?: () => number
|
|
30
|
+
ariaLabel?: () => string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createTagsInput(config: TagsInputConfig) {
|
|
34
|
+
const disabled = () => config.disabled?.() ?? false
|
|
35
|
+
const unique = () => config.unique?.() ?? true
|
|
36
|
+
const max = () => config.max?.() ?? Infinity
|
|
37
|
+
const tags = () => config.value()
|
|
38
|
+
|
|
39
|
+
let draft = $state('')
|
|
40
|
+
|
|
41
|
+
function add(raw: string) {
|
|
42
|
+
const tag = raw.trim()
|
|
43
|
+
if (!tag || disabled()) return
|
|
44
|
+
if (tags().length >= max()) return
|
|
45
|
+
if (unique() && tags().includes(tag)) { draft = ''; return }
|
|
46
|
+
config.onChange?.([...tags(), tag])
|
|
47
|
+
draft = ''
|
|
48
|
+
}
|
|
49
|
+
function removeAt(i: number) {
|
|
50
|
+
if (disabled()) return
|
|
51
|
+
config.onChange?.(tags().filter((_, idx) => idx !== i))
|
|
52
|
+
}
|
|
53
|
+
function setDraft(v: string) { draft = v }
|
|
54
|
+
function onInput(e: Event) { draft = (e.currentTarget as HTMLInputElement).value }
|
|
55
|
+
function onKeydown(e: KeyboardEvent) {
|
|
56
|
+
if (disabled()) return
|
|
57
|
+
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add(draft) }
|
|
58
|
+
else if (e.key === 'Backspace' && draft === '' && tags().length) { removeAt(tags().length - 1) }
|
|
59
|
+
}
|
|
60
|
+
function onBlur() { add(draft) }
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
/** Current tags (controlled value). */
|
|
64
|
+
get tags() { return tags() },
|
|
65
|
+
/** Current draft text in the input. */
|
|
66
|
+
get draft() { return draft },
|
|
67
|
+
add,
|
|
68
|
+
removeAt,
|
|
69
|
+
setDraft,
|
|
70
|
+
/** Spread onto the container element. */
|
|
71
|
+
rootProps: () => ({
|
|
72
|
+
role: 'group' as const,
|
|
73
|
+
'aria-label': config.ariaLabel?.(),
|
|
74
|
+
}),
|
|
75
|
+
/** Spread onto the text <input>. */
|
|
76
|
+
inputProps: () => ({
|
|
77
|
+
type: 'text' as const,
|
|
78
|
+
value: draft,
|
|
79
|
+
disabled: disabled(),
|
|
80
|
+
'aria-label': config.ariaLabel?.() ?? 'Add tag',
|
|
81
|
+
oninput: onInput,
|
|
82
|
+
onkeydown: onKeydown,
|
|
83
|
+
onblur: onBlur,
|
|
84
|
+
}),
|
|
85
|
+
/** Spread onto the chip wrapper at `index`. */
|
|
86
|
+
tagProps: (index: number) => ({
|
|
87
|
+
'data-idx': index,
|
|
88
|
+
}),
|
|
89
|
+
/** Spread onto a chip's remove <button> at `index`. */
|
|
90
|
+
removeProps: (index: number) => ({
|
|
91
|
+
type: 'button' as const,
|
|
92
|
+
'aria-label': `Remove ${tags()[index] ?? ''}`,
|
|
93
|
+
onclick: () => removeAt(index),
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type TagsInput = ReturnType<typeof createTagsInput>
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createTimePicker - the HEADLESS core behind <SvTimePicker>: a runes-based
|
|
3
|
+
* state machine for an analog clock-dial time picker (12/24-hour, minute
|
|
4
|
+
* snapping, hour->minute auto-switch, keyboard) plus the pure dial geometry
|
|
5
|
+
* (tick coordinates + hand endpoint) and **prop-getters** you spread onto YOUR
|
|
6
|
+
* OWN markup. The DOM-bound pieces - pointer capture + `getBoundingClientRect`
|
|
7
|
+
* hit-testing - stay in the styled component; this core exposes `SIZE`/`C` and a
|
|
8
|
+
* pure `pointerSelect(angle, dist)` so the component only has to convert an event
|
|
9
|
+
* into an angle.
|
|
10
|
+
*
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script lang="ts">
|
|
13
|
+
* import { createTimePicker } from '@svgrid/grid'
|
|
14
|
+
* let value = $state<Date | null>(new Date())
|
|
15
|
+
* const tp = createTimePicker({ value: () => value, onChange: (d) => (value = d) })
|
|
16
|
+
* </script>
|
|
17
|
+
* <button {...tp.segProps('hour')}>{tp.displayHour}</button>:<button {...tp.segProps('minute')}>{tp.mm}</button>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export type TimeValue = Date | string | number | null
|
|
21
|
+
|
|
22
|
+
export type TimeSelection = 'hour' | 'minute'
|
|
23
|
+
export type TimeFormat = '12-hour' | '24-hour'
|
|
24
|
+
|
|
25
|
+
/** A clock-face number and its computed position on the dial. */
|
|
26
|
+
export type DialTick = { label: string; x: number; y: number; value: number; ring: 'outer' | 'inner' }
|
|
27
|
+
|
|
28
|
+
/** Reactive inputs are getters; callbacks are closures. */
|
|
29
|
+
export type TimePickerConfig = {
|
|
30
|
+
/** Date, "HH:MM[:SS]" string, or epoch ms. */
|
|
31
|
+
value: () => TimeValue
|
|
32
|
+
/** Fires with a Date (today's date carrying the picked time). */
|
|
33
|
+
onChange?: (value: Date) => void
|
|
34
|
+
format?: () => TimeFormat
|
|
35
|
+
minuteInterval?: () => number
|
|
36
|
+
/** After picking an hour, jump the dial to minutes. Default true. */
|
|
37
|
+
autoSwitchToMinutes?: () => boolean
|
|
38
|
+
disabled?: () => boolean
|
|
39
|
+
readonly?: () => boolean
|
|
40
|
+
/** Which dial opens first. */
|
|
41
|
+
selection?: () => TimeSelection
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Dial geometry (pure) -----------------------------------------------------
|
|
45
|
+
const SIZE = 240
|
|
46
|
+
const C = SIZE / 2
|
|
47
|
+
const OUTER = C - 22
|
|
48
|
+
const INNER = C - 58
|
|
49
|
+
|
|
50
|
+
function pointAt(angleDeg: number, r: number) {
|
|
51
|
+
const rad = ((angleDeg - 90) * Math.PI) / 180
|
|
52
|
+
return { x: C + r * Math.cos(rad), y: C + r * Math.sin(rad) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse a time value to { h, m }, falling back to "now" (pure). */
|
|
56
|
+
export function parseTimeValue(v: TimeValue): { h: number; m: number } {
|
|
57
|
+
if (v == null) { const n = new Date(); return { h: n.getHours(), m: n.getMinutes() } }
|
|
58
|
+
if (typeof v === 'string') {
|
|
59
|
+
const mt = v.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/)
|
|
60
|
+
if (mt) return { h: Math.min(23, +mt[1]!), m: Math.min(59, +mt[2]!) }
|
|
61
|
+
const d = new Date(v)
|
|
62
|
+
if (!isNaN(d.getTime())) return { h: d.getHours(), m: d.getMinutes() }
|
|
63
|
+
const n = new Date(); return { h: n.getHours(), m: n.getMinutes() }
|
|
64
|
+
}
|
|
65
|
+
const d = new Date(v)
|
|
66
|
+
return { h: d.getHours(), m: d.getMinutes() }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createTimePicker(config: TimePickerConfig) {
|
|
70
|
+
const format = () => config.format?.() ?? '24-hour'
|
|
71
|
+
const minuteInterval = () => config.minuteInterval?.() ?? 1
|
|
72
|
+
const autoSwitchToMinutes = () => config.autoSwitchToMinutes?.() ?? true
|
|
73
|
+
const isDisabled = () => config.disabled?.() ?? false
|
|
74
|
+
const isReadonly = () => config.readonly?.() ?? false
|
|
75
|
+
|
|
76
|
+
const is12 = $derived(format() === '12-hour')
|
|
77
|
+
const isInteractive = $derived(!isDisabled() && !isReadonly())
|
|
78
|
+
|
|
79
|
+
let hours = $state(0)
|
|
80
|
+
let minutes = $state(0)
|
|
81
|
+
let sel = $state<TimeSelection>('hour')
|
|
82
|
+
let seeded = false
|
|
83
|
+
let lastKey = ''
|
|
84
|
+
$effect(() => {
|
|
85
|
+
const { h, m } = parseTimeValue(config.value())
|
|
86
|
+
const key = `${h}:${m}`
|
|
87
|
+
if (key !== lastKey) {
|
|
88
|
+
lastKey = key
|
|
89
|
+
hours = h
|
|
90
|
+
minutes = m
|
|
91
|
+
if (!seeded) { seeded = true; sel = config.selection?.() ?? 'hour' }
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const displayHour = $derived(is12 ? (hours % 12 === 0 ? 12 : hours % 12) : hours)
|
|
96
|
+
const isPm = $derived(hours >= 12)
|
|
97
|
+
const mm = $derived(String(minutes).padStart(2, '0'))
|
|
98
|
+
|
|
99
|
+
const hourTicks = $derived.by<DialTick[]>(() => {
|
|
100
|
+
const ticks: DialTick[] = []
|
|
101
|
+
if (is12) {
|
|
102
|
+
for (let i = 0; i < 12; i++) {
|
|
103
|
+
const h = i === 0 ? 12 : i
|
|
104
|
+
const p = pointAt(i * 30, OUTER)
|
|
105
|
+
ticks.push({ label: String(h), x: p.x, y: p.y, value: h, ring: 'outer' })
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
for (let i = 0; i < 12; i++) {
|
|
109
|
+
const outer = pointAt(i * 30, OUTER) // 12..23
|
|
110
|
+
const inner = pointAt(i * 30, INNER) // 00..11
|
|
111
|
+
const ho = i + 12
|
|
112
|
+
const hi = i
|
|
113
|
+
ticks.push({ label: String(ho).padStart(2, '0'), x: outer.x, y: outer.y, value: ho, ring: 'outer' })
|
|
114
|
+
ticks.push({ label: hi === 0 ? '00' : String(hi).padStart(2, '0'), x: inner.x, y: inner.y, value: hi, ring: 'inner' })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return ticks
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const minuteTicks = $derived.by<DialTick[]>(() =>
|
|
121
|
+
Array.from({ length: 12 }, (_, i) => {
|
|
122
|
+
const v = i * 5
|
|
123
|
+
const p = pointAt(i * 30, OUTER)
|
|
124
|
+
return { label: String(v).padStart(2, '0'), x: p.x, y: p.y, value: v, ring: 'outer' as const }
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// Hand endpoint for the current selection.
|
|
129
|
+
const handAngle = $derived(
|
|
130
|
+
sel === 'hour' ? (is12 ? (displayHour % 12) * 30 : (hours % 12) * 30) : minutes * 6,
|
|
131
|
+
)
|
|
132
|
+
const handRadius = $derived(sel === 'hour' && !is12 && hours % 12 === hours && hours < 12 ? INNER : OUTER)
|
|
133
|
+
const handEnd = $derived(pointAt(handAngle, handRadius))
|
|
134
|
+
|
|
135
|
+
// --- Emit + mutators -------------------------------------------------------
|
|
136
|
+
function emit() {
|
|
137
|
+
const d = new Date()
|
|
138
|
+
d.setHours(hours, minutes, 0, 0)
|
|
139
|
+
config.onChange?.(d)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const snap = (m: number) => {
|
|
143
|
+
const step = Math.max(1, Math.floor(minuteInterval()))
|
|
144
|
+
return (Math.round(m / step) * step) % 60
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setSelection(s: TimeSelection) { sel = s }
|
|
148
|
+
|
|
149
|
+
function setMinute(m: number) {
|
|
150
|
+
minutes = snap(m)
|
|
151
|
+
lastKey = `${hours}:${minutes}`
|
|
152
|
+
emit()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setAmPm(pm: boolean) {
|
|
156
|
+
if (pm === isPm) return
|
|
157
|
+
hours = pm ? (hours % 12) + 12 : hours % 12
|
|
158
|
+
lastKey = `${hours}:${minutes}`
|
|
159
|
+
emit()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Apply a dial angle (0..360, 12 o'clock = 0) + distance from center. The
|
|
163
|
+
* styled component converts a pointer event into these via the DOM rect. */
|
|
164
|
+
function pointerSelect(angle: number, dist: number) {
|
|
165
|
+
if (!isInteractive) return
|
|
166
|
+
if (sel === 'hour') {
|
|
167
|
+
const idx = Math.round(angle / 30) % 12
|
|
168
|
+
if (is12) {
|
|
169
|
+
const h = idx === 0 ? 12 : idx
|
|
170
|
+
const base = h % 12
|
|
171
|
+
hours = isPm ? base + 12 : base
|
|
172
|
+
} else {
|
|
173
|
+
const inner = dist < (OUTER + INNER) / 2
|
|
174
|
+
hours = inner ? idx : idx + 12
|
|
175
|
+
}
|
|
176
|
+
lastKey = `${hours}:${minutes}`
|
|
177
|
+
emit()
|
|
178
|
+
} else {
|
|
179
|
+
const m = Math.round(angle / 6) % 60
|
|
180
|
+
setMinute(m)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Called on pointer-up: auto-advance hour -> minute when configured. */
|
|
185
|
+
function endPointer() {
|
|
186
|
+
if (sel === 'hour' && autoSwitchToMinutes()) sel = 'minute'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function onKeydown(e: KeyboardEvent) {
|
|
190
|
+
if (!isInteractive) return
|
|
191
|
+
const step = sel === 'minute' ? Math.max(1, Math.floor(minuteInterval())) : 1
|
|
192
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
|
|
193
|
+
e.preventDefault()
|
|
194
|
+
if (sel === 'hour') { hours = (hours + 1) % 24 } else { minutes = (minutes + step) % 60 }
|
|
195
|
+
lastKey = `${hours}:${minutes}`; emit()
|
|
196
|
+
} else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
|
|
197
|
+
e.preventDefault()
|
|
198
|
+
if (sel === 'hour') { hours = (hours + 23) % 24 } else { minutes = (minutes - step + 60) % 60 }
|
|
199
|
+
lastKey = `${hours}:${minutes}`; emit()
|
|
200
|
+
} else if (e.key === 'Tab' && !e.shiftKey && sel === 'hour') {
|
|
201
|
+
sel = 'minute'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function now() {
|
|
206
|
+
const n = new Date()
|
|
207
|
+
hours = n.getHours(); minutes = n.getMinutes()
|
|
208
|
+
lastKey = `${hours}:${minutes}`
|
|
209
|
+
emit()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isActiveHour(t: DialTick): boolean {
|
|
213
|
+
if (is12) return t.value === displayHour
|
|
214
|
+
return t.value === hours
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
// Dial geometry constants (the component needs these to map a pointer event
|
|
219
|
+
// to an angle without re-deriving the layout).
|
|
220
|
+
SIZE,
|
|
221
|
+
C,
|
|
222
|
+
|
|
223
|
+
// --- Reactive state -------------------------------------------------------
|
|
224
|
+
get hours() { return hours },
|
|
225
|
+
get minutes() { return minutes },
|
|
226
|
+
get selection() { return sel },
|
|
227
|
+
get is12() { return is12 },
|
|
228
|
+
get isInteractive() { return isInteractive },
|
|
229
|
+
get displayHour() { return displayHour },
|
|
230
|
+
get isPm() { return isPm },
|
|
231
|
+
get mm() { return mm },
|
|
232
|
+
get hourTicks() { return hourTicks },
|
|
233
|
+
get minuteTicks() { return minuteTicks },
|
|
234
|
+
get handEnd() { return handEnd },
|
|
235
|
+
|
|
236
|
+
// --- Derived helpers ------------------------------------------------------
|
|
237
|
+
isActiveHour,
|
|
238
|
+
|
|
239
|
+
// --- Actions --------------------------------------------------------------
|
|
240
|
+
setSelection,
|
|
241
|
+
setMinute,
|
|
242
|
+
setAmPm,
|
|
243
|
+
pointerSelect,
|
|
244
|
+
endPointer,
|
|
245
|
+
onKeydown,
|
|
246
|
+
now,
|
|
247
|
+
|
|
248
|
+
// --- Prop-getters ---------------------------------------------------------
|
|
249
|
+
/** The hour / minute segment button in the header. */
|
|
250
|
+
segProps(which: TimeSelection) {
|
|
251
|
+
return {
|
|
252
|
+
type: 'button' as const,
|
|
253
|
+
disabled: isDisabled(),
|
|
254
|
+
onclick: () => setSelection(which),
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
/** An AM / PM toggle button (12-hour mode). */
|
|
258
|
+
ampmProps(pm: boolean) {
|
|
259
|
+
return {
|
|
260
|
+
type: 'button' as const,
|
|
261
|
+
disabled: isDisabled(),
|
|
262
|
+
onclick: () => setAmPm(pm),
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
/** The clock dial: ARIA slider semantics + keyboard. Pointer capture stays in
|
|
266
|
+
* the component (it needs the DOM element). */
|
|
267
|
+
dialProps() {
|
|
268
|
+
return {
|
|
269
|
+
role: 'slider' as const,
|
|
270
|
+
tabindex: isDisabled() ? -1 : 0,
|
|
271
|
+
'aria-label': sel === 'hour' ? 'Hour' : 'Minute',
|
|
272
|
+
'aria-valuenow': sel === 'hour' ? hours : minutes,
|
|
273
|
+
'aria-valuemin': 0,
|
|
274
|
+
'aria-valuemax': sel === 'hour' ? 23 : 59,
|
|
275
|
+
onkeydown: onKeydown,
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
/** The Now footer button. */
|
|
279
|
+
nowProps() {
|
|
280
|
+
return {
|
|
281
|
+
type: 'button' as const,
|
|
282
|
+
disabled: isDisabled(),
|
|
283
|
+
onclick: now,
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type TimePicker = ReturnType<typeof createTimePicker>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createToggle - the HEADLESS core behind <SvToggleButton>, in the same spirit
|
|
3
|
+
* as `createSvGrid`: a tiny controlled state machine (a single pressed on/off
|
|
4
|
+
* bit + ARIA) exposed as **prop-getters** you spread onto YOUR OWN markup. No
|
|
5
|
+
* styles, no DOM assumptions.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createToggle } from '@svgrid/grid'
|
|
10
|
+
* let pressed = $state(false)
|
|
11
|
+
* const t = createToggle({ pressed: () => pressed, onChange: (v) => (pressed = v) })
|
|
12
|
+
* </script>
|
|
13
|
+
* <button {...t.buttonProps()}>Bold</button>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The styled <SvToggleButton> is just one renderer over this core.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes
|
|
20
|
+
* (the same controlled pattern used across the SvGrid UI kit). */
|
|
21
|
+
export type ToggleConfig = {
|
|
22
|
+
pressed: () => boolean
|
|
23
|
+
onChange?: (pressed: boolean) => void
|
|
24
|
+
disabled?: () => boolean
|
|
25
|
+
ariaLabel?: () => string | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ToggleButtonProps = {
|
|
29
|
+
type: 'button'
|
|
30
|
+
'aria-pressed': boolean
|
|
31
|
+
'aria-label': string | undefined
|
|
32
|
+
disabled: boolean
|
|
33
|
+
'data-pressed': '' | undefined
|
|
34
|
+
onclick: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Toggle = {
|
|
38
|
+
/** Current pressed state. */
|
|
39
|
+
readonly pressed: boolean
|
|
40
|
+
readonly disabled: boolean
|
|
41
|
+
/** Flip pressed (no-op when disabled). */
|
|
42
|
+
toggle: () => void
|
|
43
|
+
/** Spread onto the button element. */
|
|
44
|
+
buttonProps: () => ToggleButtonProps
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createToggle(config: ToggleConfig): Toggle {
|
|
48
|
+
const pressed = () => config.pressed()
|
|
49
|
+
const disabled = () => config.disabled?.() ?? false
|
|
50
|
+
|
|
51
|
+
function toggle() {
|
|
52
|
+
if (disabled()) return
|
|
53
|
+
config.onChange?.(!pressed())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
get pressed() { return pressed() },
|
|
58
|
+
get disabled() { return disabled() },
|
|
59
|
+
toggle,
|
|
60
|
+
buttonProps: () => ({
|
|
61
|
+
type: 'button',
|
|
62
|
+
'aria-pressed': pressed(),
|
|
63
|
+
'aria-label': config.ariaLabel?.(),
|
|
64
|
+
disabled: disabled(),
|
|
65
|
+
'data-pressed': pressed() ? '' : undefined,
|
|
66
|
+
onclick: toggle,
|
|
67
|
+
}),
|
|
68
|
+
}
|
|
69
|
+
}
|