@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
package/src/SvColorInput.svelte
CHANGED
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
* SvColorInput - a color swatch that opens a portalled popover with a hex
|
|
4
4
|
* field, a native picker, and a preset palette. Parity: Smart `smart-color-input`.
|
|
5
5
|
* Emits a hex string. Popover escapes the grid scroll container via popover.ts.
|
|
6
|
+
*
|
|
7
|
+
* The styled renderer over the headless `createColorInput` core: hex
|
|
8
|
+
* normalization, palette, draft field and open/close state come from the core;
|
|
9
|
+
* the portal + anchored measurement stay here (render concerns).
|
|
6
10
|
*/
|
|
7
11
|
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
12
|
+
import { createColorInput } from './createColorInput.svelte'
|
|
8
13
|
|
|
9
14
|
type Props = {
|
|
10
15
|
value?: string
|
|
@@ -19,71 +24,46 @@
|
|
|
19
24
|
autoOpen?: boolean
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
const DEFAULT_PALETTE = [
|
|
23
|
-
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6',
|
|
24
|
-
'#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
|
|
25
|
-
'#f43f5e', '#64748b', '#0f172a', '#ffffff',
|
|
26
|
-
]
|
|
27
|
-
|
|
28
27
|
let {
|
|
29
28
|
value = '#3b82f6',
|
|
30
29
|
onChange,
|
|
31
30
|
disabled = false,
|
|
32
31
|
readonly = false,
|
|
33
|
-
palette
|
|
32
|
+
palette,
|
|
34
33
|
name,
|
|
35
34
|
size = 'md',
|
|
36
35
|
ariaLabel,
|
|
37
36
|
autoOpen = false,
|
|
38
37
|
}: Props = $props()
|
|
39
38
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
const col = createColorInput({
|
|
40
|
+
value: () => value,
|
|
41
|
+
onChange: (hex) => onChange?.(hex),
|
|
42
|
+
disabled: () => disabled,
|
|
43
|
+
readonly: () => readonly,
|
|
44
|
+
palette: () => palette as string[],
|
|
45
|
+
ariaLabel: () => ariaLabel,
|
|
46
|
+
})
|
|
47
|
+
|
|
43
48
|
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
44
49
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
45
50
|
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
46
51
|
|
|
47
|
-
const normalized = $derived(normalizeHex(value) ?? '#000000')
|
|
48
|
-
|
|
49
|
-
function normalizeHex(h: string): string | null {
|
|
50
|
-
let s = h.trim()
|
|
51
|
-
if (!s.startsWith('#')) s = '#' + s
|
|
52
|
-
if (/^#[0-9a-fA-F]{3}$/.test(s)) s = '#' + s.slice(1).split('').map((c) => c + c).join('')
|
|
53
|
-
return /^#[0-9a-fA-F]{6}$/.test(s) ? s.toLowerCase() : null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
function updatePos() {
|
|
57
53
|
if (!triggerEl) return
|
|
58
54
|
panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 210, minWidth: 200 })
|
|
59
55
|
}
|
|
60
|
-
function openPanel() {
|
|
61
|
-
if (!isInteractive || open) return
|
|
62
|
-
hexDraft = normalized
|
|
63
|
-
open = true
|
|
64
|
-
updatePos()
|
|
65
|
-
}
|
|
66
|
-
function toggle() { open ? (open = false) : openPanel() }
|
|
67
|
-
|
|
68
|
-
function pick(hex: string) {
|
|
69
|
-
const n = normalizeHex(hex)
|
|
70
|
-
if (n) onChange?.(n)
|
|
71
|
-
}
|
|
72
|
-
function commitHex() {
|
|
73
|
-
const n = normalizeHex(hexDraft)
|
|
74
|
-
if (n) onChange?.(n)
|
|
75
|
-
else hexDraft = normalized
|
|
76
|
-
}
|
|
77
56
|
|
|
78
57
|
$effect(() => {
|
|
79
|
-
if (!open) return
|
|
58
|
+
if (!col.popover.open) return
|
|
59
|
+
updatePos()
|
|
80
60
|
const reposition = () => updatePos()
|
|
81
61
|
window.addEventListener('scroll', reposition, true)
|
|
82
62
|
window.addEventListener('resize', reposition)
|
|
83
63
|
const onDown = (e: PointerEvent) => {
|
|
84
64
|
const t = e.target as Node | null
|
|
85
65
|
if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return
|
|
86
|
-
|
|
66
|
+
col.popover.close()
|
|
87
67
|
}
|
|
88
68
|
document.addEventListener('pointerdown', onDown, true)
|
|
89
69
|
return () => {
|
|
@@ -95,27 +75,22 @@
|
|
|
95
75
|
|
|
96
76
|
function focusOpen(node: HTMLButtonElement) {
|
|
97
77
|
if (!autoOpen) return
|
|
98
|
-
node.focus();
|
|
78
|
+
node.focus(); col.popover.show()
|
|
99
79
|
}
|
|
100
80
|
</script>
|
|
101
81
|
|
|
102
82
|
<button
|
|
103
|
-
type="button"
|
|
104
83
|
bind:this={triggerEl}
|
|
105
84
|
class="sv-color sv-color--{size}"
|
|
106
85
|
class:is-disabled={disabled}
|
|
107
|
-
|
|
108
|
-
aria-expanded={open}
|
|
109
|
-
aria-label={ariaLabel ?? `Color ${normalized}`}
|
|
110
|
-
disabled={!isInteractive}
|
|
111
|
-
onclick={toggle}
|
|
86
|
+
{...col.swatchProps()}
|
|
112
87
|
use:focusOpen
|
|
113
88
|
>
|
|
114
|
-
<span class="sv-color__swatch" style:background={normalized}></span>
|
|
115
|
-
<span class="sv-color__hex">{normalized}</span>
|
|
89
|
+
<span class="sv-color__swatch" style:background={col.normalized}></span>
|
|
90
|
+
<span class="sv-color__hex">{col.normalized}</span>
|
|
116
91
|
</button>
|
|
117
92
|
|
|
118
|
-
{#if open}
|
|
93
|
+
{#if col.popover.open}
|
|
119
94
|
<div
|
|
120
95
|
class="sv-color__panel"
|
|
121
96
|
bind:this={panelEl}
|
|
@@ -127,32 +102,30 @@
|
|
|
127
102
|
aria-label="Choose color"
|
|
128
103
|
>
|
|
129
104
|
<div class="sv-color__top">
|
|
130
|
-
<input class="sv-color__native" type="color" value={normalized} oninput={(e) => pick((e.currentTarget as HTMLInputElement).value)} aria-label="Color picker" />
|
|
105
|
+
<input class="sv-color__native" type="color" value={col.normalized} oninput={(e) => col.pick((e.currentTarget as HTMLInputElement).value)} aria-label="Color picker" />
|
|
131
106
|
<input
|
|
132
107
|
class="sv-color__field"
|
|
133
108
|
type="text"
|
|
134
|
-
bind:value={hexDraft}
|
|
109
|
+
bind:value={col.hexDraft}
|
|
135
110
|
spellcheck="false"
|
|
136
111
|
aria-label="Hex value"
|
|
137
|
-
onblur={commitHex}
|
|
138
|
-
onkeydown={(e) => { if (e.key === 'Enter') commitHex() }}
|
|
112
|
+
onblur={col.commitHex}
|
|
113
|
+
onkeydown={(e) => { if (e.key === 'Enter') col.commitHex() }}
|
|
139
114
|
/>
|
|
140
115
|
</div>
|
|
141
116
|
<div class="sv-color__palette">
|
|
142
|
-
{#each palette as c (c)}
|
|
117
|
+
{#each col.palette as c (c)}
|
|
143
118
|
<button
|
|
144
|
-
type="button"
|
|
145
119
|
class="sv-color__chip"
|
|
146
|
-
class:is-active={
|
|
120
|
+
class:is-active={col.isActive(c)}
|
|
147
121
|
style:background={c}
|
|
148
|
-
|
|
149
|
-
onclick={() => { pick(c); open = false }}
|
|
122
|
+
{...col.chipProps(c)}
|
|
150
123
|
></button>
|
|
151
124
|
{/each}
|
|
152
125
|
</div>
|
|
153
126
|
</div>
|
|
154
127
|
{/if}
|
|
155
|
-
{#if name}<input type="hidden" {name} value={normalized} />{/if}
|
|
128
|
+
{#if name}<input type="hidden" {name} value={col.normalized} />{/if}
|
|
156
129
|
|
|
157
130
|
<style>
|
|
158
131
|
.sv-color {
|
package/src/SvComboBox.svelte
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
* SvComboBox - an editable combobox: type to filter a portalled list, pick an
|
|
4
4
|
* option (value must come from the list). Parity: Smart `smart-combo-box`.
|
|
5
5
|
* Controlled via `value` + `onChange`. On blur, unmatched text reverts.
|
|
6
|
+
*
|
|
7
|
+
* This is one styled renderer over the headless `createCombobox` core (state +
|
|
8
|
+
* keyboard + ARIA); only portal/measure/scroll render concerns live here.
|
|
6
9
|
*/
|
|
7
10
|
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
8
|
-
import {
|
|
11
|
+
import { type ListOption } from './list-option'
|
|
12
|
+
import { createCombobox } from './createCombobox.svelte'
|
|
9
13
|
|
|
10
14
|
type Props = {
|
|
11
15
|
options: ReadonlyArray<ListOption>
|
|
@@ -21,90 +25,64 @@
|
|
|
21
25
|
|
|
22
26
|
let { options, value = null, onChange, placeholder = 'Select…', disabled = false, name, size = 'md', ariaLabel, autoOpen = false }: Props = $props()
|
|
23
27
|
|
|
24
|
-
let open = $state(false)
|
|
25
|
-
let query = $state('')
|
|
26
|
-
let active = $state(0)
|
|
27
|
-
let editing = $state(false)
|
|
28
28
|
let inputEl = $state<HTMLInputElement | null>(null)
|
|
29
29
|
let fieldEl = $state<HTMLDivElement | null>(null)
|
|
30
30
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
31
31
|
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
32
32
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const combo = createCombobox({
|
|
34
|
+
options: () => options,
|
|
35
|
+
value: () => value,
|
|
36
|
+
onChange: (v) => onChange?.(v),
|
|
37
|
+
disabled: () => disabled,
|
|
38
|
+
ariaLabel: () => ariaLabel,
|
|
39
|
+
focusInput: () => inputEl?.focus(),
|
|
40
|
+
blurInput: () => inputEl?.blur(),
|
|
41
|
+
})
|
|
36
42
|
|
|
37
43
|
function updatePos() {
|
|
38
44
|
if (!fieldEl) return
|
|
39
|
-
rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: Math.min(filtered.length, 8) * 34 + 8 })
|
|
40
|
-
}
|
|
41
|
-
function openPanel() { if (disabled || open) return; open = true; active = 0; updatePos() }
|
|
42
|
-
function close(revert = true) {
|
|
43
|
-
open = false; editing = false
|
|
44
|
-
if (revert) query = selected?.label ?? ''
|
|
45
|
-
}
|
|
46
|
-
function pick(o: ListOption | undefined) {
|
|
47
|
-
if (!o || o.disabled) return
|
|
48
|
-
onChange?.(o.value); query = o.label; editing = false; open = false; inputEl?.blur()
|
|
49
|
-
}
|
|
50
|
-
function onInput(e: Event) {
|
|
51
|
-
query = (e.currentTarget as HTMLInputElement).value
|
|
52
|
-
editing = true; active = 0
|
|
53
|
-
if (!open) openPanel(); else updatePos()
|
|
45
|
+
rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: Math.min(combo.filtered.length, 8) * 34 + 8 })
|
|
54
46
|
}
|
|
55
|
-
function onKeydown(e: KeyboardEvent) {
|
|
56
|
-
if (disabled) return
|
|
57
|
-
if (!open && (e.key === 'ArrowDown')) { e.preventDefault(); editing = true; openPanel(); return }
|
|
58
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
|
|
59
|
-
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
|
|
60
|
-
else if (e.key === 'Enter') { e.preventDefault(); pick(filtered[active]) }
|
|
61
|
-
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
62
|
-
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
63
|
-
}
|
|
64
|
-
function onFocus() { editing = true; query = selected?.label ?? ''; openPanel() }
|
|
65
|
-
function onBlur() { /* outside-click effect handles close+revert */ }
|
|
66
47
|
|
|
48
|
+
// Position + reposition + outside-click close are render concerns (need the DOM).
|
|
67
49
|
$effect(() => {
|
|
68
|
-
if (!open) return
|
|
50
|
+
if (!combo.open) return
|
|
51
|
+
updatePos()
|
|
69
52
|
const rp = () => updatePos()
|
|
70
53
|
window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
|
|
71
|
-
const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (fieldEl?.contains(t) || panelEl?.contains(t))) return; close() }
|
|
54
|
+
const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (fieldEl?.contains(t) || panelEl?.contains(t))) return; combo.close() }
|
|
72
55
|
document.addEventListener('pointerdown', od, true)
|
|
73
56
|
return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
|
|
74
57
|
})
|
|
75
|
-
|
|
58
|
+
// Keep the active option scrolled into view (render concern).
|
|
59
|
+
$effect(() => {
|
|
60
|
+
if (!combo.open) return
|
|
61
|
+
const i = combo.activeIndex
|
|
62
|
+
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
63
|
+
})
|
|
76
64
|
function focusOpen(node: HTMLInputElement) { if (autoOpen) { node.focus() } }
|
|
77
65
|
</script>
|
|
78
66
|
|
|
79
|
-
<div bind:this={fieldEl} class="sv-combo sv-combo--{size}" class:is-open={open} class:is-disabled={disabled}>
|
|
67
|
+
<div bind:this={fieldEl} class="sv-combo sv-combo--{size}" class:is-open={combo.open} class:is-disabled={disabled}>
|
|
80
68
|
<input
|
|
81
69
|
bind:this={inputEl}
|
|
82
70
|
class="sv-combo__input"
|
|
83
71
|
type="text"
|
|
84
|
-
role="combobox"
|
|
85
|
-
aria-expanded={open}
|
|
86
|
-
aria-controls="combo-list"
|
|
87
|
-
aria-autocomplete="list"
|
|
88
|
-
value={shownText}
|
|
89
72
|
{placeholder}
|
|
90
|
-
{
|
|
91
|
-
aria-label={ariaLabel}
|
|
92
|
-
oninput={onInput}
|
|
93
|
-
onfocus={onFocus}
|
|
94
|
-
onblur={onBlur}
|
|
95
|
-
onkeydown={onKeydown}
|
|
73
|
+
{...combo.inputProps()}
|
|
96
74
|
use:focusOpen
|
|
97
75
|
/>
|
|
98
|
-
<button
|
|
76
|
+
<button class="sv-combo__chev" {...combo.triggerProps()}>
|
|
99
77
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
100
78
|
</button>
|
|
101
79
|
</div>
|
|
102
80
|
|
|
103
|
-
{#if open}
|
|
104
|
-
<div bind:this={panelEl}
|
|
105
|
-
{#each filtered as opt, i (opt.value)}
|
|
81
|
+
{#if combo.open}
|
|
82
|
+
<div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} {...combo.listboxProps()}>
|
|
83
|
+
{#each combo.filtered as opt, i (opt.value)}
|
|
106
84
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
|
107
|
-
<div class="sv-ddl__opt" class:is-active={i
|
|
85
|
+
<div class="sv-ddl__opt" class:is-active={combo.isActive(i)} class:is-selected={combo.isSelected(opt)} class:is-disabled={opt.disabled} {...combo.optionProps(i)}>{opt.label}</div>
|
|
108
86
|
{:else}
|
|
109
87
|
<div class="sv-ddl__empty">No matches</div>
|
|
110
88
|
{/each}
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
* SvCountryInput - a searchable country picker (flag + name + dial code).
|
|
4
4
|
* Emits the ISO 3166-1 alpha-2 code. Parity: Smart country-input pattern.
|
|
5
5
|
* Data from countries.ts; popover via popover.ts.
|
|
6
|
+
*
|
|
7
|
+
* One styled renderer over the headless `createCountryInput` core (search +
|
|
8
|
+
* roving active index + keyboard); only portal/measure/focus render concerns
|
|
9
|
+
* live here.
|
|
6
10
|
*/
|
|
7
11
|
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
8
|
-
import {
|
|
12
|
+
import { flagEmoji } from './countries'
|
|
13
|
+
import { createCountryInput } from './createCountryInput.svelte'
|
|
9
14
|
|
|
10
15
|
type Props = {
|
|
11
16
|
value?: string | null
|
|
@@ -21,45 +26,42 @@
|
|
|
21
26
|
|
|
22
27
|
let { value = null, onChange, showDial = false, placeholder = 'Select country…', disabled = false, name, size = 'md', ariaLabel }: Props = $props()
|
|
23
28
|
|
|
24
|
-
let open = $state(false)
|
|
25
|
-
let query = $state('')
|
|
26
|
-
let active = $state(0)
|
|
27
29
|
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
28
30
|
let searchEl = $state<HTMLInputElement | null>(null)
|
|
29
31
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
30
32
|
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
31
33
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
const ci = createCountryInput({
|
|
35
|
+
value: () => value,
|
|
36
|
+
onChange: (c) => onChange?.(c),
|
|
37
|
+
disabled: () => disabled,
|
|
38
|
+
ariaLabel: () => ariaLabel,
|
|
39
|
+
focusTrigger: () => triggerEl?.focus(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const selected = $derived(ci.selected)
|
|
38
43
|
|
|
39
44
|
function updatePos() { if (triggerEl) rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 320, minWidth: 240 }) }
|
|
40
|
-
function openPanel() { if (disabled || open) return; open = true; query = ''; active = 0; updatePos(); queueMicrotask(() => searchEl?.focus()) }
|
|
41
|
-
function close() { open = false; triggerEl?.focus() }
|
|
42
|
-
function pick(code: string) { onChange?.(code); open = false; triggerEl?.focus() }
|
|
43
|
-
function onKeydown(e: KeyboardEvent) {
|
|
44
|
-
if (!open) return
|
|
45
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
|
|
46
|
-
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
|
|
47
|
-
else if (e.key === 'Enter') { e.preventDefault(); const c = filtered[active]; if (c) pick(c.code) }
|
|
48
|
-
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
49
|
-
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
50
|
-
}
|
|
51
45
|
|
|
52
46
|
$effect(() => {
|
|
53
|
-
if (!open) return
|
|
47
|
+
if (!ci.open) return
|
|
48
|
+
updatePos()
|
|
49
|
+
queueMicrotask(() => searchEl?.focus())
|
|
54
50
|
const rp = () => updatePos()
|
|
55
51
|
window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
|
|
56
|
-
const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return;
|
|
52
|
+
const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; ci.close() }
|
|
57
53
|
document.addEventListener('pointerdown', od, true)
|
|
58
54
|
return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
|
|
59
55
|
})
|
|
56
|
+
// Keep the active option scrolled into view (render concern).
|
|
57
|
+
$effect(() => {
|
|
58
|
+
if (!ci.open) return
|
|
59
|
+
const i = ci.activeIndex
|
|
60
|
+
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
61
|
+
})
|
|
60
62
|
</script>
|
|
61
63
|
|
|
62
|
-
<button bind:this={triggerEl}
|
|
64
|
+
<button bind:this={triggerEl} class="sv-country sv-country--{size}" class:is-open={ci.open} class:is-disabled={disabled} {...ci.triggerProps()}>
|
|
63
65
|
{#if selected}
|
|
64
66
|
<span class="sv-country__flag" aria-hidden="true">{flagEmoji(selected.code)}</span>
|
|
65
67
|
<span class="sv-country__name">{selected.name}</span>
|
|
@@ -70,13 +72,13 @@
|
|
|
70
72
|
<svg class="sv-country__chev" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
71
73
|
</button>
|
|
72
74
|
|
|
73
|
-
{#if open}
|
|
75
|
+
{#if ci.open}
|
|
74
76
|
<div bind:this={panelEl} class="sv-country__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} role="dialog">
|
|
75
|
-
<input bind:this={searchEl} class="sv-country__search"
|
|
76
|
-
<div class="sv-country__list"
|
|
77
|
-
{#each filtered as c, i (c.code)}
|
|
77
|
+
<input bind:this={searchEl} class="sv-country__search" placeholder="Search countries…" {...ci.searchProps()} />
|
|
78
|
+
<div class="sv-country__list" {...ci.listboxProps()}>
|
|
79
|
+
{#each ci.filtered as c, i (c.code)}
|
|
78
80
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
|
79
|
-
<div class="sv-country__opt" class:is-active={i
|
|
81
|
+
<div class="sv-country__opt" class:is-active={ci.isActive(i)} class:is-selected={ci.isSelected(c.code)} {...ci.optionProps(i)}>
|
|
80
82
|
<span class="sv-country__flag" aria-hidden="true">{flagEmoji(c.code)}</span>
|
|
81
83
|
<span class="sv-country__name">{c.name}</span>
|
|
82
84
|
<span class="sv-country__dial">{c.dial}</span>
|