@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.
Files changed (141) hide show
  1. package/dist/SvAutoComplete.svelte +23 -34
  2. package/dist/SvCalendar.svelte +74 -334
  3. package/dist/SvCalendar.svelte.d.ts +5 -23
  4. package/dist/SvCheckBox.svelte +11 -10
  5. package/dist/SvColorInput.svelte +31 -58
  6. package/dist/SvComboBox.svelte +32 -54
  7. package/dist/SvCountryInput.svelte +31 -29
  8. package/dist/SvDateTimePicker.svelte +53 -110
  9. package/dist/SvDateTimePicker.svelte.d.ts +4 -3
  10. package/dist/SvDropDownList.svelte +31 -54
  11. package/dist/SvGauge.svelte +21 -23
  12. package/dist/SvGauge.svelte.d.ts +0 -5
  13. package/dist/SvListBox.svelte +25 -58
  14. package/dist/SvListBox.svelte.d.ts +3 -0
  15. package/dist/SvMaskedInput.svelte +0 -0
  16. package/dist/SvNumberInput.svelte +0 -0
  17. package/dist/SvNumberInput.svelte.d.ts +2 -6
  18. package/dist/SvPasswordInput.svelte +18 -28
  19. package/dist/SvPasswordInput.svelte.d.ts +0 -4
  20. package/dist/SvPhoneInput.svelte +0 -0
  21. package/dist/SvRadioGroup.svelte +14 -37
  22. package/dist/SvRadioGroup.svelte.d.ts +6 -9
  23. package/dist/SvRating.svelte +22 -42
  24. package/dist/SvRating.svelte.d.ts +3 -5
  25. package/dist/SvSlider.svelte +30 -57
  26. package/dist/SvSlider.svelte.d.ts +0 -5
  27. package/dist/SvSwitchButton.svelte +10 -17
  28. package/dist/SvSwitchButton.svelte.d.ts +0 -5
  29. package/dist/SvTabs.svelte +26 -39
  30. package/dist/SvTabs.svelte.d.ts +5 -5
  31. package/dist/SvTagsInput.svelte +17 -28
  32. package/dist/SvTagsInput.svelte.d.ts +0 -5
  33. package/dist/SvTimePicker.svelte +52 -210
  34. package/dist/SvTimePicker.svelte.d.ts +11 -8
  35. package/dist/SvToggleButton.svelte +9 -9
  36. package/dist/SvTree.svelte +30 -109
  37. package/dist/SvTree.svelte.d.ts +9 -11
  38. package/dist/cdn/svgrid.js +7488 -5839
  39. package/dist/cdn/svgrid.svelte-external.js +4691 -3028
  40. package/dist/createAutocomplete.svelte.d.ts +77 -0
  41. package/dist/createAutocomplete.svelte.js +100 -0
  42. package/dist/createCalendar.svelte.d.ts +192 -0
  43. package/dist/createCalendar.svelte.js +402 -0
  44. package/dist/createCheckbox.svelte.d.ts +45 -0
  45. package/dist/createCheckbox.svelte.js +26 -0
  46. package/dist/createColorInput.svelte.d.ts +67 -0
  47. package/dist/createColorInput.svelte.js +100 -0
  48. package/dist/createCombobox.svelte.d.ts +102 -0
  49. package/dist/createCombobox.svelte.js +171 -0
  50. package/dist/createCountryInput.svelte.d.ts +86 -0
  51. package/dist/createCountryInput.svelte.js +126 -0
  52. package/dist/createDateTimePicker.svelte.d.ts +83 -0
  53. package/dist/createDateTimePicker.svelte.js +202 -0
  54. package/dist/createDropdownList.svelte.d.ts +78 -0
  55. package/dist/createDropdownList.svelte.js +117 -0
  56. package/dist/createGauge.svelte.d.ts +56 -0
  57. package/dist/createGauge.svelte.js +49 -0
  58. package/dist/createListbox.svelte.d.ts +79 -0
  59. package/dist/createListbox.svelte.js +117 -0
  60. package/dist/createMaskedInput.svelte.d.ts +30 -0
  61. package/dist/createMaskedInput.svelte.js +62 -0
  62. package/dist/createNumberInput.svelte.d.ts +72 -0
  63. package/dist/createNumberInput.svelte.js +167 -0
  64. package/dist/createPasswordInput.svelte.d.ts +60 -0
  65. package/dist/createPasswordInput.svelte.js +72 -0
  66. package/dist/createPhoneInput.svelte.d.ts +46 -0
  67. package/dist/createPhoneInput.svelte.js +105 -0
  68. package/dist/createRadioGroup.svelte.d.ts +64 -0
  69. package/dist/createRadioGroup.svelte.js +73 -0
  70. package/dist/createRating.svelte.d.ts +70 -0
  71. package/dist/createRating.svelte.js +85 -0
  72. package/dist/createSlider.svelte.d.ts +71 -0
  73. package/dist/createSlider.svelte.js +123 -0
  74. package/dist/createSwitch.svelte.d.ts +43 -0
  75. package/dist/createSwitch.svelte.js +41 -0
  76. package/dist/createTabs.svelte.d.ts +78 -0
  77. package/dist/createTabs.svelte.js +92 -0
  78. package/dist/createTagsInput.svelte.d.ts +65 -0
  79. package/dist/createTagsInput.svelte.js +93 -0
  80. package/dist/createTimePicker.svelte.d.ts +106 -0
  81. package/dist/createTimePicker.svelte.js +248 -0
  82. package/dist/createToggle.svelte.d.ts +43 -0
  83. package/dist/createToggle.svelte.js +39 -0
  84. package/dist/createTree.svelte.d.ts +96 -0
  85. package/dist/createTree.svelte.js +194 -0
  86. package/dist/editor-contract.d.ts +48 -0
  87. package/dist/editor-contract.js +26 -0
  88. package/dist/index.d.ts +24 -0
  89. package/dist/index.js +31 -0
  90. package/package.json +1 -1
  91. package/src/SvAutoComplete.svelte +23 -34
  92. package/src/SvCalendar.svelte +74 -334
  93. package/src/SvCheckBox.svelte +11 -10
  94. package/src/SvColorInput.svelte +31 -58
  95. package/src/SvComboBox.svelte +32 -54
  96. package/src/SvCountryInput.svelte +31 -29
  97. package/src/SvDateTimePicker.svelte +53 -110
  98. package/src/SvDropDownList.svelte +31 -54
  99. package/src/SvGauge.svelte +21 -23
  100. package/src/SvListBox.svelte +25 -58
  101. package/src/SvMaskedInput.svelte +0 -0
  102. package/src/SvNumberInput.svelte +0 -0
  103. package/src/SvPasswordInput.svelte +18 -28
  104. package/src/SvPhoneInput.svelte +0 -0
  105. package/src/SvRadioGroup.svelte +14 -37
  106. package/src/SvRating.svelte +22 -42
  107. package/src/SvSlider.svelte +30 -57
  108. package/src/SvSwitchButton.svelte +10 -17
  109. package/src/SvTabs.svelte +26 -39
  110. package/src/SvTagsInput.svelte +17 -28
  111. package/src/SvTimePicker.svelte +52 -210
  112. package/src/SvToggleButton.svelte +9 -9
  113. package/src/SvTree.svelte +30 -109
  114. package/src/createAutocomplete.svelte.ts +104 -0
  115. package/src/createCalendar.svelte.ts +488 -0
  116. package/src/createCheckbox.svelte.ts +74 -0
  117. package/src/createColorInput.svelte.ts +115 -0
  118. package/src/createCombobox.svelte.ts +158 -0
  119. package/src/createCountryInput.svelte.ts +126 -0
  120. package/src/createDateTimePicker.svelte.ts +216 -0
  121. package/src/createDropdownList.svelte.ts +125 -0
  122. package/src/createGauge.svelte.ts +86 -0
  123. package/src/createListbox.svelte.ts +186 -0
  124. package/src/createMaskedInput.svelte.ts +81 -0
  125. package/src/createNumberInput.svelte.ts +193 -0
  126. package/src/createPasswordInput.svelte.ts +87 -0
  127. package/src/createPhoneInput.svelte.ts +119 -0
  128. package/src/createRadioGroup.svelte.ts +132 -0
  129. package/src/createRating.svelte.ts +145 -0
  130. package/src/createSlider.svelte.ts +160 -0
  131. package/src/createSwitch.svelte.ts +78 -0
  132. package/src/createTabs.svelte.ts +129 -0
  133. package/src/createTagsInput.svelte.ts +98 -0
  134. package/src/createTimePicker.svelte.ts +289 -0
  135. package/src/createToggle.svelte.ts +69 -0
  136. package/src/createTree.svelte.ts +216 -0
  137. package/src/editor-contract.test.ts +32 -0
  138. package/src/editor-contract.ts +60 -0
  139. package/src/index.ts +39 -0
  140. package/src/ui-inputs.test.ts +26 -0
  141. package/src/ui-selection.test.ts +13 -0
@@ -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 = DEFAULT_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 isInteractive = $derived(!disabled && !readonly)
41
- let open = $state(false)
42
- let hexDraft = $state('')
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
- open = false
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(); openPanel()
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
- aria-haspopup="dialog"
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={normalizeHex(c) === normalized}
120
+ class:is-active={col.isActive(c)}
147
121
  style:background={c}
148
- aria-label={c}
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 {
@@ -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 { filterOptions, type ListOption } from './list-option'
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 selected = $derived(options.find((o) => o.value === value) ?? null)
34
- const filtered = $derived(editing ? filterOptions(options, query) : [...options])
35
- const shownText = $derived(editing ? query : selected?.label ?? '')
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
- $effect(() => { if (!editing) query = selected?.label ?? '' })
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
- {disabled}
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 type="button" class="sv-combo__chev" tabindex="-1" aria-label="Toggle" onclick={() => (open ? close() : (inputEl?.focus(), openPanel()))} {disabled}>
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} id="combo-list" 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`} role="listbox">
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 === active} class:is-selected={opt.value === value} class:is-disabled={opt.disabled} role="option" tabindex="-1" aria-selected={opt.value === value} data-idx={i} onclick={() => pick(opt)} onpointermove={() => { if (!opt.disabled) active = i }}>{opt.label}</div>
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 { COUNTRIES, COUNTRY_BY_CODE, flagEmoji } from './countries'
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 selected = $derived(value ? COUNTRY_BY_CODE.get(value) ?? null : null)
33
- const filtered = $derived(
34
- query.trim() === ''
35
- ? COUNTRIES
36
- : COUNTRIES.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()) || c.dial.includes(query) || c.code.toLowerCase() === query.toLowerCase()),
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; open = false }
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} type="button" class="sv-country sv-country--{size}" class:is-open={open} class:is-disabled={disabled} aria-haspopup="listbox" aria-expanded={open} aria-label={ariaLabel} {disabled} onclick={() => (open ? (open = false) : openPanel())}>
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" type="text" bind:value={query} placeholder="Search countries…" aria-label="Search countries" onkeydown={onKeydown} oninput={() => { active = 0; updatePos() }} />
76
- <div class="sv-country__list" role="listbox">
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 === active} class:is-selected={c.code === value} role="option" tabindex="-1" aria-selected={c.code === value} data-idx={i} onclick={() => pick(c.code)} onpointermove={() => (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>