@human-kit/svelte-components 1.0.0-alpha.1

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 (140) hide show
  1. package/dist/combobox/TODO.md +175 -0
  2. package/dist/combobox/button/combobox-button.svelte +57 -0
  3. package/dist/combobox/button/combobox-button.svelte.d.ts +9 -0
  4. package/dist/combobox/index.d.ts +14 -0
  5. package/dist/combobox/index.js +18 -0
  6. package/dist/combobox/index.parts.d.ts +10 -0
  7. package/dist/combobox/index.parts.js +11 -0
  8. package/dist/combobox/input/combobox-input.svelte +98 -0
  9. package/dist/combobox/input/combobox-input.svelte.d.ts +13 -0
  10. package/dist/combobox/item/combobox-item-implicit-text-test.svelte +21 -0
  11. package/dist/combobox/item/combobox-item-implicit-text-test.svelte.d.ts +3 -0
  12. package/dist/combobox/item/combobox-listboxitem.svelte +136 -0
  13. package/dist/combobox/item/combobox-listboxitem.svelte.d.ts +18 -0
  14. package/dist/combobox/item-indicator/combobox-item-indicator.svelte +63 -0
  15. package/dist/combobox/item-indicator/combobox-item-indicator.svelte.d.ts +17 -0
  16. package/dist/combobox/list/combobox-listbox.svelte +76 -0
  17. package/dist/combobox/list/combobox-listbox.svelte.d.ts +47 -0
  18. package/dist/combobox/popover/combobox-popover.svelte +69 -0
  19. package/dist/combobox/popover/combobox-popover.svelte.d.ts +12 -0
  20. package/dist/combobox/root/combobox-filtered-test.svelte +51 -0
  21. package/dist/combobox/root/combobox-filtered-test.svelte.d.ts +7 -0
  22. package/dist/combobox/root/combobox-multiselect-test.svelte +76 -0
  23. package/dist/combobox/root/combobox-multiselect-test.svelte.d.ts +13 -0
  24. package/dist/combobox/root/combobox-numeric-string-id-test.svelte +20 -0
  25. package/dist/combobox/root/combobox-numeric-string-id-test.svelte.d.ts +3 -0
  26. package/dist/combobox/root/combobox-test.svelte +43 -0
  27. package/dist/combobox/root/combobox-test.svelte.d.ts +9 -0
  28. package/dist/combobox/root/combobox.svelte +696 -0
  29. package/dist/combobox/root/combobox.svelte.d.ts +58 -0
  30. package/dist/combobox/root/context.d.ts +90 -0
  31. package/dist/combobox/root/context.js +15 -0
  32. package/dist/combobox/tag/combobox-tag.svelte +58 -0
  33. package/dist/combobox/tag/combobox-tag.svelte.d.ts +22 -0
  34. package/dist/combobox/tag/tag-context-provider.svelte +36 -0
  35. package/dist/combobox/tag/tag-context-provider.svelte.d.ts +14 -0
  36. package/dist/combobox/tag-remove/combobox-tag-remove.svelte +53 -0
  37. package/dist/combobox/tag-remove/combobox-tag-remove.svelte.d.ts +14 -0
  38. package/dist/combobox/tags/combobox-tags.svelte +50 -0
  39. package/dist/combobox/tags/combobox-tags.svelte.d.ts +20 -0
  40. package/dist/dialog/content/dialog-content.svelte +121 -0
  41. package/dist/dialog/content/dialog-content.svelte.d.ts +19 -0
  42. package/dist/dialog/index.d.ts +10 -0
  43. package/dist/dialog/index.js +15 -0
  44. package/dist/dialog/index.parts.d.ts +5 -0
  45. package/dist/dialog/index.parts.js +6 -0
  46. package/dist/dialog/overlay/dialog-overlay.svelte +39 -0
  47. package/dist/dialog/overlay/dialog-overlay.svelte.d.ts +12 -0
  48. package/dist/dialog/portal/dialog-portal.svelte +32 -0
  49. package/dist/dialog/portal/dialog-portal.svelte.d.ts +12 -0
  50. package/dist/dialog/root/context.d.ts +25 -0
  51. package/dist/dialog/root/context.js +8 -0
  52. package/dist/dialog/root/dialog-root.svelte +99 -0
  53. package/dist/dialog/root/dialog-root.svelte.d.ts +21 -0
  54. package/dist/dialog/root/dialog-stack.d.ts +32 -0
  55. package/dist/dialog/root/dialog-stack.js +55 -0
  56. package/dist/dialog/root/dialog-test.svelte +38 -0
  57. package/dist/dialog/root/dialog-test.svelte.d.ts +10 -0
  58. package/dist/dialog/root/dialog-with-combobox-test.svelte +61 -0
  59. package/dist/dialog/root/dialog-with-combobox-test.svelte.d.ts +7 -0
  60. package/dist/dialog/root/nested-dialog-test.svelte +63 -0
  61. package/dist/dialog/root/nested-dialog-test.svelte.d.ts +8 -0
  62. package/dist/dialog/root/types.d.ts +10 -0
  63. package/dist/dialog/root/types.js +1 -0
  64. package/dist/dialog/trigger/dialog-trigger.svelte +71 -0
  65. package/dist/dialog/trigger/dialog-trigger.svelte.d.ts +12 -0
  66. package/dist/hooks/use-virtual-focus.svelte.d.ts +55 -0
  67. package/dist/hooks/use-virtual-focus.svelte.js +201 -0
  68. package/dist/index.d.ts +13 -0
  69. package/dist/index.js +19 -0
  70. package/dist/input/index.d.ts +3 -0
  71. package/dist/input/index.js +3 -0
  72. package/dist/input/input.svelte +19 -0
  73. package/dist/input/input.svelte.d.ts +8 -0
  74. package/dist/label/index.d.ts +3 -0
  75. package/dist/label/index.js +3 -0
  76. package/dist/label/label.svelte +21 -0
  77. package/dist/label/label.svelte.d.ts +8 -0
  78. package/dist/listbox/index.d.ts +6 -0
  79. package/dist/listbox/index.js +10 -0
  80. package/dist/listbox/index.parts.d.ts +2 -0
  81. package/dist/listbox/index.parts.js +3 -0
  82. package/dist/listbox/item/listbox-item.svelte +186 -0
  83. package/dist/listbox/item/listbox-item.svelte.d.ts +34 -0
  84. package/dist/listbox/root/context.d.ts +73 -0
  85. package/dist/listbox/root/context.js +249 -0
  86. package/dist/listbox/root/listbox-numeric-id-test.svelte +18 -0
  87. package/dist/listbox/root/listbox-numeric-id-test.svelte.d.ts +3 -0
  88. package/dist/listbox/root/listbox-test.svelte +27 -0
  89. package/dist/listbox/root/listbox-test.svelte.d.ts +8 -0
  90. package/dist/listbox/root/listbox.svelte +146 -0
  91. package/dist/listbox/root/listbox.svelte.d.ts +54 -0
  92. package/dist/popover/content/popover-content-test.svelte +43 -0
  93. package/dist/popover/content/popover-content-test.svelte.d.ts +12 -0
  94. package/dist/popover/content/popover-content.svelte +167 -0
  95. package/dist/popover/content/popover-content.svelte.d.ts +38 -0
  96. package/dist/popover/index.d.ts +8 -0
  97. package/dist/popover/index.js +14 -0
  98. package/dist/popover/index.parts.d.ts +4 -0
  99. package/dist/popover/index.parts.js +5 -0
  100. package/dist/popover/root/context.d.ts +24 -0
  101. package/dist/popover/root/context.js +10 -0
  102. package/dist/popover/root/popover-root.svelte +87 -0
  103. package/dist/popover/root/popover-root.svelte.d.ts +20 -0
  104. package/dist/popover/root/popover-test.svelte +40 -0
  105. package/dist/popover/root/popover-test.svelte.d.ts +11 -0
  106. package/dist/popover/trigger/popover-trigger-button.svelte +42 -0
  107. package/dist/popover/trigger/popover-trigger-button.svelte.d.ts +12 -0
  108. package/dist/popover/trigger/popover-trigger-in-dialog-test.svelte +29 -0
  109. package/dist/popover/trigger/popover-trigger-in-dialog-test.svelte.d.ts +18 -0
  110. package/dist/popover/trigger/popover-trigger.svelte +71 -0
  111. package/dist/popover/trigger/popover-trigger.svelte.d.ts +12 -0
  112. package/dist/portal/index.d.ts +1 -0
  113. package/dist/portal/index.js +1 -0
  114. package/dist/portal/portal.svelte +44 -0
  115. package/dist/portal/portal.svelte.d.ts +10 -0
  116. package/dist/primitives/aria-hide-outside.d.ts +38 -0
  117. package/dist/primitives/aria-hide-outside.js +152 -0
  118. package/dist/primitives/click-outside.d.ts +26 -0
  119. package/dist/primitives/click-outside.js +66 -0
  120. package/dist/primitives/floating.d.ts +57 -0
  121. package/dist/primitives/floating.js +179 -0
  122. package/dist/primitives/focus-trap.d.ts +19 -0
  123. package/dist/primitives/focus-trap.js +102 -0
  124. package/dist/primitives/index.d.ts +6 -0
  125. package/dist/primitives/index.js +7 -0
  126. package/dist/primitives/keyboard-navigation.d.ts +88 -0
  127. package/dist/primitives/keyboard-navigation.js +274 -0
  128. package/dist/primitives/scroll-lock.d.ts +19 -0
  129. package/dist/primitives/scroll-lock.js +62 -0
  130. package/dist/test-mocks/app-environment.d.ts +7 -0
  131. package/dist/test-mocks/app-environment.js +7 -0
  132. package/dist/test-mocks/app-navigation.d.ts +11 -0
  133. package/dist/test-mocks/app-navigation.js +11 -0
  134. package/dist/test-mocks/app-stores.d.ts +16 -0
  135. package/dist/test-mocks/app-stores.js +18 -0
  136. package/dist/utils/cn.d.ts +2 -0
  137. package/dist/utils/cn.js +5 -0
  138. package/dist/utils/index.d.ts +1 -0
  139. package/dist/utils/index.js +1 -0
  140. package/package.json +99 -0
@@ -0,0 +1,175 @@
1
+ # ComboBox - Code Review & TODOs
2
+
3
+ Comprehensive review based on: **Accessibility**, **Scalability**, **Performance**, **Svelte 5 Runes Best Practices**.
4
+
5
+ ---
6
+
7
+ ## 🔊 Accesibilidad
8
+
9
+ ### Completado ✅
10
+
11
+ - [x] ARIA pattern: `aria-activedescendant` para virtual focus
12
+ - [x] `aria-expanded`, `aria-haspopup`, `aria-controls` en input
13
+ - [x] `aria-label` en ListBox
14
+ - [x] `role="combobox"`, `role="listbox"`, `role="option"`
15
+ - [x] `aria-selected` en items seleccionados
16
+ - [x] `aria-disabled` en items/placeholder deshabilitados
17
+ - [x] Input soporta `aria-label` y `aria-labelledby` props
18
+ - [x] ListBox tiene ID para que `aria-controls` funcione correctamente
19
+ - [x] Button tiene `aria-controls` apuntando al listbox
20
+ - [x] Wrapper group soporta `aria-label` y `aria-labelledby`
21
+ - [x] Input soporta `aria-describedby` para instrucciones de uso
22
+
23
+ ### Pendiente
24
+
25
+ - [ ] **Live regions para conteo de resultados**
26
+ - Agregar `<div aria-live="polite">` que anuncie "{N} resultados disponibles" al filtrar
27
+ - Importante para screen readers que no ven el cambio visual
28
+
29
+ - [ ] **Anuncio de selección**
30
+ - Anunciar "Item seleccionado: {label}" cuando se selecciona
31
+ - Usar `aria-live="assertive"` para cambios importantes
32
+
33
+ - [ ] **Soporte para grupos (sections)**
34
+ - Implementar `role="group"` con `aria-labelledby` para secciones
35
+ - Agregar `ComboBox.Section` component
36
+
37
+ ---
38
+
39
+ ## 📈 Escalabilidad
40
+
41
+ ### Completado ✅
42
+
43
+ - [x] Hook `useVirtualFocus` reutilizable
44
+ - [x] Controlled/uncontrolled mode
45
+ - [x] Filtrado automático en items
46
+ - [x] `emptyPlaceholder` reactivo
47
+
48
+ ### Pendiente
49
+
50
+ - [ ] **`filterFn` prop customizable**
51
+ - Actualmente filtrado es case-insensitive includes
52
+ - Permitir: fuzzy search, startsWith, exact match, async search
53
+
54
+ - [ ] **`allowCreate` prop**
55
+ - Permitir crear nuevos items cuando no hay match
56
+ - Callback `onCreate?: (value: string) => void`
57
+
58
+ - [x] **Multiple selection UI**
59
+ - Chips/tags para items seleccionados ✅ `ComboBox.Tags`, `ComboBox.Tag`, `ComboBox.TagRemove`
60
+ - Clear all button (disponible via `clearSelection()` en context)
61
+ - Contador de seleccionados (disponible via `selectedValue.size`)
62
+ - Navegación de tags con teclado (ArrowLeft/Right, Delete/Backspace)
63
+ - `ComboBox.ItemIndicator` para mostrar checks en items seleccionados
64
+
65
+ - [ ] **Form integration**
66
+ - `name` prop para `<form>` nativo
67
+ - Hidden input con valor serializado
68
+ - Validación con `required`, `aria-invalid`
69
+
70
+ - [ ] **Async data support**
71
+ - Props: `isLoading`, `loadingPlaceholder`
72
+ - Callback: `onLoadMore` para infinite scroll
73
+ - Debounce integrado para búsqueda async
74
+
75
+ - [ ] **Virtualization**
76
+ - Para listas grandes (>100 items)
77
+ - Integrar con `@tanstack/virtual` o similar
78
+
79
+ ---
80
+
81
+ ## ⚡ Performance
82
+
83
+ ### Completado ✅
84
+
85
+ - [x] Cache de DOM queries con invalidación (`cachedItemOrder`)
86
+ - [x] `untrack()` para evitar loops infinitos en effects
87
+ - [x] Subscription pattern para `itemCount` reactivo
88
+ - [x] Scoped queries via `containerRef`
89
+
90
+ ### Pendiente
91
+
92
+ - [ ] **Memoización de `isVisible` en ListBoxItem**
93
+ - Actualmente se recalcula en cada render
94
+ - Considerar memoizar con `$derived` más granular
95
+
96
+ - [ ] **Batch registration**
97
+ - `registerItem` se llama por cada item individualmente
98
+ - Para listas grandes, batch notifications
99
+
100
+ - [ ] **Lazy itemLabels**
101
+ - El Map `itemLabels` crece con cada item
102
+ - Limpiar en unmount está implementado, pero considerar WeakMap
103
+
104
+ - [ ] **Effect cleanup optimizations**
105
+ - Revisar effects que podrían consolidarse
106
+ - `combobox-listboxitem.svelte` tiene 2 effects que podrían ser 1
107
+
108
+ ---
109
+
110
+ ## 🔧 Svelte 5 Runes Best Practices
111
+
112
+ ### Completado ✅
113
+
114
+ - [x] `$state` para estado reactivo
115
+ - [x] `$derived` para valores computados
116
+ - [x] `$effect` con cleanup functions
117
+ - [x] `$bindable` para two-way binding
118
+ - [x] `$props()` para destructuring
119
+ - [x] `untrack()` para evitar re-runs innecesarios
120
+ - [x] `$derived(expression)` en vez de `$derived(() => ...)` - Simplificado en `combobox-listboxitem.svelte`
121
+ - [x] Effects consolidados - Usando 1 `$effect` + `onDestroy` en vez de 2 effects
122
+
123
+ ### Revisado - No requiere cambios
124
+
125
+ - [x] **`$effect.pre`**: Revisado - no hay race conditions que lo requieran
126
+ - [x] **Context typing**: El type único es apropiado - tree-shaking no aplica a context objects
127
+
128
+ ---
129
+
130
+ ## 🧪 Testing
131
+
132
+ ### Completado ✅
133
+
134
+ - [x] 291 tests unitarios pasando
135
+ - [x] Keyboard navigation tests
136
+ - [x] Selection tests
137
+ - [x] Filtering tests
138
+ - [x] Empty placeholder tests
139
+ - [x] ARIA accessibility tests (6 tests)
140
+ - [x] Edge cases: rapid typing, whitespace, backspace
141
+ - [x] Disabled/ReadOnly state tests
142
+ - [x] Trigger modes (focus, input, manual)
143
+ - [x] Selection behavior (Enter, click, Escape restoration)
144
+ - [x] Multi-select tests (12 tests)
145
+ - [x] Tags component tests (4 tests)
146
+ - [x] Tag component tests (13 tests) - incluye navegación por teclado
147
+ - [x] TagRemove component tests (6 tests)
148
+ - [x] ItemIndicator component tests (5 tests)
149
+
150
+ ### Pendiente
151
+
152
+ - [ ] **Tests con muchos items (100+)** - performance tests
153
+ - [ ] **Visual regression tests** - screenshots de estados
154
+
155
+ ---
156
+
157
+ ## 📝 Documentación
158
+
159
+ - [ ] **JSDoc completo**
160
+ - Documentar todas las props públicas
161
+ - Ejemplos de uso en comments
162
+
163
+ - [ ] **Storybook/Demo page**
164
+ - Ejemplos interactivos de todos los casos de uso
165
+ - Estados: loading, error, disabled, readonly
166
+
167
+ ---
168
+
169
+ ## 🎯 Próximos Pasos Priorizados
170
+
171
+ 1. **Live regions** (accessibility - alto impacto)
172
+ 2. **Form integration** (usabilidad - casos comunes)
173
+ 3. **`filterFn` customizable** (escalabilidad)
174
+ 4. **Consolidar effects** (performance/best practices)
175
+ 5. **Async data support** (escalabilidad)
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLButtonAttributes } from 'svelte/elements';
4
+ import { useComboBoxContext } from '../root/context';
5
+
6
+ type ComboBoxButtonProps = HTMLButtonAttributes & {
7
+ class?: string;
8
+ children?: Snippet;
9
+ };
10
+
11
+ let { class: className, children, tabindex = -1, ...restProps }: ComboBoxButtonProps = $props();
12
+
13
+ const ctx = useComboBoxContext();
14
+
15
+ // Use onmousedown with preventDefault to prevent blur from firing
16
+ // before the toggle. This prevents the race condition where:
17
+ // 1. Click button -> blur fires -> popover closes
18
+ // 2. Then onclick fires -> popover opens again
19
+ function handleMouseDown(e: MouseEvent) {
20
+ e.preventDefault();
21
+ if (!ctx.isDisabled && !ctx.isReadOnly) {
22
+ ctx.toggle();
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <button
28
+ type="button"
29
+ {tabindex}
30
+ aria-label={ctx.isOpen ? 'Close menu' : 'Open menu'}
31
+ aria-expanded={ctx.isOpen}
32
+ aria-controls={`combobox-listbox-${ctx.instanceId}`}
33
+ disabled={ctx.isDisabled}
34
+ data-pressed={ctx.isOpen}
35
+ onmousedown={handleMouseDown}
36
+ class={className}
37
+ {...restProps}
38
+ >
39
+ {#if children}
40
+ {@render children()}
41
+ {:else}
42
+ <svg
43
+ xmlns="http://www.w3.org/2000/svg"
44
+ width="16"
45
+ height="16"
46
+ viewBox="0 0 24 24"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ stroke-width="2"
50
+ stroke-linecap="round"
51
+ stroke-linejoin="round"
52
+ class="transition-transform {ctx.isOpen ? 'rotate-180' : ''}"
53
+ >
54
+ <path d="m6 9 6 6 6-6" />
55
+ </svg>
56
+ {/if}
57
+ </button>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ type ComboBoxButtonProps = HTMLButtonAttributes & {
4
+ class?: string;
5
+ children?: Snippet;
6
+ };
7
+ declare const ComboboxButton: import("svelte").Component<ComboBoxButtonProps, {}, "">;
8
+ type ComboboxButton = ReturnType<typeof ComboboxButton>;
9
+ export default ComboboxButton;
@@ -0,0 +1,14 @@
1
+ export * as ComboBox from './index.parts.ts';
2
+ export { default as ComboBoxRoot } from './root/combobox.svelte';
3
+ export { default as ComboBoxInput } from './input/combobox-input.svelte';
4
+ export { default as ComboBoxButton } from './button/combobox-button.svelte';
5
+ export { default as ComboBoxPopover } from './popover/combobox-popover.svelte';
6
+ export { default as ComboBoxList } from './list/combobox-listbox.svelte';
7
+ export { default as ComboBoxItem } from './item/combobox-listboxitem.svelte';
8
+ export { default as ComboBoxItemIndicator } from './item-indicator/combobox-item-indicator.svelte';
9
+ export { default as ComboBoxTags } from './tags/combobox-tags.svelte';
10
+ export { default as ComboBoxTag } from './tag/combobox-tag.svelte';
11
+ export { default as ComboBoxTagRemove } from './tag-remove/combobox-tag-remove.svelte';
12
+ export { getComboBoxContext, setComboBoxContext, useComboBoxContext, type ComboBoxContext } from './root/context.ts';
13
+ import * as ComboBoxParts from './index.parts.ts';
14
+ export default ComboBoxParts;
@@ -0,0 +1,18 @@
1
+ // Namespace export for component composition: <ComboBox.Root>, <ComboBox.Input>, etc.
2
+ export * as ComboBox from './index.parts.ts';
3
+ // Direct named exports for individual imports
4
+ export { default as ComboBoxRoot } from './root/combobox.svelte';
5
+ export { default as ComboBoxInput } from './input/combobox-input.svelte';
6
+ export { default as ComboBoxButton } from './button/combobox-button.svelte';
7
+ export { default as ComboBoxPopover } from './popover/combobox-popover.svelte';
8
+ export { default as ComboBoxList } from './list/combobox-listbox.svelte';
9
+ export { default as ComboBoxItem } from './item/combobox-listboxitem.svelte';
10
+ export { default as ComboBoxItemIndicator } from './item-indicator/combobox-item-indicator.svelte';
11
+ export { default as ComboBoxTags } from './tags/combobox-tags.svelte';
12
+ export { default as ComboBoxTag } from './tag/combobox-tag.svelte';
13
+ export { default as ComboBoxTagRemove } from './tag-remove/combobox-tag-remove.svelte';
14
+ // Context and types
15
+ export { getComboBoxContext, setComboBoxContext, useComboBoxContext } from './root/context.ts';
16
+ // Default export as namespace object
17
+ import * as ComboBoxParts from './index.parts.ts';
18
+ export default ComboBoxParts;
@@ -0,0 +1,10 @@
1
+ export { default as Root } from './root/combobox.svelte';
2
+ export { default as Input } from './input/combobox-input.svelte';
3
+ export { default as Button } from './button/combobox-button.svelte';
4
+ export { default as Popover } from './popover/combobox-popover.svelte';
5
+ export { default as List } from './list/combobox-listbox.svelte';
6
+ export { default as Item } from './item/combobox-listboxitem.svelte';
7
+ export { default as ItemIndicator } from './item-indicator/combobox-item-indicator.svelte';
8
+ export { default as Tags } from './tags/combobox-tags.svelte';
9
+ export { default as Tag } from './tag/combobox-tag.svelte';
10
+ export { default as TagRemove } from './tag-remove/combobox-tag-remove.svelte';
@@ -0,0 +1,11 @@
1
+ // Short alias exports for namespace usage: ComboBox.Root, ComboBox.Input, etc.
2
+ export { default as Root } from './root/combobox.svelte';
3
+ export { default as Input } from './input/combobox-input.svelte';
4
+ export { default as Button } from './button/combobox-button.svelte';
5
+ export { default as Popover } from './popover/combobox-popover.svelte';
6
+ export { default as List } from './list/combobox-listbox.svelte';
7
+ export { default as Item } from './item/combobox-listboxitem.svelte';
8
+ export { default as ItemIndicator } from './item-indicator/combobox-item-indicator.svelte';
9
+ export { default as Tags } from './tags/combobox-tags.svelte';
10
+ export { default as Tag } from './tag/combobox-tag.svelte';
11
+ export { default as TagRemove } from './tag-remove/combobox-tag-remove.svelte';
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { useComboBoxContext } from '../root/context';
4
+ import { cn } from '../../utils/cn';
5
+
6
+ type ComboBoxInputProps = HTMLInputAttributes & {
7
+ /** Accessible label for the input */
8
+ 'aria-label'?: string;
9
+ /** ID of element that labels this input */
10
+ 'aria-labelledby'?: string;
11
+ /** ID of element that describes this input (e.g., usage instructions) */
12
+ 'aria-describedby'?: string;
13
+ class?: string;
14
+ };
15
+
16
+ let {
17
+ 'aria-label': ariaLabel,
18
+ 'aria-labelledby': ariaLabelledby,
19
+ 'aria-describedby': ariaDescribedby,
20
+ class: className,
21
+ ...restProps
22
+ }: ComboBoxInputProps = $props();
23
+
24
+ let inputRef: HTMLInputElement | null = $state(null);
25
+ const ctx = useComboBoxContext();
26
+
27
+ $effect(() => {
28
+ if (inputRef) {
29
+ ctx.setInputRef(inputRef);
30
+ ctx.setTriggerRef(inputRef);
31
+ }
32
+ });
33
+
34
+ function handleInput(event: Event) {
35
+ ctx.setFocusedTagId(null);
36
+ const target = event.target as HTMLInputElement;
37
+ ctx.setInputValue(target.value);
38
+ // Open on input for all trigger modes when user types
39
+ if (!ctx.isOpen && target.value.length > 0) {
40
+ ctx.open();
41
+ }
42
+ }
43
+
44
+ function handleFocus() {
45
+ // Open on focus if trigger is 'focus'
46
+ // Use a small delay to avoid opening immediately on programmatic focus
47
+ // (e.g., from a focus trap). This gives time for refs to be set up.
48
+ if (ctx.trigger === 'focus' && !ctx.isOpen) {
49
+ requestAnimationFrame(() => {
50
+ if (ctx.trigger === 'focus' && !ctx.isOpen) {
51
+ ctx.open();
52
+ }
53
+ });
54
+ }
55
+ }
56
+
57
+ function handleMouseDown() {
58
+ ctx.setFocusedTagId(null);
59
+ // Open on press if trigger is 'press'
60
+ if (ctx.trigger === 'press' && !ctx.isOpen && !ctx.isDisabled && !ctx.isReadOnly) {
61
+ ctx.open();
62
+ }
63
+ }
64
+
65
+ function handleBlur() {
66
+ // Restore selection label or deselect if empty
67
+ ctx.handleInputBlur();
68
+ }
69
+ </script>
70
+
71
+ <input
72
+ bind:this={inputRef}
73
+ type="text"
74
+ role="combobox"
75
+ aria-autocomplete="list"
76
+ aria-expanded={ctx.isOpen}
77
+ aria-haspopup="listbox"
78
+ aria-controls={`combobox-listbox-${ctx.instanceId}`}
79
+ aria-activedescendant={ctx.focusedItemId !== null
80
+ ? `combobox-item-${ctx.instanceId}-${ctx.focusedItemId}`
81
+ : undefined}
82
+ aria-label={ariaLabel}
83
+ aria-labelledby={ariaLabelledby}
84
+ aria-describedby={ariaDescribedby}
85
+ value={ctx.displayValue}
86
+ disabled={ctx.isDisabled}
87
+ readonly={ctx.isReadOnly}
88
+ oninput={handleInput}
89
+ onfocus={handleFocus}
90
+ onmousedown={handleMouseDown}
91
+ onblur={handleBlur}
92
+ onkeydown={ctx.handleKeydown}
93
+ class={cn(
94
+ 'bg-depth-2 sunken placeholder:text-muted-foreground hover:bg-depth-1 focus:ring-border h-8 w-full rounded-xs border px-2 text-sm shadow-xs transition-all ease-out outline-none focus:ring focus:ring-offset-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
95
+ className
96
+ )}
97
+ {...restProps}
98
+ />
@@ -0,0 +1,13 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ type ComboBoxInputProps = HTMLInputAttributes & {
3
+ /** Accessible label for the input */
4
+ 'aria-label'?: string;
5
+ /** ID of element that labels this input */
6
+ 'aria-labelledby'?: string;
7
+ /** ID of element that describes this input (e.g., usage instructions) */
8
+ 'aria-describedby'?: string;
9
+ class?: string;
10
+ };
11
+ declare const ComboboxInput: import("svelte").Component<ComboBoxInputProps, {}, "">;
12
+ type ComboboxInput = ReturnType<typeof ComboboxInput>;
13
+ export default ComboboxInput;
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import { ComboBox } from '../index';
3
+
4
+ let selected = $state<string | number | undefined>();
5
+ </script>
6
+
7
+ <ComboBox.Root bind:value={selected}>
8
+ <ComboBox.Input placeholder="Search countries..." />
9
+ <ComboBox.Button />
10
+
11
+ <ComboBox.Popover>
12
+ <ComboBox.List emptyPlaceholder="No countries found">
13
+ <!-- Intentionally omit textValue to validate implicit text extraction -->
14
+ <ComboBox.Item id="ar">Argentina</ComboBox.Item>
15
+ <ComboBox.Item id="br">Brazil</ComboBox.Item>
16
+ <ComboBox.Item id="ca">Canada</ComboBox.Item>
17
+ </ComboBox.List>
18
+ </ComboBox.Popover>
19
+ </ComboBox.Root>
20
+
21
+ <div data-testid="selected">{selected ?? ''}</div>
@@ -0,0 +1,3 @@
1
+ declare const ComboboxItemImplicitTextTest: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ComboboxItemImplicitTextTest = ReturnType<typeof ComboboxItemImplicitTextTest>;
3
+ export default ComboboxItemImplicitTextTest;
@@ -0,0 +1,136 @@
1
+ <script lang="ts" module>
2
+ export const COMBOBOX_ITEM_CONTEXT_KEY = Symbol.for('combobox-item');
3
+
4
+ export type ComboBoxItemContext = {
5
+ id: string | number;
6
+ };
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import type { ComponentProps } from 'svelte';
11
+ import { untrack, onDestroy, setContext } from 'svelte';
12
+ import ListBoxItem from '../../listbox/item/listbox-item.svelte';
13
+ import { useComboBoxContext } from '../root/context';
14
+ import { cn } from '../../utils/cn';
15
+
16
+ /**
17
+ * ComboBox.ListBoxItem wraps ListBox.Item and provides ComboBox-specific behavior:
18
+ * - Virtual focus (aria-activedescendant pattern)
19
+ * - Unique ID with instanceId for multiple comboboxes
20
+ * - Registration with ComboBox context for navigation
21
+ * - Scroll on focus for keyboard navigation
22
+ * - Automatic filtering based on inputValue
23
+ */
24
+ type ComboBoxListBoxItemProps = Omit<
25
+ ComponentProps<typeof ListBoxItem>,
26
+ // Internal override props that ComboBox.ListBoxItem controls
27
+ | 'customId'
28
+ | 'disableFocusHandling'
29
+ | 'isFocusedOverride'
30
+ | 'onItemSelect'
31
+ | 'onResolvedTextValue'
32
+ | 'scrollOnFocus'
33
+ | 'isParentDisabled'
34
+ >;
35
+
36
+ let { id, class: className, ...props }: ComboBoxListBoxItemProps = $props();
37
+
38
+ const ctx = useComboBoxContext();
39
+
40
+ // Provide item context for child components like ItemIndicator
41
+ setContext<ComboBoxItemContext>(COMBOBOX_ITEM_CONTEXT_KEY, {
42
+ get id() {
43
+ return id;
44
+ }
45
+ });
46
+
47
+ // Text value for filtering and display.
48
+ // If textValue prop is omitted, resolve it from rendered content on mount.
49
+ let resolvedTextValue = $state<string | null>(props.textValue ?? null);
50
+ const effectiveTextValue = $derived(resolvedTextValue ?? '');
51
+
52
+ $effect(() => {
53
+ if (props.textValue !== undefined) {
54
+ resolvedTextValue = props.textValue;
55
+ }
56
+ });
57
+
58
+ // Normalized input for filtering comparison
59
+ const normalizedInput = $derived(ctx.inputValue.trim().toLowerCase());
60
+
61
+ // Automatic filtering: if text is not resolved yet, keep item visible until mount resolves it.
62
+ const isVisible = $derived(
63
+ !normalizedInput ||
64
+ !effectiveTextValue ||
65
+ effectiveTextValue.toLowerCase().includes(normalizedInput)
66
+ );
67
+
68
+ // Virtual focus from ComboBox context
69
+ const isFocused = $derived(ctx.focusedItemId === id);
70
+
71
+ // Generate unique ID using instanceId
72
+ const uniqueId = $derived(`combobox-item-${ctx.instanceId}-${id}`);
73
+
74
+ // Track registration state to avoid re-registering
75
+ let isRegistered = $state(false);
76
+
77
+ // Reactive registration: register when visible, unregister when hidden
78
+ $effect(() => {
79
+ const visible = isVisible;
80
+ const label = effectiveTextValue || String(id);
81
+ const itemId = id;
82
+
83
+ untrack(() => {
84
+ if (visible && !isRegistered) {
85
+ ctx.registerItem(itemId, label);
86
+ isRegistered = true;
87
+ } else if (!visible && isRegistered) {
88
+ ctx.unregisterItem(itemId);
89
+ isRegistered = false;
90
+ }
91
+ });
92
+ });
93
+
94
+ function handleResolvedTextValue(label: string) {
95
+ if (!label) return;
96
+ resolvedTextValue = label;
97
+ // Update label map when already registered.
98
+ if (isRegistered) {
99
+ ctx.registerItem(id, label);
100
+ }
101
+ }
102
+
103
+ // Cleanup on component destroy - use onDestroy for clearer semantics
104
+ onDestroy(() => {
105
+ if (isRegistered) {
106
+ ctx.unregisterItem(id);
107
+ }
108
+ });
109
+
110
+ // Custom select handler that uses ComboBox context
111
+ function handleSelect(itemId: string | number, label: string) {
112
+ ctx.select(itemId, label);
113
+ }
114
+ </script>
115
+
116
+ {#if isVisible}
117
+ <ListBoxItem
118
+ {id}
119
+ {...props}
120
+ textValue={props.textValue}
121
+ customId={uniqueId}
122
+ disableFocusHandling={true}
123
+ isFocusedOverride={isFocused}
124
+ onItemSelect={handleSelect}
125
+ onResolvedTextValue={handleResolvedTextValue}
126
+ scrollOnFocus={true}
127
+ isParentDisabled={ctx.isDisabled}
128
+ class={cn(
129
+ 'cursor-pointer px-3 py-2 transition-colors outline-none',
130
+ 'data-focused:bg-accent data-hovered:bg-accent',
131
+ 'data-selected:bg-primary/10 data-selected:font-medium',
132
+ 'data-disabled:pointer-events-none data-disabled:opacity-50',
133
+ className
134
+ )}
135
+ />
136
+ {/if}
@@ -0,0 +1,18 @@
1
+ export declare const COMBOBOX_ITEM_CONTEXT_KEY: unique symbol;
2
+ export type ComboBoxItemContext = {
3
+ id: string | number;
4
+ };
5
+ import type { ComponentProps } from 'svelte';
6
+ import ListBoxItem from '../../listbox/item/listbox-item.svelte';
7
+ /**
8
+ * ComboBox.ListBoxItem wraps ListBox.Item and provides ComboBox-specific behavior:
9
+ * - Virtual focus (aria-activedescendant pattern)
10
+ * - Unique ID with instanceId for multiple comboboxes
11
+ * - Registration with ComboBox context for navigation
12
+ * - Scroll on focus for keyboard navigation
13
+ * - Automatic filtering based on inputValue
14
+ */
15
+ type ComboBoxListBoxItemProps = Omit<ComponentProps<typeof ListBoxItem>, 'customId' | 'disableFocusHandling' | 'isFocusedOverride' | 'onItemSelect' | 'onResolvedTextValue' | 'scrollOnFocus' | 'isParentDisabled'>;
16
+ declare const ComboboxListboxitem: import("svelte").Component<ComboBoxListBoxItemProps, {}, "">;
17
+ type ComboboxListboxitem = ReturnType<typeof ComboboxListboxitem>;
18
+ export default ComboboxListboxitem;
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { getContext } from 'svelte';
5
+ import { cn } from '../../utils/cn';
6
+ import { useComboBoxContext } from '../root/context';
7
+ import {
8
+ COMBOBOX_ITEM_CONTEXT_KEY,
9
+ type ComboBoxItemContext
10
+ } from '../item/combobox-listboxitem.svelte';
11
+
12
+ /**
13
+ * ComboBox.ItemIndicator - Visual indicator shown when an item is selected.
14
+ * Must be used inside ComboBox.Item.
15
+ * Only renders when the parent item is selected.
16
+ */
17
+ type ComboBoxItemIndicatorProps = {
18
+ /** Content to render when selected (defaults to checkmark icon) */
19
+ children?: Snippet;
20
+ /** Force show the indicator regardless of selection state */
21
+ forceMount?: boolean;
22
+ class?: string;
23
+ } & Omit<HTMLAttributes<HTMLSpanElement>, 'class' | 'children'>;
24
+
25
+ let {
26
+ children,
27
+ forceMount = false,
28
+ class: className,
29
+ ...restProps
30
+ }: ComboBoxItemIndicatorProps = $props();
31
+
32
+ const comboboxCtx = useComboBoxContext();
33
+ const itemCtx = getContext<ComboBoxItemContext>(COMBOBOX_ITEM_CONTEXT_KEY);
34
+
35
+ const isSelected = $derived(comboboxCtx.selectedValue.has(itemCtx.id));
36
+ const shouldRender = $derived(forceMount || isSelected);
37
+ </script>
38
+
39
+ {#if shouldRender}
40
+ <span
41
+ aria-hidden="true"
42
+ data-state={isSelected ? 'checked' : 'unchecked'}
43
+ class={cn('inline-flex items-center justify-center', className)}
44
+ {...restProps}
45
+ >
46
+ {#if children}
47
+ {@render children()}
48
+ {:else}
49
+ <svg
50
+ xmlns="http://www.w3.org/2000/svg"
51
+ viewBox="0 0 16 16"
52
+ fill="currentColor"
53
+ class="h-4 w-4"
54
+ >
55
+ <path
56
+ fill-rule="evenodd"
57
+ d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
58
+ clip-rule="evenodd"
59
+ />
60
+ </svg>
61
+ {/if}
62
+ </span>
63
+ {/if}
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ /**
4
+ * ComboBox.ItemIndicator - Visual indicator shown when an item is selected.
5
+ * Must be used inside ComboBox.Item.
6
+ * Only renders when the parent item is selected.
7
+ */
8
+ type ComboBoxItemIndicatorProps = {
9
+ /** Content to render when selected (defaults to checkmark icon) */
10
+ children?: Snippet;
11
+ /** Force show the indicator regardless of selection state */
12
+ forceMount?: boolean;
13
+ class?: string;
14
+ } & Omit<HTMLAttributes<HTMLSpanElement>, 'class' | 'children'>;
15
+ declare const ComboboxItemIndicator: import("svelte").Component<ComboBoxItemIndicatorProps, {}, "">;
16
+ type ComboboxItemIndicator = ReturnType<typeof ComboboxItemIndicator>;
17
+ export default ComboboxItemIndicator;