@svgrid/grid 1.2.23 → 1.2.24
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,78 @@
|
|
|
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 = {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type TabsOrientation = 'horizontal' | 'vertical';
|
|
32
|
+
export type TabsActivation = 'automatic' | 'manual';
|
|
33
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
34
|
+
export type TabsConfig = {
|
|
35
|
+
tabs: () => ReadonlyArray<TabItem>;
|
|
36
|
+
value: () => string | undefined;
|
|
37
|
+
onChange?: (id: string) => void;
|
|
38
|
+
orientation?: () => TabsOrientation;
|
|
39
|
+
activation?: () => TabsActivation;
|
|
40
|
+
ariaLabel?: () => string | undefined;
|
|
41
|
+
};
|
|
42
|
+
export declare function createTabs(config: TabsConfig): {
|
|
43
|
+
/** Currently selected tab id (falls back to the first enabled tab). */
|
|
44
|
+
readonly activeId: string;
|
|
45
|
+
/** Tab id that should hold DOM focus after the latest keyboard move. */
|
|
46
|
+
readonly focusId: string;
|
|
47
|
+
/** Monotonic counter; changes only on keyboard navigation. */
|
|
48
|
+
readonly focusTick: number;
|
|
49
|
+
isActive: (tid: string) => boolean;
|
|
50
|
+
select: (tid: string) => void;
|
|
51
|
+
onKeydown: (e: KeyboardEvent) => void;
|
|
52
|
+
/** Spread onto the tablist container element. */
|
|
53
|
+
tablistProps: () => {
|
|
54
|
+
role: "tablist";
|
|
55
|
+
'aria-orientation': TabsOrientation;
|
|
56
|
+
'aria-label': string | undefined;
|
|
57
|
+
};
|
|
58
|
+
/** Spread onto the tab button for `tid`. */
|
|
59
|
+
tabProps: (tid: string) => {
|
|
60
|
+
role: "tab";
|
|
61
|
+
id: string;
|
|
62
|
+
'data-tab': string;
|
|
63
|
+
'aria-selected': boolean;
|
|
64
|
+
'aria-controls': string;
|
|
65
|
+
tabindex: number;
|
|
66
|
+
disabled: boolean | undefined;
|
|
67
|
+
onclick: () => void;
|
|
68
|
+
onkeydown: (e: KeyboardEvent) => void;
|
|
69
|
+
};
|
|
70
|
+
/** Spread onto the tabpanel element for `tid` (usually the active id). */
|
|
71
|
+
panelProps: (tid: string) => {
|
|
72
|
+
role: "tabpanel";
|
|
73
|
+
id: string;
|
|
74
|
+
'aria-labelledby': string;
|
|
75
|
+
tabindex: number;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
export type Tabs = ReturnType<typeof createTabs>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
let uid = 0;
|
|
2
|
+
export function createTabs(config) {
|
|
3
|
+
const id = `sv-tabs-${uid++}`;
|
|
4
|
+
const tabs = () => config.tabs();
|
|
5
|
+
const orientation = () => config.orientation?.() ?? 'horizontal';
|
|
6
|
+
const activation = () => config.activation?.() ?? 'automatic';
|
|
7
|
+
const enabled = $derived(tabs().filter((t) => !t.disabled));
|
|
8
|
+
const active = $derived(config.value() ?? tabs().find((t) => !t.disabled)?.id ?? tabs()[0]?.id ?? '');
|
|
9
|
+
// Roving focus target. `focusTick` bumps on every keyboard move so the styled
|
|
10
|
+
// renderer can move real DOM focus without stealing it on mount.
|
|
11
|
+
let focusId = $state('');
|
|
12
|
+
let focusTick = $state(0);
|
|
13
|
+
const requestFocus = (tid) => { focusId = tid; focusTick++; };
|
|
14
|
+
const tabId = (tid) => `${id}-tab-${tid}`;
|
|
15
|
+
const panelId = (tid) => `${id}-panel-${tid}`;
|
|
16
|
+
function select(tid) {
|
|
17
|
+
const t = tabs().find((x) => x.id === tid);
|
|
18
|
+
if (!t || t.disabled)
|
|
19
|
+
return;
|
|
20
|
+
config.onChange?.(tid);
|
|
21
|
+
}
|
|
22
|
+
function onKeydown(e) {
|
|
23
|
+
const list = enabled;
|
|
24
|
+
if (!list.length)
|
|
25
|
+
return;
|
|
26
|
+
const idx = list.findIndex((t) => t.id === active);
|
|
27
|
+
const fwd = orientation() === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
28
|
+
const back = orientation() === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
29
|
+
let next = null;
|
|
30
|
+
if (e.key === fwd)
|
|
31
|
+
next = list[(idx + 1) % list.length]?.id ?? null;
|
|
32
|
+
else if (e.key === back)
|
|
33
|
+
next = list[(idx - 1 + list.length) % list.length]?.id ?? null;
|
|
34
|
+
else if (e.key === 'Home')
|
|
35
|
+
next = list[0]?.id ?? null;
|
|
36
|
+
else if (e.key === 'End')
|
|
37
|
+
next = list.at(-1)?.id ?? null;
|
|
38
|
+
else if ((e.key === 'Enter' || e.key === ' ') && activation() === 'manual') {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
select(active);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
return;
|
|
45
|
+
if (next) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
requestFocus(next);
|
|
48
|
+
if (activation() === 'automatic')
|
|
49
|
+
select(next);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
/** Currently selected tab id (falls back to the first enabled tab). */
|
|
54
|
+
get activeId() { return active; },
|
|
55
|
+
/** Tab id that should hold DOM focus after the latest keyboard move. */
|
|
56
|
+
get focusId() { return focusId; },
|
|
57
|
+
/** Monotonic counter; changes only on keyboard navigation. */
|
|
58
|
+
get focusTick() { return focusTick; },
|
|
59
|
+
isActive: (tid) => tid === active,
|
|
60
|
+
select,
|
|
61
|
+
onKeydown,
|
|
62
|
+
/** Spread onto the tablist container element. */
|
|
63
|
+
tablistProps: () => ({
|
|
64
|
+
role: 'tablist',
|
|
65
|
+
'aria-orientation': orientation(),
|
|
66
|
+
'aria-label': config.ariaLabel?.(),
|
|
67
|
+
}),
|
|
68
|
+
/** Spread onto the tab button for `tid`. */
|
|
69
|
+
tabProps: (tid) => {
|
|
70
|
+
const t = tabs().find((x) => x.id === tid);
|
|
71
|
+
const isActive = tid === active;
|
|
72
|
+
return {
|
|
73
|
+
role: 'tab',
|
|
74
|
+
id: tabId(tid),
|
|
75
|
+
'data-tab': tid,
|
|
76
|
+
'aria-selected': isActive,
|
|
77
|
+
'aria-controls': panelId(tid),
|
|
78
|
+
tabindex: isActive ? 0 : -1,
|
|
79
|
+
disabled: t?.disabled,
|
|
80
|
+
onclick: () => select(tid),
|
|
81
|
+
onkeydown: onKeydown,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
/** Spread onto the tabpanel element for `tid` (usually the active id). */
|
|
85
|
+
panelProps: (tid) => ({
|
|
86
|
+
role: 'tabpanel',
|
|
87
|
+
id: panelId(tid),
|
|
88
|
+
'aria-labelledby': tabId(tid),
|
|
89
|
+
tabindex: 0,
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
22
|
+
export type TagsInputConfig = {
|
|
23
|
+
value: () => string[];
|
|
24
|
+
onChange?: (tags: string[]) => void;
|
|
25
|
+
disabled?: () => boolean;
|
|
26
|
+
/** Reject duplicate tags. Default true. */
|
|
27
|
+
unique?: () => boolean;
|
|
28
|
+
max?: () => number;
|
|
29
|
+
ariaLabel?: () => string | undefined;
|
|
30
|
+
};
|
|
31
|
+
export declare function createTagsInput(config: TagsInputConfig): {
|
|
32
|
+
/** Current tags (controlled value). */
|
|
33
|
+
readonly tags: string[];
|
|
34
|
+
/** Current draft text in the input. */
|
|
35
|
+
readonly draft: string;
|
|
36
|
+
add: (raw: string) => void;
|
|
37
|
+
removeAt: (i: number) => void;
|
|
38
|
+
setDraft: (v: string) => void;
|
|
39
|
+
/** Spread onto the container element. */
|
|
40
|
+
rootProps: () => {
|
|
41
|
+
role: "group";
|
|
42
|
+
'aria-label': string | undefined;
|
|
43
|
+
};
|
|
44
|
+
/** Spread onto the text <input>. */
|
|
45
|
+
inputProps: () => {
|
|
46
|
+
type: "text";
|
|
47
|
+
value: string;
|
|
48
|
+
disabled: boolean;
|
|
49
|
+
'aria-label': string;
|
|
50
|
+
oninput: (e: Event) => void;
|
|
51
|
+
onkeydown: (e: KeyboardEvent) => void;
|
|
52
|
+
onblur: () => void;
|
|
53
|
+
};
|
|
54
|
+
/** Spread onto the chip wrapper at `index`. */
|
|
55
|
+
tagProps: (index: number) => {
|
|
56
|
+
'data-idx': number;
|
|
57
|
+
};
|
|
58
|
+
/** Spread onto a chip's remove <button> at `index`. */
|
|
59
|
+
removeProps: (index: number) => {
|
|
60
|
+
type: "button";
|
|
61
|
+
'aria-label': string;
|
|
62
|
+
onclick: () => void;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export type TagsInput = ReturnType<typeof createTagsInput>;
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
export function createTagsInput(config) {
|
|
22
|
+
const disabled = () => config.disabled?.() ?? false;
|
|
23
|
+
const unique = () => config.unique?.() ?? true;
|
|
24
|
+
const max = () => config.max?.() ?? Infinity;
|
|
25
|
+
const tags = () => config.value();
|
|
26
|
+
let draft = $state('');
|
|
27
|
+
function add(raw) {
|
|
28
|
+
const tag = raw.trim();
|
|
29
|
+
if (!tag || disabled())
|
|
30
|
+
return;
|
|
31
|
+
if (tags().length >= max())
|
|
32
|
+
return;
|
|
33
|
+
if (unique() && tags().includes(tag)) {
|
|
34
|
+
draft = '';
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
config.onChange?.([...tags(), tag]);
|
|
38
|
+
draft = '';
|
|
39
|
+
}
|
|
40
|
+
function removeAt(i) {
|
|
41
|
+
if (disabled())
|
|
42
|
+
return;
|
|
43
|
+
config.onChange?.(tags().filter((_, idx) => idx !== i));
|
|
44
|
+
}
|
|
45
|
+
function setDraft(v) { draft = v; }
|
|
46
|
+
function onInput(e) { draft = e.currentTarget.value; }
|
|
47
|
+
function onKeydown(e) {
|
|
48
|
+
if (disabled())
|
|
49
|
+
return;
|
|
50
|
+
if (e.key === 'Enter' || e.key === ',') {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
add(draft);
|
|
53
|
+
}
|
|
54
|
+
else if (e.key === 'Backspace' && draft === '' && tags().length) {
|
|
55
|
+
removeAt(tags().length - 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function onBlur() { add(draft); }
|
|
59
|
+
return {
|
|
60
|
+
/** Current tags (controlled value). */
|
|
61
|
+
get tags() { return tags(); },
|
|
62
|
+
/** Current draft text in the input. */
|
|
63
|
+
get draft() { return draft; },
|
|
64
|
+
add,
|
|
65
|
+
removeAt,
|
|
66
|
+
setDraft,
|
|
67
|
+
/** Spread onto the container element. */
|
|
68
|
+
rootProps: () => ({
|
|
69
|
+
role: 'group',
|
|
70
|
+
'aria-label': config.ariaLabel?.(),
|
|
71
|
+
}),
|
|
72
|
+
/** Spread onto the text <input>. */
|
|
73
|
+
inputProps: () => ({
|
|
74
|
+
type: 'text',
|
|
75
|
+
value: draft,
|
|
76
|
+
disabled: disabled(),
|
|
77
|
+
'aria-label': config.ariaLabel?.() ?? 'Add tag',
|
|
78
|
+
oninput: onInput,
|
|
79
|
+
onkeydown: onKeydown,
|
|
80
|
+
onblur: onBlur,
|
|
81
|
+
}),
|
|
82
|
+
/** Spread onto the chip wrapper at `index`. */
|
|
83
|
+
tagProps: (index) => ({
|
|
84
|
+
'data-idx': index,
|
|
85
|
+
}),
|
|
86
|
+
/** Spread onto a chip's remove <button> at `index`. */
|
|
87
|
+
removeProps: (index) => ({
|
|
88
|
+
type: 'button',
|
|
89
|
+
'aria-label': `Remove ${tags()[index] ?? ''}`,
|
|
90
|
+
onclick: () => removeAt(index),
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
export type TimeSelection = 'hour' | 'minute';
|
|
22
|
+
export type TimeFormat = '12-hour' | '24-hour';
|
|
23
|
+
/** A clock-face number and its computed position on the dial. */
|
|
24
|
+
export type DialTick = {
|
|
25
|
+
label: string;
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
value: number;
|
|
29
|
+
ring: 'outer' | 'inner';
|
|
30
|
+
};
|
|
31
|
+
/** Reactive inputs are getters; callbacks are closures. */
|
|
32
|
+
export type TimePickerConfig = {
|
|
33
|
+
/** Date, "HH:MM[:SS]" string, or epoch ms. */
|
|
34
|
+
value: () => TimeValue;
|
|
35
|
+
/** Fires with a Date (today's date carrying the picked time). */
|
|
36
|
+
onChange?: (value: Date) => void;
|
|
37
|
+
format?: () => TimeFormat;
|
|
38
|
+
minuteInterval?: () => number;
|
|
39
|
+
/** After picking an hour, jump the dial to minutes. Default true. */
|
|
40
|
+
autoSwitchToMinutes?: () => boolean;
|
|
41
|
+
disabled?: () => boolean;
|
|
42
|
+
readonly?: () => boolean;
|
|
43
|
+
/** Which dial opens first. */
|
|
44
|
+
selection?: () => TimeSelection;
|
|
45
|
+
};
|
|
46
|
+
/** Parse a time value to { h, m }, falling back to "now" (pure). */
|
|
47
|
+
export declare function parseTimeValue(v: TimeValue): {
|
|
48
|
+
h: number;
|
|
49
|
+
m: number;
|
|
50
|
+
};
|
|
51
|
+
export declare function createTimePicker(config: TimePickerConfig): {
|
|
52
|
+
SIZE: number;
|
|
53
|
+
C: number;
|
|
54
|
+
readonly hours: number;
|
|
55
|
+
readonly minutes: number;
|
|
56
|
+
readonly selection: TimeSelection;
|
|
57
|
+
readonly is12: boolean;
|
|
58
|
+
readonly isInteractive: boolean;
|
|
59
|
+
readonly displayHour: number;
|
|
60
|
+
readonly isPm: boolean;
|
|
61
|
+
readonly mm: string;
|
|
62
|
+
readonly hourTicks: DialTick[];
|
|
63
|
+
readonly minuteTicks: DialTick[];
|
|
64
|
+
readonly handEnd: {
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
};
|
|
68
|
+
isActiveHour: (t: DialTick) => boolean;
|
|
69
|
+
setSelection: (s: TimeSelection) => void;
|
|
70
|
+
setMinute: (m: number) => void;
|
|
71
|
+
setAmPm: (pm: boolean) => void;
|
|
72
|
+
pointerSelect: (angle: number, dist: number) => void;
|
|
73
|
+
endPointer: () => void;
|
|
74
|
+
onKeydown: (e: KeyboardEvent) => void;
|
|
75
|
+
now: () => void;
|
|
76
|
+
/** The hour / minute segment button in the header. */
|
|
77
|
+
segProps(which: TimeSelection): {
|
|
78
|
+
type: "button";
|
|
79
|
+
disabled: boolean;
|
|
80
|
+
onclick: () => void;
|
|
81
|
+
};
|
|
82
|
+
/** An AM / PM toggle button (12-hour mode). */
|
|
83
|
+
ampmProps(pm: boolean): {
|
|
84
|
+
type: "button";
|
|
85
|
+
disabled: boolean;
|
|
86
|
+
onclick: () => void;
|
|
87
|
+
};
|
|
88
|
+
/** The clock dial: ARIA slider semantics + keyboard. Pointer capture stays in
|
|
89
|
+
* the component (it needs the DOM element). */
|
|
90
|
+
dialProps(): {
|
|
91
|
+
role: "slider";
|
|
92
|
+
tabindex: number;
|
|
93
|
+
'aria-label': string;
|
|
94
|
+
'aria-valuenow': number;
|
|
95
|
+
'aria-valuemin': number;
|
|
96
|
+
'aria-valuemax': number;
|
|
97
|
+
onkeydown: (e: KeyboardEvent) => void;
|
|
98
|
+
};
|
|
99
|
+
/** The Now footer button. */
|
|
100
|
+
nowProps(): {
|
|
101
|
+
type: "button";
|
|
102
|
+
disabled: boolean;
|
|
103
|
+
onclick: () => void;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
export type TimePicker = ReturnType<typeof createTimePicker>;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// --- Dial geometry (pure) -----------------------------------------------------
|
|
2
|
+
const SIZE = 240;
|
|
3
|
+
const C = SIZE / 2;
|
|
4
|
+
const OUTER = C - 22;
|
|
5
|
+
const INNER = C - 58;
|
|
6
|
+
function pointAt(angleDeg, r) {
|
|
7
|
+
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
|
8
|
+
return { x: C + r * Math.cos(rad), y: C + r * Math.sin(rad) };
|
|
9
|
+
}
|
|
10
|
+
/** Parse a time value to { h, m }, falling back to "now" (pure). */
|
|
11
|
+
export function parseTimeValue(v) {
|
|
12
|
+
if (v == null) {
|
|
13
|
+
const n = new Date();
|
|
14
|
+
return { h: n.getHours(), m: n.getMinutes() };
|
|
15
|
+
}
|
|
16
|
+
if (typeof v === 'string') {
|
|
17
|
+
const mt = v.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/);
|
|
18
|
+
if (mt)
|
|
19
|
+
return { h: Math.min(23, +mt[1]), m: Math.min(59, +mt[2]) };
|
|
20
|
+
const d = new Date(v);
|
|
21
|
+
if (!isNaN(d.getTime()))
|
|
22
|
+
return { h: d.getHours(), m: d.getMinutes() };
|
|
23
|
+
const n = new Date();
|
|
24
|
+
return { h: n.getHours(), m: n.getMinutes() };
|
|
25
|
+
}
|
|
26
|
+
const d = new Date(v);
|
|
27
|
+
return { h: d.getHours(), m: d.getMinutes() };
|
|
28
|
+
}
|
|
29
|
+
export function createTimePicker(config) {
|
|
30
|
+
const format = () => config.format?.() ?? '24-hour';
|
|
31
|
+
const minuteInterval = () => config.minuteInterval?.() ?? 1;
|
|
32
|
+
const autoSwitchToMinutes = () => config.autoSwitchToMinutes?.() ?? true;
|
|
33
|
+
const isDisabled = () => config.disabled?.() ?? false;
|
|
34
|
+
const isReadonly = () => config.readonly?.() ?? false;
|
|
35
|
+
const is12 = $derived(format() === '12-hour');
|
|
36
|
+
const isInteractive = $derived(!isDisabled() && !isReadonly());
|
|
37
|
+
let hours = $state(0);
|
|
38
|
+
let minutes = $state(0);
|
|
39
|
+
let sel = $state('hour');
|
|
40
|
+
let seeded = false;
|
|
41
|
+
let lastKey = '';
|
|
42
|
+
$effect(() => {
|
|
43
|
+
const { h, m } = parseTimeValue(config.value());
|
|
44
|
+
const key = `${h}:${m}`;
|
|
45
|
+
if (key !== lastKey) {
|
|
46
|
+
lastKey = key;
|
|
47
|
+
hours = h;
|
|
48
|
+
minutes = m;
|
|
49
|
+
if (!seeded) {
|
|
50
|
+
seeded = true;
|
|
51
|
+
sel = config.selection?.() ?? 'hour';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
const displayHour = $derived(is12 ? (hours % 12 === 0 ? 12 : hours % 12) : hours);
|
|
56
|
+
const isPm = $derived(hours >= 12);
|
|
57
|
+
const mm = $derived(String(minutes).padStart(2, '0'));
|
|
58
|
+
const hourTicks = $derived.by(() => {
|
|
59
|
+
const ticks = [];
|
|
60
|
+
if (is12) {
|
|
61
|
+
for (let i = 0; i < 12; i++) {
|
|
62
|
+
const h = i === 0 ? 12 : i;
|
|
63
|
+
const p = pointAt(i * 30, OUTER);
|
|
64
|
+
ticks.push({ label: String(h), x: p.x, y: p.y, value: h, ring: 'outer' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
for (let i = 0; i < 12; i++) {
|
|
69
|
+
const outer = pointAt(i * 30, OUTER); // 12..23
|
|
70
|
+
const inner = pointAt(i * 30, INNER); // 00..11
|
|
71
|
+
const ho = i + 12;
|
|
72
|
+
const hi = i;
|
|
73
|
+
ticks.push({ label: String(ho).padStart(2, '0'), x: outer.x, y: outer.y, value: ho, ring: 'outer' });
|
|
74
|
+
ticks.push({ label: hi === 0 ? '00' : String(hi).padStart(2, '0'), x: inner.x, y: inner.y, value: hi, ring: 'inner' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return ticks;
|
|
78
|
+
});
|
|
79
|
+
const minuteTicks = $derived.by(() => Array.from({ length: 12 }, (_, i) => {
|
|
80
|
+
const v = i * 5;
|
|
81
|
+
const p = pointAt(i * 30, OUTER);
|
|
82
|
+
return { label: String(v).padStart(2, '0'), x: p.x, y: p.y, value: v, ring: 'outer' };
|
|
83
|
+
}));
|
|
84
|
+
// Hand endpoint for the current selection.
|
|
85
|
+
const handAngle = $derived(sel === 'hour' ? (is12 ? (displayHour % 12) * 30 : (hours % 12) * 30) : minutes * 6);
|
|
86
|
+
const handRadius = $derived(sel === 'hour' && !is12 && hours % 12 === hours && hours < 12 ? INNER : OUTER);
|
|
87
|
+
const handEnd = $derived(pointAt(handAngle, handRadius));
|
|
88
|
+
// --- Emit + mutators -------------------------------------------------------
|
|
89
|
+
function emit() {
|
|
90
|
+
const d = new Date();
|
|
91
|
+
d.setHours(hours, minutes, 0, 0);
|
|
92
|
+
config.onChange?.(d);
|
|
93
|
+
}
|
|
94
|
+
const snap = (m) => {
|
|
95
|
+
const step = Math.max(1, Math.floor(minuteInterval()));
|
|
96
|
+
return (Math.round(m / step) * step) % 60;
|
|
97
|
+
};
|
|
98
|
+
function setSelection(s) { sel = s; }
|
|
99
|
+
function setMinute(m) {
|
|
100
|
+
minutes = snap(m);
|
|
101
|
+
lastKey = `${hours}:${minutes}`;
|
|
102
|
+
emit();
|
|
103
|
+
}
|
|
104
|
+
function setAmPm(pm) {
|
|
105
|
+
if (pm === isPm)
|
|
106
|
+
return;
|
|
107
|
+
hours = pm ? (hours % 12) + 12 : hours % 12;
|
|
108
|
+
lastKey = `${hours}:${minutes}`;
|
|
109
|
+
emit();
|
|
110
|
+
}
|
|
111
|
+
/** Apply a dial angle (0..360, 12 o'clock = 0) + distance from center. The
|
|
112
|
+
* styled component converts a pointer event into these via the DOM rect. */
|
|
113
|
+
function pointerSelect(angle, dist) {
|
|
114
|
+
if (!isInteractive)
|
|
115
|
+
return;
|
|
116
|
+
if (sel === 'hour') {
|
|
117
|
+
const idx = Math.round(angle / 30) % 12;
|
|
118
|
+
if (is12) {
|
|
119
|
+
const h = idx === 0 ? 12 : idx;
|
|
120
|
+
const base = h % 12;
|
|
121
|
+
hours = isPm ? base + 12 : base;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const inner = dist < (OUTER + INNER) / 2;
|
|
125
|
+
hours = inner ? idx : idx + 12;
|
|
126
|
+
}
|
|
127
|
+
lastKey = `${hours}:${minutes}`;
|
|
128
|
+
emit();
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const m = Math.round(angle / 6) % 60;
|
|
132
|
+
setMinute(m);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Called on pointer-up: auto-advance hour -> minute when configured. */
|
|
136
|
+
function endPointer() {
|
|
137
|
+
if (sel === 'hour' && autoSwitchToMinutes())
|
|
138
|
+
sel = 'minute';
|
|
139
|
+
}
|
|
140
|
+
function onKeydown(e) {
|
|
141
|
+
if (!isInteractive)
|
|
142
|
+
return;
|
|
143
|
+
const step = sel === 'minute' ? Math.max(1, Math.floor(minuteInterval())) : 1;
|
|
144
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
if (sel === 'hour') {
|
|
147
|
+
hours = (hours + 1) % 24;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
minutes = (minutes + step) % 60;
|
|
151
|
+
}
|
|
152
|
+
lastKey = `${hours}:${minutes}`;
|
|
153
|
+
emit();
|
|
154
|
+
}
|
|
155
|
+
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
if (sel === 'hour') {
|
|
158
|
+
hours = (hours + 23) % 24;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
minutes = (minutes - step + 60) % 60;
|
|
162
|
+
}
|
|
163
|
+
lastKey = `${hours}:${minutes}`;
|
|
164
|
+
emit();
|
|
165
|
+
}
|
|
166
|
+
else if (e.key === 'Tab' && !e.shiftKey && sel === 'hour') {
|
|
167
|
+
sel = 'minute';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function now() {
|
|
171
|
+
const n = new Date();
|
|
172
|
+
hours = n.getHours();
|
|
173
|
+
minutes = n.getMinutes();
|
|
174
|
+
lastKey = `${hours}:${minutes}`;
|
|
175
|
+
emit();
|
|
176
|
+
}
|
|
177
|
+
function isActiveHour(t) {
|
|
178
|
+
if (is12)
|
|
179
|
+
return t.value === displayHour;
|
|
180
|
+
return t.value === hours;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
// Dial geometry constants (the component needs these to map a pointer event
|
|
184
|
+
// to an angle without re-deriving the layout).
|
|
185
|
+
SIZE,
|
|
186
|
+
C,
|
|
187
|
+
// --- Reactive state -------------------------------------------------------
|
|
188
|
+
get hours() { return hours; },
|
|
189
|
+
get minutes() { return minutes; },
|
|
190
|
+
get selection() { return sel; },
|
|
191
|
+
get is12() { return is12; },
|
|
192
|
+
get isInteractive() { return isInteractive; },
|
|
193
|
+
get displayHour() { return displayHour; },
|
|
194
|
+
get isPm() { return isPm; },
|
|
195
|
+
get mm() { return mm; },
|
|
196
|
+
get hourTicks() { return hourTicks; },
|
|
197
|
+
get minuteTicks() { return minuteTicks; },
|
|
198
|
+
get handEnd() { return handEnd; },
|
|
199
|
+
// --- Derived helpers ------------------------------------------------------
|
|
200
|
+
isActiveHour,
|
|
201
|
+
// --- Actions --------------------------------------------------------------
|
|
202
|
+
setSelection,
|
|
203
|
+
setMinute,
|
|
204
|
+
setAmPm,
|
|
205
|
+
pointerSelect,
|
|
206
|
+
endPointer,
|
|
207
|
+
onKeydown,
|
|
208
|
+
now,
|
|
209
|
+
// --- Prop-getters ---------------------------------------------------------
|
|
210
|
+
/** The hour / minute segment button in the header. */
|
|
211
|
+
segProps(which) {
|
|
212
|
+
return {
|
|
213
|
+
type: 'button',
|
|
214
|
+
disabled: isDisabled(),
|
|
215
|
+
onclick: () => setSelection(which),
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
/** An AM / PM toggle button (12-hour mode). */
|
|
219
|
+
ampmProps(pm) {
|
|
220
|
+
return {
|
|
221
|
+
type: 'button',
|
|
222
|
+
disabled: isDisabled(),
|
|
223
|
+
onclick: () => setAmPm(pm),
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
/** The clock dial: ARIA slider semantics + keyboard. Pointer capture stays in
|
|
227
|
+
* the component (it needs the DOM element). */
|
|
228
|
+
dialProps() {
|
|
229
|
+
return {
|
|
230
|
+
role: 'slider',
|
|
231
|
+
tabindex: isDisabled() ? -1 : 0,
|
|
232
|
+
'aria-label': sel === 'hour' ? 'Hour' : 'Minute',
|
|
233
|
+
'aria-valuenow': sel === 'hour' ? hours : minutes,
|
|
234
|
+
'aria-valuemin': 0,
|
|
235
|
+
'aria-valuemax': sel === 'hour' ? 23 : 59,
|
|
236
|
+
onkeydown: onKeydown,
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
/** The Now footer button. */
|
|
240
|
+
nowProps() {
|
|
241
|
+
return {
|
|
242
|
+
type: 'button',
|
|
243
|
+
disabled: isDisabled(),
|
|
244
|
+
onclick: now,
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|