@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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createCombobox - the HEADLESS core behind <SvComboBox>, in the same spirit as
|
|
3
|
+
* `createListbox` / `createSvGrid`: a runes-based state machine (type-to-filter,
|
|
4
|
+
* roving active index, full keyboard, unmatched-text revert) exposed as
|
|
5
|
+
* **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM, no portal
|
|
6
|
+
* or measurement - those render concerns stay in the styled component.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createCombobox } from '@svgrid/grid'
|
|
11
|
+
* let value = $state<string | null>(null)
|
|
12
|
+
* const cb = createCombobox({ options: () => options, value: () => value, onChange: (v) => (value = v) })
|
|
13
|
+
* </script>
|
|
14
|
+
* <input {...cb.inputProps()} />
|
|
15
|
+
* {#if cb.open}
|
|
16
|
+
* <ul {...cb.listboxProps()}>
|
|
17
|
+
* {#each cb.filtered as opt, i (opt.value)}<li {...cb.optionProps(i)}>{opt.label}</li>{/each}
|
|
18
|
+
* </ul>
|
|
19
|
+
* {/if}
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { filterOptions, type ListOption } from './list-option'
|
|
23
|
+
|
|
24
|
+
export type ComboboxValue = string | number | null
|
|
25
|
+
|
|
26
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
27
|
+
export type ComboboxConfig = {
|
|
28
|
+
options: () => ReadonlyArray<ListOption>
|
|
29
|
+
value: () => ComboboxValue
|
|
30
|
+
onChange?: (value: ComboboxValue) => void
|
|
31
|
+
disabled?: () => boolean
|
|
32
|
+
ariaLabel?: () => string | undefined
|
|
33
|
+
/** DOM focus hooks provided by the renderer; the core itself never touches the DOM. */
|
|
34
|
+
focusInput?: () => void
|
|
35
|
+
blurInput?: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let uid = 0
|
|
39
|
+
|
|
40
|
+
export function createCombobox(config: ComboboxConfig) {
|
|
41
|
+
const id = `sv-combo-${uid++}`
|
|
42
|
+
const listId = `${id}-list`
|
|
43
|
+
const opts = () => config.options()
|
|
44
|
+
const disabled = () => config.disabled?.() ?? false
|
|
45
|
+
|
|
46
|
+
let open = $state(false)
|
|
47
|
+
let query = $state('')
|
|
48
|
+
let active = $state(0)
|
|
49
|
+
let editing = $state(false)
|
|
50
|
+
|
|
51
|
+
const selected = $derived(opts().find((o) => o.value === config.value()) ?? null)
|
|
52
|
+
const filtered = $derived(editing ? filterOptions(opts(), query) : [...opts()])
|
|
53
|
+
const shownText = $derived(editing ? query : selected?.label ?? '')
|
|
54
|
+
|
|
55
|
+
// While not actively editing, keep the shown text in sync with the selection.
|
|
56
|
+
$effect(() => { if (!editing) query = selected?.label ?? '' })
|
|
57
|
+
|
|
58
|
+
const optionId = (i: number) => `${listId}-opt-${i}`
|
|
59
|
+
|
|
60
|
+
function openPanel() { if (disabled() || open) return; open = true; active = 0 }
|
|
61
|
+
function close(revert = true) {
|
|
62
|
+
open = false; editing = false
|
|
63
|
+
if (revert) query = selected?.label ?? ''
|
|
64
|
+
}
|
|
65
|
+
function toggle() { if (open) { close() } else { config.focusInput?.(); openPanel() } }
|
|
66
|
+
function pick(o: ListOption | undefined) {
|
|
67
|
+
if (!o || o.disabled) return
|
|
68
|
+
config.onChange?.(o.value); query = o.label; editing = false; open = false; config.blurInput?.()
|
|
69
|
+
}
|
|
70
|
+
function setActive(i: number) { const o = filtered[i]; if (o && !o.disabled) active = i }
|
|
71
|
+
function onInput(e: Event) {
|
|
72
|
+
query = (e.currentTarget as HTMLInputElement).value
|
|
73
|
+
editing = true; active = 0
|
|
74
|
+
if (!open) openPanel()
|
|
75
|
+
}
|
|
76
|
+
function onFocus() { editing = true; query = selected?.label ?? ''; openPanel() }
|
|
77
|
+
function onKeydown(e: KeyboardEvent) {
|
|
78
|
+
if (disabled()) return
|
|
79
|
+
if (!open && e.key === 'ArrowDown') { e.preventDefault(); editing = true; openPanel(); return }
|
|
80
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
|
|
81
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
|
|
82
|
+
else if (e.key === 'Enter') { e.preventDefault(); pick(filtered[active]) }
|
|
83
|
+
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
/** Panel open state. */
|
|
88
|
+
get open() { return open },
|
|
89
|
+
/** Highlighted option index into `filtered`. */
|
|
90
|
+
get activeIndex() { return active },
|
|
91
|
+
/** True while the user is typing a filter query. */
|
|
92
|
+
get editing() { return editing },
|
|
93
|
+
/** Current filter query text. */
|
|
94
|
+
get query() { return query },
|
|
95
|
+
/** Options after the live substring filter. */
|
|
96
|
+
get filtered() { return filtered },
|
|
97
|
+
/** The option matching the controlled value, if any. */
|
|
98
|
+
get selected() { return selected },
|
|
99
|
+
/** Text shown in the field (query while editing, else the selected label). */
|
|
100
|
+
get shownText() { return shownText },
|
|
101
|
+
/** Controlled value. */
|
|
102
|
+
get value() { return config.value() },
|
|
103
|
+
/** id of the listbox element (matches inputProps `aria-controls`). */
|
|
104
|
+
listId,
|
|
105
|
+
isActive: (i: number) => i === active,
|
|
106
|
+
isSelected: (o: ListOption) => o.value === config.value(),
|
|
107
|
+
setActive,
|
|
108
|
+
openPanel,
|
|
109
|
+
close,
|
|
110
|
+
toggle,
|
|
111
|
+
pick,
|
|
112
|
+
onInput,
|
|
113
|
+
onFocus,
|
|
114
|
+
onKeydown,
|
|
115
|
+
/** Spread onto the editable <input>. */
|
|
116
|
+
inputProps: () => ({
|
|
117
|
+
role: 'combobox' as const,
|
|
118
|
+
'aria-expanded': open,
|
|
119
|
+
'aria-controls': listId,
|
|
120
|
+
'aria-autocomplete': 'list' as const,
|
|
121
|
+
'aria-label': config.ariaLabel?.(),
|
|
122
|
+
value: shownText,
|
|
123
|
+
disabled: disabled(),
|
|
124
|
+
oninput: onInput,
|
|
125
|
+
onfocus: onFocus,
|
|
126
|
+
onkeydown: onKeydown,
|
|
127
|
+
}),
|
|
128
|
+
/** Spread onto the chevron/toggle <button>. */
|
|
129
|
+
triggerProps: () => ({
|
|
130
|
+
type: 'button' as const,
|
|
131
|
+
tabindex: -1,
|
|
132
|
+
'aria-label': 'Toggle',
|
|
133
|
+
disabled: disabled(),
|
|
134
|
+
onclick: toggle,
|
|
135
|
+
}),
|
|
136
|
+
/** Spread onto the listbox container. */
|
|
137
|
+
listboxProps: () => ({
|
|
138
|
+
id: listId,
|
|
139
|
+
role: 'listbox' as const,
|
|
140
|
+
}),
|
|
141
|
+
/** Spread onto the option at `index` (index into `filtered`). */
|
|
142
|
+
optionProps: (index: number) => {
|
|
143
|
+
const o = filtered[index]
|
|
144
|
+
return {
|
|
145
|
+
role: 'option' as const,
|
|
146
|
+
id: optionId(index),
|
|
147
|
+
tabindex: -1,
|
|
148
|
+
'aria-selected': o ? o.value === config.value() : false,
|
|
149
|
+
'aria-disabled': o?.disabled,
|
|
150
|
+
'data-idx': index,
|
|
151
|
+
onclick: () => pick(o),
|
|
152
|
+
onpointermove: () => { if (o && !o.disabled) active = index },
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type Combobox = ReturnType<typeof createCombobox>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createCountryInput - the HEADLESS core behind <SvCountryInput>: a searchable
|
|
3
|
+
* country picker (search by name, dial code, or ISO code; roving active index +
|
|
4
|
+
* keyboard) that emits the ISO 3166-1 alpha-2 code. Exposed as **prop-getters**
|
|
5
|
+
* you spread onto YOUR OWN markup. No styles/DOM/portal - those stay in the
|
|
6
|
+
* styled component.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createCountryInput, flagEmoji } from '@svgrid/grid'
|
|
11
|
+
* let value = $state<string | null>(null)
|
|
12
|
+
* const ci = createCountryInput({ value: () => value, onChange: (c) => (value = c) })
|
|
13
|
+
* </script>
|
|
14
|
+
* <button {...ci.triggerProps()}>{ci.selected?.name ?? 'Select'}</button>
|
|
15
|
+
* {#if ci.open}
|
|
16
|
+
* <input {...ci.searchProps()} />
|
|
17
|
+
* <ul {...ci.listboxProps()}>
|
|
18
|
+
* {#each ci.filtered as c, i (c.code)}<li {...ci.optionProps(i)}>{c.name}</li>{/each}
|
|
19
|
+
* </ul>
|
|
20
|
+
* {/if}
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import { COUNTRIES, COUNTRY_BY_CODE, type Country } from './countries'
|
|
24
|
+
|
|
25
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
26
|
+
export type CountryInputConfig = {
|
|
27
|
+
value: () => string | null
|
|
28
|
+
onChange?: (code: string) => void
|
|
29
|
+
disabled?: () => boolean
|
|
30
|
+
ariaLabel?: () => string | undefined
|
|
31
|
+
/** DOM focus hook provided by the renderer; the core never touches the DOM. */
|
|
32
|
+
focusTrigger?: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createCountryInput(config: CountryInputConfig) {
|
|
36
|
+
const disabled = () => config.disabled?.() ?? false
|
|
37
|
+
|
|
38
|
+
let open = $state(false)
|
|
39
|
+
let query = $state('')
|
|
40
|
+
let active = $state(0)
|
|
41
|
+
|
|
42
|
+
const selected = $derived(config.value() ? COUNTRY_BY_CODE.get(config.value()!) ?? null : null)
|
|
43
|
+
const filtered = $derived<Country[]>(
|
|
44
|
+
query.trim() === ''
|
|
45
|
+
? COUNTRIES
|
|
46
|
+
: COUNTRIES.filter(
|
|
47
|
+
(c) =>
|
|
48
|
+
c.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
49
|
+
c.dial.includes(query) ||
|
|
50
|
+
c.code.toLowerCase() === query.toLowerCase(),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
function openPanel() { if (disabled() || open) return; open = true; query = ''; active = 0 }
|
|
55
|
+
function close() { open = false; config.focusTrigger?.() }
|
|
56
|
+
function toggle() { if (open) { open = false } else { openPanel() } }
|
|
57
|
+
function pick(code: string) { config.onChange?.(code); open = false; config.focusTrigger?.() }
|
|
58
|
+
function setQuery(v: string) { query = v; active = 0 }
|
|
59
|
+
function onSearchInput(e: Event) { setQuery((e.currentTarget as HTMLInputElement).value) }
|
|
60
|
+
function onKeydown(e: KeyboardEvent) {
|
|
61
|
+
if (!open) return
|
|
62
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
|
|
63
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
|
|
64
|
+
else if (e.key === 'Enter') { e.preventDefault(); const c = filtered[active]; if (c) pick(c.code) }
|
|
65
|
+
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
/** Panel open state. */
|
|
70
|
+
get open() { return open },
|
|
71
|
+
/** Highlighted country index into `filtered`. */
|
|
72
|
+
get activeIndex() { return active },
|
|
73
|
+
/** Current search query. */
|
|
74
|
+
get query() { return query },
|
|
75
|
+
/** Countries after the current search filter. */
|
|
76
|
+
get filtered() { return filtered },
|
|
77
|
+
/** The selected country, if any. */
|
|
78
|
+
get selected() { return selected },
|
|
79
|
+
/** Controlled value (ISO alpha-2 code). */
|
|
80
|
+
get value() { return config.value() },
|
|
81
|
+
isActive: (i: number) => i === active,
|
|
82
|
+
isSelected: (code: string) => code === config.value(),
|
|
83
|
+
setActive: (i: number) => { active = i },
|
|
84
|
+
setQuery,
|
|
85
|
+
openPanel,
|
|
86
|
+
close,
|
|
87
|
+
toggle,
|
|
88
|
+
pick,
|
|
89
|
+
onKeydown,
|
|
90
|
+
/** Spread onto the trigger <button>. */
|
|
91
|
+
triggerProps: () => ({
|
|
92
|
+
type: 'button' as const,
|
|
93
|
+
'aria-haspopup': 'listbox' as const,
|
|
94
|
+
'aria-expanded': open,
|
|
95
|
+
'aria-label': config.ariaLabel?.(),
|
|
96
|
+
disabled: disabled(),
|
|
97
|
+
onclick: toggle,
|
|
98
|
+
}),
|
|
99
|
+
/** Spread onto the search <input> inside the panel. */
|
|
100
|
+
searchProps: () => ({
|
|
101
|
+
type: 'text' as const,
|
|
102
|
+
value: query,
|
|
103
|
+
'aria-label': 'Search countries',
|
|
104
|
+
oninput: onSearchInput,
|
|
105
|
+
onkeydown: onKeydown,
|
|
106
|
+
}),
|
|
107
|
+
/** Spread onto the listbox container. */
|
|
108
|
+
listboxProps: () => ({
|
|
109
|
+
role: 'listbox' as const,
|
|
110
|
+
}),
|
|
111
|
+
/** Spread onto the country option at `index` (index into `filtered`). */
|
|
112
|
+
optionProps: (index: number) => {
|
|
113
|
+
const c = filtered[index]
|
|
114
|
+
return {
|
|
115
|
+
role: 'option' as const,
|
|
116
|
+
tabindex: -1,
|
|
117
|
+
'aria-selected': c ? c.code === config.value() : false,
|
|
118
|
+
'data-idx': index,
|
|
119
|
+
onclick: () => { if (c) pick(c.code) },
|
|
120
|
+
onpointermove: () => { active = index },
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type CountryInput = ReturnType<typeof createCountryInput>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createDateTimePicker - the HEADLESS core behind <SvDateTimePicker>: a
|
|
3
|
+
* runes-based state machine for a masked date-time field (format/parse via the
|
|
4
|
+
* ./datetime token engine, min/max clamping, nullable clear, spin-button
|
|
5
|
+
* stepping) plus the dropdown's open/tab state and the calendar/time-picker
|
|
6
|
+
* wiring. The render-only concerns - the portalled popover positioning, DOM
|
|
7
|
+
* refs, outside-click + reposition listeners - stay in the styled component.
|
|
8
|
+
*
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script lang="ts">
|
|
11
|
+
* import { createDateTimePicker } from '@svgrid/grid'
|
|
12
|
+
* let value = $state<Date | null>(new Date())
|
|
13
|
+
* const dtp = createDateTimePicker({ value: () => value, onChange: (d) => (value = d) })
|
|
14
|
+
* </script>
|
|
15
|
+
* <input bind:value={dtp.text} {...dtp.inputProps()} />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { formatDate, parseDate } from './datetime/date-format'
|
|
19
|
+
import { toDate, clampDate, withTime, startOfDay, type DateLike } from './datetime/date-core'
|
|
20
|
+
|
|
21
|
+
export type DateTimeValue = Date | string | number | null
|
|
22
|
+
export type DropDownDisplayMode = 'both' | 'calendar' | 'time'
|
|
23
|
+
export type DateTimeTab = 'date' | 'time'
|
|
24
|
+
|
|
25
|
+
/** Reactive inputs are getters; callbacks are closures. */
|
|
26
|
+
export type DateTimePickerConfig = {
|
|
27
|
+
value: () => DateTimeValue
|
|
28
|
+
onChange?: (value: Date | null) => void
|
|
29
|
+
/** Fired when the value is finalized (Enter, blur, or a single-date pick). */
|
|
30
|
+
onCommit?: (value: Date | null) => void
|
|
31
|
+
/** Fired on Escape / dismiss without committing. */
|
|
32
|
+
onCancel?: () => void
|
|
33
|
+
formatString?: () => string
|
|
34
|
+
min?: () => DateLike | null
|
|
35
|
+
max?: () => DateLike | null
|
|
36
|
+
nullable?: () => boolean
|
|
37
|
+
locale?: () => string
|
|
38
|
+
dropDownDisplayMode?: () => DropDownDisplayMode
|
|
39
|
+
spinButtons?: () => boolean
|
|
40
|
+
stepMinutes?: () => number
|
|
41
|
+
disabled?: () => boolean
|
|
42
|
+
readonly?: () => boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultLocale = () => (typeof navigator !== 'undefined' ? navigator.language : 'en-US')
|
|
46
|
+
|
|
47
|
+
export function createDateTimePicker(config: DateTimePickerConfig) {
|
|
48
|
+
const formatString = () => config.formatString?.() ?? 'yyyy-MM-dd HH:mm'
|
|
49
|
+
const nullable = () => config.nullable?.() ?? true
|
|
50
|
+
const locale = () => config.locale?.() ?? defaultLocale()
|
|
51
|
+
const dropDownDisplayMode = () => config.dropDownDisplayMode?.() ?? 'both'
|
|
52
|
+
const spinButtons = () => config.spinButtons?.() ?? false
|
|
53
|
+
const stepMinutes = () => config.stepMinutes?.() ?? 1
|
|
54
|
+
const isDisabled = () => config.disabled?.() ?? false
|
|
55
|
+
const isReadonly = () => config.readonly?.() ?? false
|
|
56
|
+
|
|
57
|
+
const isInteractive = $derived(!isDisabled() && !isReadonly())
|
|
58
|
+
const minD = $derived(toDate(config.min?.() ?? null))
|
|
59
|
+
const maxD = $derived(toDate(config.max?.() ?? null))
|
|
60
|
+
|
|
61
|
+
let current = $state<Date | null>(null)
|
|
62
|
+
let text = $state('')
|
|
63
|
+
let lastKey = ''
|
|
64
|
+
$effect(() => {
|
|
65
|
+
const d = toDate(config.value() as DateLike)
|
|
66
|
+
const key = d ? String(d.getTime()) : 'null'
|
|
67
|
+
if (key !== lastKey) {
|
|
68
|
+
lastKey = key
|
|
69
|
+
current = d
|
|
70
|
+
text = d ? formatDate(d, formatString(), locale()) : ''
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// --- Dropdown (open/tab) state; positioning stays in the component ----------
|
|
75
|
+
let open = $state(false)
|
|
76
|
+
let tab = $state<DateTimeTab>('date')
|
|
77
|
+
|
|
78
|
+
const showDateTab = $derived(dropDownDisplayMode() !== 'time')
|
|
79
|
+
const showTimeTab = $derived(dropDownDisplayMode() !== 'calendar')
|
|
80
|
+
|
|
81
|
+
// --- Commit helpers --------------------------------------------------------
|
|
82
|
+
function commit(next: Date | null) {
|
|
83
|
+
const clamped = next ? clampDate(next, minD, maxD) : null
|
|
84
|
+
current = clamped
|
|
85
|
+
lastKey = clamped ? String(clamped.getTime()) : 'null'
|
|
86
|
+
text = clamped ? formatDate(clamped, formatString(), locale()) : ''
|
|
87
|
+
config.onChange?.(clamped)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function commitFromText() {
|
|
91
|
+
const raw = text.trim()
|
|
92
|
+
if (!raw) {
|
|
93
|
+
if (nullable()) commit(null)
|
|
94
|
+
else text = current ? formatDate(current, formatString(), locale()) : ''
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const parsed = parseDate(raw, formatString(), locale(), 2029, current ?? new Date())
|
|
98
|
+
if (parsed) commit(parsed)
|
|
99
|
+
else text = current ? formatDate(current, formatString(), locale()) : '' // revert on bad input
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bump(deltaMin: number) {
|
|
103
|
+
const base = current ?? new Date()
|
|
104
|
+
commit(new Date(base.getTime() + deltaMin * 60000))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Sub-picker wiring -----------------------------------------------------
|
|
108
|
+
function onCalendarChange(dates: Date[]) {
|
|
109
|
+
const day = dates[0]
|
|
110
|
+
if (!day) return
|
|
111
|
+
commit(withTime(day, current ?? startOfDay(day)))
|
|
112
|
+
// Date-only picker: a single pick finalizes the value.
|
|
113
|
+
if (dropDownDisplayMode() === 'calendar') { open = false; config.onCommit?.(current) }
|
|
114
|
+
else if (dropDownDisplayMode() === 'both') tab = 'time'
|
|
115
|
+
}
|
|
116
|
+
function onTimeChange(t: Date) {
|
|
117
|
+
const base = current ?? new Date()
|
|
118
|
+
commit(withTime(base, t))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Dropdown control ------------------------------------------------------
|
|
122
|
+
function openPanel() {
|
|
123
|
+
if (!isInteractive || open) return
|
|
124
|
+
tab = dropDownDisplayMode() === 'time' ? 'time' : 'date'
|
|
125
|
+
open = true
|
|
126
|
+
}
|
|
127
|
+
function closePanel() { open = false }
|
|
128
|
+
function toggle() { (open ? closePanel : openPanel)() }
|
|
129
|
+
function setTab(t: DateTimeTab) { tab = t }
|
|
130
|
+
|
|
131
|
+
function onInputKeydown(e: KeyboardEvent) {
|
|
132
|
+
if (e.key === 'Enter') { e.preventDefault(); commitFromText(); closePanel(); config.onCommit?.(current) }
|
|
133
|
+
else if (e.key === 'Escape') { closePanel(); config.onCancel?.() }
|
|
134
|
+
else if (e.key === 'ArrowDown' && (e.altKey || !text)) { e.preventDefault(); openPanel() }
|
|
135
|
+
else if (spinButtons() && e.key === 'ArrowUp') { e.preventDefault(); bump(stepMinutes()) }
|
|
136
|
+
else if (spinButtons() && e.key === 'ArrowDown') { e.preventDefault(); bump(-stepMinutes()) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
// --- Reactive state -------------------------------------------------------
|
|
141
|
+
get current() { return current },
|
|
142
|
+
get text() { return text },
|
|
143
|
+
set text(v: string) { text = v },
|
|
144
|
+
get open() { return open },
|
|
145
|
+
get tab() { return tab },
|
|
146
|
+
get isInteractive() { return isInteractive },
|
|
147
|
+
get showDateTab() { return showDateTab },
|
|
148
|
+
get showTimeTab() { return showTimeTab },
|
|
149
|
+
|
|
150
|
+
// --- Actions --------------------------------------------------------------
|
|
151
|
+
commit,
|
|
152
|
+
commitFromText,
|
|
153
|
+
bump,
|
|
154
|
+
onCalendarChange,
|
|
155
|
+
onTimeChange,
|
|
156
|
+
openPanel,
|
|
157
|
+
closePanel,
|
|
158
|
+
toggle,
|
|
159
|
+
setTab,
|
|
160
|
+
onInputKeydown,
|
|
161
|
+
|
|
162
|
+
// --- Prop-getters ---------------------------------------------------------
|
|
163
|
+
/** The text field: masked value round-trips through the token engine. Spread
|
|
164
|
+
* alongside `bind:value={dtp.text}`. */
|
|
165
|
+
inputProps() {
|
|
166
|
+
return {
|
|
167
|
+
type: 'text' as const,
|
|
168
|
+
disabled: isDisabled(),
|
|
169
|
+
readonly: isReadonly(),
|
|
170
|
+
onblur: commitFromText,
|
|
171
|
+
onkeydown: onInputKeydown,
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
/** The calendar-toggle button. */
|
|
175
|
+
toggleProps() {
|
|
176
|
+
return {
|
|
177
|
+
type: 'button' as const,
|
|
178
|
+
'aria-label': 'Open picker',
|
|
179
|
+
'aria-haspopup': 'dialog' as const,
|
|
180
|
+
'aria-expanded': open,
|
|
181
|
+
disabled: !isInteractive,
|
|
182
|
+
tabindex: -1,
|
|
183
|
+
onclick: toggle,
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
/** The inline clear (x) button (nullable values). */
|
|
187
|
+
clearProps() {
|
|
188
|
+
return {
|
|
189
|
+
type: 'button' as const,
|
|
190
|
+
'aria-label': 'Clear',
|
|
191
|
+
onclick: () => commit(null),
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
/** A spin (increment/decrement) button that steps by `stepMinutes`. */
|
|
195
|
+
spinProps(dir: 1 | -1) {
|
|
196
|
+
return {
|
|
197
|
+
type: 'button' as const,
|
|
198
|
+
tabindex: -1,
|
|
199
|
+
'aria-label': dir === 1 ? 'Increment' : 'Decrement',
|
|
200
|
+
disabled: !isInteractive,
|
|
201
|
+
onclick: () => bump(dir * stepMinutes()),
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
/** A DATE / TIME tab button. */
|
|
205
|
+
tabProps(which: DateTimeTab) {
|
|
206
|
+
return {
|
|
207
|
+
type: 'button' as const,
|
|
208
|
+
role: 'tab' as const,
|
|
209
|
+
'aria-selected': tab === which,
|
|
210
|
+
onclick: () => setTab(which),
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type DateTimePicker = ReturnType<typeof createDateTimePicker>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createDropdownList - the HEADLESS core behind <SvDropDownList>: a single-select
|
|
3
|
+
* dropdown state machine (trigger open/close, roving active index over enabled
|
|
4
|
+
* options, full keyboard) exposed as **prop-getters** you spread onto YOUR OWN
|
|
5
|
+
* markup. No styles, no DOM, no portal/measurement - those stay in the styled
|
|
6
|
+
* component.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createDropdownList } from '@svgrid/grid'
|
|
11
|
+
* let value = $state<string | null>(null)
|
|
12
|
+
* const dd = createDropdownList({ options: () => options, value: () => value, onChange: (v) => (value = v) })
|
|
13
|
+
* </script>
|
|
14
|
+
* <button {...dd.triggerProps()}>{dd.selected?.label ?? 'Select'}</button>
|
|
15
|
+
* {#if dd.open}
|
|
16
|
+
* <ul {...dd.listboxProps()}>
|
|
17
|
+
* {#each options as opt, i (opt.value)}<li {...dd.optionProps(i)}>{opt.label}</li>{/each}
|
|
18
|
+
* </ul>
|
|
19
|
+
* {/if}
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import type { ListOption } from './list-option'
|
|
23
|
+
|
|
24
|
+
export type DropdownValue = string | number | null
|
|
25
|
+
|
|
26
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
27
|
+
export type DropdownListConfig = {
|
|
28
|
+
options: () => ReadonlyArray<ListOption>
|
|
29
|
+
value: () => DropdownValue
|
|
30
|
+
onChange?: (value: string | number) => void
|
|
31
|
+
disabled?: () => boolean
|
|
32
|
+
ariaLabel?: () => string | undefined
|
|
33
|
+
/** DOM focus hook provided by the renderer; the core never touches the DOM. */
|
|
34
|
+
focusTrigger?: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createDropdownList(config: DropdownListConfig) {
|
|
38
|
+
const opts = () => config.options()
|
|
39
|
+
const disabled = () => config.disabled?.() ?? false
|
|
40
|
+
|
|
41
|
+
let open = $state(false)
|
|
42
|
+
let active = $state(-1)
|
|
43
|
+
|
|
44
|
+
const selected = $derived(opts().find((o) => o.value === config.value()) ?? null)
|
|
45
|
+
const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
|
|
46
|
+
|
|
47
|
+
function openPanel() {
|
|
48
|
+
if (disabled() || open) return
|
|
49
|
+
open = true
|
|
50
|
+
active = Math.max(0, opts().findIndex((o) => o.value === config.value()))
|
|
51
|
+
}
|
|
52
|
+
function close() { open = false }
|
|
53
|
+
function toggle() { if (open) { close() } else { openPanel() } }
|
|
54
|
+
function pick(i: number) {
|
|
55
|
+
const o = opts()[i]
|
|
56
|
+
if (!o || o.disabled) return
|
|
57
|
+
config.onChange?.(o.value); close(); config.focusTrigger?.()
|
|
58
|
+
}
|
|
59
|
+
function setActive(i: number) { const o = opts()[i]; if (o && !o.disabled) active = i }
|
|
60
|
+
function move(d: number) {
|
|
61
|
+
const pos = enabledIdx.indexOf(active)
|
|
62
|
+
active = enabledIdx[(pos + d + enabledIdx.length) % enabledIdx.length] ?? active
|
|
63
|
+
}
|
|
64
|
+
function onKeydown(e: KeyboardEvent) {
|
|
65
|
+
if (disabled()) return
|
|
66
|
+
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openPanel(); return }
|
|
67
|
+
if (!open) return
|
|
68
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
|
|
69
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
|
|
70
|
+
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(active) }
|
|
71
|
+
else if (e.key === 'Escape') { e.preventDefault(); close(); config.focusTrigger?.() }
|
|
72
|
+
else if (e.key === 'Home') { active = enabledIdx[0] ?? 0 }
|
|
73
|
+
else if (e.key === 'End') { active = enabledIdx.at(-1) ?? 0 }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
/** Panel open state. */
|
|
78
|
+
get open() { return open },
|
|
79
|
+
/** Highlighted option index into `options`. */
|
|
80
|
+
get activeIndex() { return active },
|
|
81
|
+
/** The option matching the controlled value, if any. */
|
|
82
|
+
get selected() { return selected },
|
|
83
|
+
/** Controlled value. */
|
|
84
|
+
get value() { return config.value() },
|
|
85
|
+
isActive: (i: number) => i === active,
|
|
86
|
+
isSelected: (o: ListOption) => o.value === config.value(),
|
|
87
|
+
setActive,
|
|
88
|
+
openPanel,
|
|
89
|
+
close,
|
|
90
|
+
toggle,
|
|
91
|
+
pick,
|
|
92
|
+
move,
|
|
93
|
+
onKeydown,
|
|
94
|
+
/** Spread onto the trigger <button>. */
|
|
95
|
+
triggerProps: () => ({
|
|
96
|
+
type: 'button' as const,
|
|
97
|
+
'aria-haspopup': 'listbox' as const,
|
|
98
|
+
'aria-expanded': open,
|
|
99
|
+
'aria-label': config.ariaLabel?.(),
|
|
100
|
+
disabled: disabled(),
|
|
101
|
+
onclick: toggle,
|
|
102
|
+
onkeydown: onKeydown,
|
|
103
|
+
}),
|
|
104
|
+
/** Spread onto the listbox container. */
|
|
105
|
+
listboxProps: () => ({
|
|
106
|
+
role: 'listbox' as const,
|
|
107
|
+
tabindex: -1,
|
|
108
|
+
}),
|
|
109
|
+
/** Spread onto the option at `index` (index into `options`). */
|
|
110
|
+
optionProps: (index: number) => {
|
|
111
|
+
const o = opts()[index]
|
|
112
|
+
return {
|
|
113
|
+
role: 'option' as const,
|
|
114
|
+
tabindex: -1,
|
|
115
|
+
'aria-selected': o ? o.value === config.value() : false,
|
|
116
|
+
'aria-disabled': o?.disabled,
|
|
117
|
+
'data-idx': index,
|
|
118
|
+
onclick: () => pick(index),
|
|
119
|
+
onpointermove: () => { if (o && !o.disabled) active = index },
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type DropdownList = ReturnType<typeof createDropdownList>
|