@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createGauge - the HEADLESS core behind <SvGauge>. A gauge is a display control,
|
|
3
|
+
* so this core is light: value in, computed arc/needle geometry out, plus a
|
|
4
|
+
* `rootProps()` with `role="meter"` + `aria-valuenow/min/max`. The styled
|
|
5
|
+
* <SvGauge> (or your own SVG) reads the geometry and draws it.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createGauge } from '@svgrid/grid'
|
|
10
|
+
* const g = createGauge({ value: () => value })
|
|
11
|
+
* </script>
|
|
12
|
+
* <div {...g.rootProps()}>
|
|
13
|
+
* <svg viewBox={`0 0 ${g.cx * 2} ${g.cy * 2}`}>
|
|
14
|
+
* <path d={g.arcPath(0, g.clamped, g.r)} />
|
|
15
|
+
* </svg>
|
|
16
|
+
* </div>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export type GaugePoint = { x: number; y: number }
|
|
20
|
+
|
|
21
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
22
|
+
export type GaugeConfig = {
|
|
23
|
+
value: () => number
|
|
24
|
+
min?: () => number
|
|
25
|
+
max?: () => number
|
|
26
|
+
/** Sweep angle in degrees (default 270, a classic dashboard gauge). */
|
|
27
|
+
sweep?: () => number
|
|
28
|
+
size?: () => number
|
|
29
|
+
thickness?: () => number
|
|
30
|
+
ariaLabel?: () => string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createGauge(config: GaugeConfig) {
|
|
34
|
+
const min = () => config.min?.() ?? 0
|
|
35
|
+
const max = () => config.max?.() ?? 100
|
|
36
|
+
const sweep = () => config.sweep?.() ?? 270
|
|
37
|
+
const size = () => config.size?.() ?? 180
|
|
38
|
+
const thickness = () => config.thickness?.() ?? 14
|
|
39
|
+
|
|
40
|
+
const clamped = $derived(Math.min(max(), Math.max(min(), config.value())))
|
|
41
|
+
const startAngle = $derived(90 + (360 - sweep()) / 2) // symmetric around the bottom
|
|
42
|
+
const cx = $derived(size() / 2)
|
|
43
|
+
const cy = $derived(size() / 2)
|
|
44
|
+
const r = $derived(size() / 2 - thickness() / 2 - 2)
|
|
45
|
+
|
|
46
|
+
const frac = (v: number) => (v - min()) / (max() - min())
|
|
47
|
+
const angleOf = (v: number) => startAngle + frac(v) * sweep()
|
|
48
|
+
function polar(angleDeg: number, radius: number): GaugePoint {
|
|
49
|
+
const a = (angleDeg * Math.PI) / 180
|
|
50
|
+
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }
|
|
51
|
+
}
|
|
52
|
+
function arcPath(v0: number, v1: number, radius: number): string {
|
|
53
|
+
const a0 = angleOf(v0)
|
|
54
|
+
const a1 = angleOf(v1)
|
|
55
|
+
const p0 = polar(a0, radius)
|
|
56
|
+
const p1 = polar(a1, radius)
|
|
57
|
+
const large = a1 - a0 > 180 ? 1 : 0
|
|
58
|
+
return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${large} 1 ${p1.x} ${p1.y}`
|
|
59
|
+
}
|
|
60
|
+
const needleEnd = $derived(polar(angleOf(clamped), r - thickness() / 2))
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
/** Value clamped into [min, max]. */
|
|
64
|
+
get clamped() { return clamped },
|
|
65
|
+
get startAngle() { return startAngle },
|
|
66
|
+
get cx() { return cx },
|
|
67
|
+
get cy() { return cy },
|
|
68
|
+
get r() { return r },
|
|
69
|
+
/** Needle tip point (for `<line x2 y2>`). */
|
|
70
|
+
get needleEnd() { return needleEnd },
|
|
71
|
+
frac,
|
|
72
|
+
angleOf,
|
|
73
|
+
polar,
|
|
74
|
+
arcPath,
|
|
75
|
+
/** Spread onto the gauge root element. */
|
|
76
|
+
rootProps: () => ({
|
|
77
|
+
role: 'meter' as const,
|
|
78
|
+
'aria-valuenow': clamped,
|
|
79
|
+
'aria-valuemin': min(),
|
|
80
|
+
'aria-valuemax': max(),
|
|
81
|
+
'aria-label': config.ariaLabel?.() ?? 'Gauge',
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type Gauge = ReturnType<typeof createGauge>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createListbox - the HEADLESS core behind <SvListBox>, in the same spirit as
|
|
3
|
+
* `createSvGrid` for the grid: a runes-based state machine (roving active index,
|
|
4
|
+
* single/multi selection, full keyboard) with **prop-getters** you spread onto
|
|
5
|
+
* YOUR OWN markup. No styles, no DOM assumptions - render it however you like.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createListbox } from '@svgrid/grid'
|
|
10
|
+
* let value = $state<string | null>(null)
|
|
11
|
+
* const lb = createListbox({
|
|
12
|
+
* options: () => options, value: () => value,
|
|
13
|
+
* onChange: (v) => (value = v),
|
|
14
|
+
* })
|
|
15
|
+
* </script>
|
|
16
|
+
* <ul {...lb.rootProps()}>
|
|
17
|
+
* {#each options as opt, i}
|
|
18
|
+
* <li {...lb.optionProps(i)}>{opt.label}</li>
|
|
19
|
+
* {/each}
|
|
20
|
+
* </ul>
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The styled <SvListBox> is just one renderer over this core.
|
|
24
|
+
*/
|
|
25
|
+
import type { ListOption } from './list-option'
|
|
26
|
+
|
|
27
|
+
export type ListboxValue = string | number | Array<string | number> | null
|
|
28
|
+
|
|
29
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes
|
|
30
|
+
* (the same controlled pattern Svelte headless libraries use). */
|
|
31
|
+
export type ListboxConfig = {
|
|
32
|
+
options: () => ReadonlyArray<ListOption>
|
|
33
|
+
value: () => ListboxValue
|
|
34
|
+
onChange?: (value: string | number | Array<string | number>) => void
|
|
35
|
+
multiple?: () => boolean
|
|
36
|
+
disabled?: () => boolean
|
|
37
|
+
ariaLabel?: () => string | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type OptionProps = {
|
|
41
|
+
role: 'option'
|
|
42
|
+
id: string
|
|
43
|
+
'aria-selected': boolean
|
|
44
|
+
'aria-disabled': boolean | undefined
|
|
45
|
+
'data-idx': number
|
|
46
|
+
'data-active': '' | undefined
|
|
47
|
+
'data-selected': '' | undefined
|
|
48
|
+
onclick: () => void
|
|
49
|
+
onpointermove: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ListboxRootProps = {
|
|
53
|
+
role: 'listbox'
|
|
54
|
+
'aria-multiselectable': boolean
|
|
55
|
+
'aria-label': string | undefined
|
|
56
|
+
'aria-disabled': boolean
|
|
57
|
+
'aria-activedescendant': string | undefined
|
|
58
|
+
tabindex: number
|
|
59
|
+
onkeydown: (e: KeyboardEvent) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Listbox = {
|
|
63
|
+
/** Currently highlighted option index (roving focus). */
|
|
64
|
+
readonly activeIndex: number
|
|
65
|
+
/** Selected values as an array (single mode -> 0 or 1 entry). */
|
|
66
|
+
readonly selectedValues: Array<string | number>
|
|
67
|
+
isSelected: (value: string | number) => boolean
|
|
68
|
+
isActive: (index: number) => boolean
|
|
69
|
+
setActive: (index: number) => void
|
|
70
|
+
/** Toggle (multi) / set (single) the option at `index`. */
|
|
71
|
+
pick: (index: number) => void
|
|
72
|
+
/** Move the active highlight by `delta`, wrapping over enabled options. */
|
|
73
|
+
move: (delta: number) => void
|
|
74
|
+
first: () => void
|
|
75
|
+
last: () => void
|
|
76
|
+
onKeydown: (e: KeyboardEvent) => void
|
|
77
|
+
/** Spread onto the list container element. */
|
|
78
|
+
rootProps: () => ListboxRootProps
|
|
79
|
+
/** Spread onto the option element at `index`. */
|
|
80
|
+
optionProps: (index: number) => OptionProps
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let uid = 0
|
|
84
|
+
|
|
85
|
+
/** Normalize a listbox value to an array (pure). */
|
|
86
|
+
export function toSelectedArray(value: ListboxValue, multiple: boolean): Array<string | number> {
|
|
87
|
+
if (multiple) return Array.isArray(value) ? [...value] : value == null ? [] : [value]
|
|
88
|
+
return value == null ? [] : [value as string | number]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createListbox(config: ListboxConfig): Listbox {
|
|
92
|
+
const id = `sv-lb-${uid++}`
|
|
93
|
+
const opts = () => config.options()
|
|
94
|
+
const multiple = () => config.multiple?.() ?? false
|
|
95
|
+
const disabled = () => config.disabled?.() ?? false
|
|
96
|
+
|
|
97
|
+
let active = $state(0)
|
|
98
|
+
const selected = $derived(toSelectedArray(config.value(), multiple()))
|
|
99
|
+
const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
|
|
100
|
+
|
|
101
|
+
// Keep the active index on an enabled option as options change.
|
|
102
|
+
$effect(() => {
|
|
103
|
+
const o = opts()[active]
|
|
104
|
+
if (!o || o.disabled) active = enabledIdx[0] ?? 0
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const isSelected = (value: string | number) => selected.includes(value)
|
|
108
|
+
const isActive = (index: number) => index === active
|
|
109
|
+
const optionId = (index: number) => `${id}-opt-${index}`
|
|
110
|
+
|
|
111
|
+
function setActive(index: number) {
|
|
112
|
+
const o = opts()[index]
|
|
113
|
+
if (o && !o.disabled) active = index
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pick(index: number) {
|
|
117
|
+
const o = opts()[index]
|
|
118
|
+
if (!o || o.disabled || disabled()) return
|
|
119
|
+
active = index
|
|
120
|
+
if (multiple()) {
|
|
121
|
+
const set = new Set(selected)
|
|
122
|
+
set.has(o.value) ? set.delete(o.value) : set.add(o.value)
|
|
123
|
+
config.onChange?.([...set])
|
|
124
|
+
} else {
|
|
125
|
+
config.onChange?.(o.value)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function move(delta: number) {
|
|
130
|
+
const list = enabledIdx
|
|
131
|
+
if (!list.length) return
|
|
132
|
+
const pos = list.indexOf(active)
|
|
133
|
+
const next = list[(pos + delta + list.length) % list.length]
|
|
134
|
+
if (next != null) active = next
|
|
135
|
+
}
|
|
136
|
+
const first = () => { active = enabledIdx[0] ?? 0 }
|
|
137
|
+
const last = () => { active = enabledIdx.at(-1) ?? 0 }
|
|
138
|
+
|
|
139
|
+
function onKeydown(e: KeyboardEvent) {
|
|
140
|
+
if (disabled()) return
|
|
141
|
+
switch (e.key) {
|
|
142
|
+
case 'ArrowDown': e.preventDefault(); move(1); break
|
|
143
|
+
case 'ArrowUp': e.preventDefault(); move(-1); break
|
|
144
|
+
case 'Home': e.preventDefault(); first(); break
|
|
145
|
+
case 'End': e.preventDefault(); last(); break
|
|
146
|
+
case ' ':
|
|
147
|
+
case 'Enter': e.preventDefault(); pick(active); break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
get activeIndex() { return active },
|
|
153
|
+
get selectedValues() { return selected },
|
|
154
|
+
isSelected,
|
|
155
|
+
isActive,
|
|
156
|
+
setActive,
|
|
157
|
+
pick,
|
|
158
|
+
move,
|
|
159
|
+
first,
|
|
160
|
+
last,
|
|
161
|
+
onKeydown,
|
|
162
|
+
rootProps: () => ({
|
|
163
|
+
role: 'listbox',
|
|
164
|
+
'aria-multiselectable': multiple(),
|
|
165
|
+
'aria-label': config.ariaLabel?.(),
|
|
166
|
+
'aria-disabled': disabled(),
|
|
167
|
+
'aria-activedescendant': opts()[active] ? optionId(active) : undefined,
|
|
168
|
+
tabindex: disabled() ? -1 : 0,
|
|
169
|
+
onkeydown: onKeydown,
|
|
170
|
+
}),
|
|
171
|
+
optionProps: (index: number) => {
|
|
172
|
+
const o = opts()[index]
|
|
173
|
+
return {
|
|
174
|
+
role: 'option',
|
|
175
|
+
id: optionId(index),
|
|
176
|
+
'aria-selected': o ? isSelected(o.value) : false,
|
|
177
|
+
'aria-disabled': o?.disabled,
|
|
178
|
+
'data-idx': index,
|
|
179
|
+
'data-active': isActive(index) ? '' : undefined,
|
|
180
|
+
'data-selected': o && isSelected(o.value) ? '' : undefined,
|
|
181
|
+
onclick: () => pick(index),
|
|
182
|
+
onpointermove: () => setActive(index),
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createMaskedInput - the HEADLESS core behind <SvMaskedInput>: a runes-based
|
|
3
|
+
* state machine that owns the pattern-mask formatting (#=digit, A=letter,
|
|
4
|
+
* *=alnum; other chars are literals) via the pure `./datetime/mask` engine,
|
|
5
|
+
* exposed as **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createMaskedInput } from '@svgrid/grid'
|
|
10
|
+
* let value = $state('')
|
|
11
|
+
* const mi = createMaskedInput({ value: () => value, mask: () => '(###) ###-####', onChange: (m) => (value = m) })
|
|
12
|
+
* </script>
|
|
13
|
+
* <input {...mi.inputProps()} />
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The styled <SvMaskedInput> is just one renderer over this core.
|
|
17
|
+
*/
|
|
18
|
+
import { applyMask, unmask, isMaskComplete } from './datetime/mask'
|
|
19
|
+
|
|
20
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
21
|
+
export type MaskedInputConfig = {
|
|
22
|
+
value: () => string
|
|
23
|
+
/** Called with (masked, raw, complete). */
|
|
24
|
+
onChange?: (masked: string, raw: string, complete: boolean) => void
|
|
25
|
+
mask?: () => string
|
|
26
|
+
placeholder?: () => string | undefined
|
|
27
|
+
disabled?: () => boolean
|
|
28
|
+
readonly?: () => boolean
|
|
29
|
+
ariaLabel?: () => string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createMaskedInput(config: MaskedInputConfig) {
|
|
33
|
+
const mask = () => config.mask?.() ?? ''
|
|
34
|
+
const disabled = () => config.disabled?.() ?? false
|
|
35
|
+
const readonly = () => config.readonly?.() ?? false
|
|
36
|
+
|
|
37
|
+
let text = $state('')
|
|
38
|
+
let lastRaw = ''
|
|
39
|
+
|
|
40
|
+
// Reflect controlled `value` into the masked display text.
|
|
41
|
+
$effect(() => {
|
|
42
|
+
const m = mask()
|
|
43
|
+
const raw = m ? unmask(config.value(), m) : config.value()
|
|
44
|
+
if (raw !== lastRaw) {
|
|
45
|
+
lastRaw = raw
|
|
46
|
+
text = m ? applyMask(raw, m).formatted : config.value()
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function onInput(e: Event) {
|
|
51
|
+
const el = e.currentTarget as HTMLInputElement
|
|
52
|
+
const m = mask()
|
|
53
|
+
const raw = m ? unmask(el.value, m) : el.value
|
|
54
|
+
const masked = m ? applyMask(raw, m).formatted : el.value
|
|
55
|
+
lastRaw = raw
|
|
56
|
+
text = masked
|
|
57
|
+
el.value = masked
|
|
58
|
+
config.onChange?.(masked, raw, m ? isMaskComplete(masked, m) : true)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
/** The current masked display text. */
|
|
63
|
+
get masked() { return text },
|
|
64
|
+
/** The raw (unmasked) data characters. */
|
|
65
|
+
get raw() { return mask() ? unmask(text, mask()) : text },
|
|
66
|
+
/** Whether every token position in the mask is filled. */
|
|
67
|
+
get complete() { return mask() ? isMaskComplete(text, mask()) : true },
|
|
68
|
+
/** Spread onto the text input element. */
|
|
69
|
+
inputProps: () => ({
|
|
70
|
+
value: text,
|
|
71
|
+
type: 'text' as const,
|
|
72
|
+
placeholder: config.placeholder?.() ?? mask(),
|
|
73
|
+
disabled: disabled(),
|
|
74
|
+
readonly: readonly(),
|
|
75
|
+
'aria-label': config.ariaLabel?.(),
|
|
76
|
+
oninput: onInput,
|
|
77
|
+
}),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type MaskedInputCore = ReturnType<typeof createMaskedInput>
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createNumberInput - the HEADLESS core behind <SvNumberInput>, in the same
|
|
3
|
+
* spirit as `createListbox` / `createSvGrid`: a runes-based state machine that
|
|
4
|
+
* owns parse / format / clamp / spinner state + keyboard, exposed as
|
|
5
|
+
* **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createNumberInput } from '@svgrid/grid'
|
|
10
|
+
* let value = $state<number | null>(0)
|
|
11
|
+
* const num = createNumberInput({ value: () => value, onChange: (v) => (value = v) })
|
|
12
|
+
* </script>
|
|
13
|
+
* <input {...num.inputProps()} />
|
|
14
|
+
* <button {...num.incrementProps()}>+</button>
|
|
15
|
+
* <button {...num.decrementProps()}>-</button>
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The styled <SvNumberInput> is just one renderer over this core.
|
|
19
|
+
*/
|
|
20
|
+
import { editorAria, type EditorAriaState } from './editor-contract'
|
|
21
|
+
|
|
22
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
23
|
+
export type NumberInputConfig = {
|
|
24
|
+
value: () => number | null
|
|
25
|
+
onChange?: (value: number | null) => void
|
|
26
|
+
min?: () => number
|
|
27
|
+
max?: () => number
|
|
28
|
+
step?: () => number
|
|
29
|
+
precision?: () => number | undefined
|
|
30
|
+
/** Group thousands (1,234) on display. */
|
|
31
|
+
grouping?: () => boolean
|
|
32
|
+
prefix?: () => string
|
|
33
|
+
suffix?: () => string
|
|
34
|
+
placeholder?: () => string | undefined
|
|
35
|
+
disabled?: () => boolean
|
|
36
|
+
readonly?: () => boolean
|
|
37
|
+
// Editor contract (ARIA + validation) - folded into inputProps().
|
|
38
|
+
id?: () => string | undefined
|
|
39
|
+
invalid?: () => boolean
|
|
40
|
+
required?: () => boolean
|
|
41
|
+
error?: () => string | undefined
|
|
42
|
+
ariaLabel?: () => string | undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createNumberInput(config: NumberInputConfig) {
|
|
46
|
+
const min = () => config.min?.() ?? -Infinity
|
|
47
|
+
const max = () => config.max?.() ?? Infinity
|
|
48
|
+
const step = () => config.step?.() ?? 1
|
|
49
|
+
const precision = () => config.precision?.()
|
|
50
|
+
const grouping = () => config.grouping?.() ?? false
|
|
51
|
+
const prefix = () => config.prefix?.() ?? ''
|
|
52
|
+
const suffix = () => config.suffix?.() ?? ''
|
|
53
|
+
const disabled = () => config.disabled?.() ?? false
|
|
54
|
+
const readonly = () => config.readonly?.() ?? false
|
|
55
|
+
|
|
56
|
+
const isInteractive = $derived(!disabled() && !readonly())
|
|
57
|
+
let text = $state('')
|
|
58
|
+
let focused = $state(false)
|
|
59
|
+
let lastKey = ''
|
|
60
|
+
|
|
61
|
+
const fmt = $derived(
|
|
62
|
+
new Intl.NumberFormat(undefined, {
|
|
63
|
+
useGrouping: grouping(),
|
|
64
|
+
minimumFractionDigits: precision() ?? 0,
|
|
65
|
+
maximumFractionDigits: precision() ?? 20,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
function display(v: number | null): string {
|
|
70
|
+
if (v == null) return ''
|
|
71
|
+
return `${prefix()}${fmt.format(v)}${suffix()}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Reflect controlled `value` into the display text unless the user is editing.
|
|
75
|
+
$effect(() => {
|
|
76
|
+
const v = config.value()
|
|
77
|
+
const key = v == null ? 'null' : String(v)
|
|
78
|
+
if (key !== lastKey) {
|
|
79
|
+
lastKey = key
|
|
80
|
+
if (!focused) text = display(v)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
function clamp(n: number): number {
|
|
85
|
+
let r = Math.min(max(), Math.max(min(), n))
|
|
86
|
+
const p = precision()
|
|
87
|
+
if (p != null) r = Math.round(r * 10 ** p) / 10 ** p
|
|
88
|
+
return r
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parse(s: string): number | null {
|
|
92
|
+
const cleaned = s.replace(prefix(), '').replace(suffix(), '').replace(/[,\s]/g, '').trim()
|
|
93
|
+
if (cleaned === '' || cleaned === '-') return null
|
|
94
|
+
const n = Number(cleaned)
|
|
95
|
+
return isNaN(n) ? null : n
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function commit() {
|
|
99
|
+
const parsed = parse(text)
|
|
100
|
+
const next = parsed == null ? null : clamp(parsed)
|
|
101
|
+
lastKey = next == null ? 'null' : String(next)
|
|
102
|
+
config.onChange?.(next)
|
|
103
|
+
text = display(next)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function bump(dir: 1 | -1) {
|
|
107
|
+
if (!isInteractive) return
|
|
108
|
+
const base = parse(text) ?? 0
|
|
109
|
+
const next = clamp(base + dir * step())
|
|
110
|
+
lastKey = String(next)
|
|
111
|
+
config.onChange?.(next)
|
|
112
|
+
text = focused ? String(next) : display(next)
|
|
113
|
+
}
|
|
114
|
+
const increment = () => bump(1)
|
|
115
|
+
const decrement = () => bump(-1)
|
|
116
|
+
|
|
117
|
+
function onFocus() {
|
|
118
|
+
focused = true
|
|
119
|
+
// Show the bare number for easy editing.
|
|
120
|
+
const v = config.value()
|
|
121
|
+
text = v == null ? '' : String(v)
|
|
122
|
+
}
|
|
123
|
+
function onBlur() {
|
|
124
|
+
focused = false
|
|
125
|
+
commit()
|
|
126
|
+
}
|
|
127
|
+
function onInput(e: Event) {
|
|
128
|
+
text = (e.currentTarget as HTMLInputElement).value
|
|
129
|
+
}
|
|
130
|
+
function onKeydown(e: KeyboardEvent) {
|
|
131
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); bump(1) }
|
|
132
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); bump(-1) }
|
|
133
|
+
else if (e.key === 'Enter') { commit() }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ariaState = (): EditorAriaState => ({
|
|
137
|
+
id: config.id?.(),
|
|
138
|
+
invalid: config.invalid?.(),
|
|
139
|
+
required: config.required?.(),
|
|
140
|
+
error: config.error?.(),
|
|
141
|
+
ariaLabel: config.ariaLabel?.(),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
/** The current display text (bind this to your input's value). */
|
|
146
|
+
get text() { return text },
|
|
147
|
+
/** Whether the field is being edited. */
|
|
148
|
+
get focused() { return focused },
|
|
149
|
+
/** Whether interaction is allowed (not disabled and not readonly). */
|
|
150
|
+
get isInteractive() { return isInteractive },
|
|
151
|
+
commit,
|
|
152
|
+
bump,
|
|
153
|
+
increment,
|
|
154
|
+
decrement,
|
|
155
|
+
/** Spread onto the text input element. */
|
|
156
|
+
inputProps: () => ({
|
|
157
|
+
id: config.id?.(),
|
|
158
|
+
value: text,
|
|
159
|
+
type: 'text' as const,
|
|
160
|
+
inputmode: 'decimal' as const,
|
|
161
|
+
role: 'spinbutton' as const,
|
|
162
|
+
placeholder: config.placeholder?.(),
|
|
163
|
+
disabled: disabled(),
|
|
164
|
+
readonly: readonly(),
|
|
165
|
+
'aria-valuenow': config.value() ?? undefined,
|
|
166
|
+
'aria-valuemin': min() === -Infinity ? undefined : min(),
|
|
167
|
+
'aria-valuemax': max() === Infinity ? undefined : max(),
|
|
168
|
+
...editorAria(ariaState()),
|
|
169
|
+
oninput: onInput,
|
|
170
|
+
onfocus: onFocus,
|
|
171
|
+
onblur: onBlur,
|
|
172
|
+
onkeydown: onKeydown,
|
|
173
|
+
}),
|
|
174
|
+
/** Spread onto a native increment button. */
|
|
175
|
+
incrementProps: () => ({
|
|
176
|
+
type: 'button' as const,
|
|
177
|
+
'aria-label': 'Increment',
|
|
178
|
+
disabled: !isInteractive,
|
|
179
|
+
tabindex: -1,
|
|
180
|
+
onclick: increment,
|
|
181
|
+
}),
|
|
182
|
+
/** Spread onto a native decrement button. */
|
|
183
|
+
decrementProps: () => ({
|
|
184
|
+
type: 'button' as const,
|
|
185
|
+
'aria-label': 'Decrement',
|
|
186
|
+
disabled: !isInteractive,
|
|
187
|
+
tabindex: -1,
|
|
188
|
+
onclick: decrement,
|
|
189
|
+
}),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type NumberInputCore = ReturnType<typeof createNumberInput>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createPasswordInput - the HEADLESS core behind <SvPasswordInput>: a runes-based
|
|
3
|
+
* state machine that owns the reveal toggle + a 4-level strength heuristic,
|
|
4
|
+
* exposed as **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM.
|
|
5
|
+
*
|
|
6
|
+
* ```svelte
|
|
7
|
+
* <script lang="ts">
|
|
8
|
+
* import { createPasswordInput } from '@svgrid/grid'
|
|
9
|
+
* let value = $state('')
|
|
10
|
+
* const pw = createPasswordInput({ value: () => value, onChange: (v) => (value = v) })
|
|
11
|
+
* </script>
|
|
12
|
+
* <input {...pw.inputProps()} />
|
|
13
|
+
* <button {...pw.toggleProps()}>{pw.revealed ? 'Hide' : 'Show'}</button>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The styled <SvPasswordInput> is just one renderer over this core.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const STRENGTH_LABELS = ['', 'Weak', 'Fair', 'Good', 'Strong']
|
|
20
|
+
|
|
21
|
+
/** Score a password 0-4 by length + character-class variety (pure). */
|
|
22
|
+
export function passwordStrength(v: string): number {
|
|
23
|
+
if (!v) return 0
|
|
24
|
+
let score = 0
|
|
25
|
+
if (v.length >= 8) score++
|
|
26
|
+
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) score++
|
|
27
|
+
if (/\d/.test(v)) score++
|
|
28
|
+
if (/[^a-zA-Z0-9]/.test(v)) score++
|
|
29
|
+
return score
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
33
|
+
export type PasswordInputConfig = {
|
|
34
|
+
value: () => string
|
|
35
|
+
onChange?: (value: string) => void
|
|
36
|
+
placeholder?: () => string | undefined
|
|
37
|
+
disabled?: () => boolean
|
|
38
|
+
readonly?: () => boolean
|
|
39
|
+
ariaLabel?: () => string | undefined
|
|
40
|
+
autocomplete?: () => string | undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createPasswordInput(config: PasswordInputConfig) {
|
|
44
|
+
const disabled = () => config.disabled?.() ?? false
|
|
45
|
+
const readonly = () => config.readonly?.() ?? false
|
|
46
|
+
|
|
47
|
+
let revealed = $state(false)
|
|
48
|
+
|
|
49
|
+
const strength = $derived(passwordStrength(config.value()))
|
|
50
|
+
|
|
51
|
+
function onInput(e: Event) {
|
|
52
|
+
config.onChange?.((e.currentTarget as HTMLInputElement).value)
|
|
53
|
+
}
|
|
54
|
+
const toggle = () => (revealed = !revealed)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
/** Whether the value is currently shown as plain text. */
|
|
58
|
+
get revealed() { return revealed },
|
|
59
|
+
/** Strength score, 0-4. */
|
|
60
|
+
get strength() { return strength },
|
|
61
|
+
/** Human label for the strength score. */
|
|
62
|
+
get strengthLabel() { return STRENGTH_LABELS[strength] },
|
|
63
|
+
toggle,
|
|
64
|
+
/** Spread onto the text/password input element. */
|
|
65
|
+
inputProps: () => ({
|
|
66
|
+
value: config.value(),
|
|
67
|
+
type: (revealed ? 'text' : 'password') as 'text' | 'password',
|
|
68
|
+
placeholder: config.placeholder?.(),
|
|
69
|
+
disabled: disabled(),
|
|
70
|
+
readonly: readonly(),
|
|
71
|
+
autocomplete: (config.autocomplete?.() ?? 'current-password') as AutoFill,
|
|
72
|
+
'aria-label': config.ariaLabel?.(),
|
|
73
|
+
oninput: onInput,
|
|
74
|
+
}),
|
|
75
|
+
/** Spread onto the reveal (show/hide) toggle button. */
|
|
76
|
+
toggleProps: () => ({
|
|
77
|
+
type: 'button' as const,
|
|
78
|
+
'aria-label': revealed ? 'Hide password' : 'Show password',
|
|
79
|
+
'aria-pressed': revealed,
|
|
80
|
+
disabled: disabled(),
|
|
81
|
+
tabindex: -1,
|
|
82
|
+
onclick: toggle,
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type PasswordInputCore = ReturnType<typeof createPasswordInput>
|