kiso 0.1.0.pre → 0.2.0.pre

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 (236) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -2
  3. data/README.md +67 -27
  4. data/Rakefile +8 -0
  5. data/app/assets/tailwind/kiso/checkbox.css +18 -0
  6. data/app/assets/tailwind/kiso/color-mode.css +9 -0
  7. data/app/assets/tailwind/kiso/dashboard.css +194 -0
  8. data/app/assets/tailwind/kiso/engine.css +117 -0
  9. data/app/assets/tailwind/kiso/input-otp.css +10 -0
  10. data/app/assets/tailwind/kiso/radio-group.css +17 -0
  11. data/app/helpers/kiso/component_helper.rb +46 -27
  12. data/app/helpers/kiso/icon_helper.rb +53 -9
  13. data/app/helpers/kiso/theme_helper.rb +38 -0
  14. data/app/javascript/controllers/kiso/combobox_controller.js +616 -0
  15. data/app/javascript/controllers/kiso/command_controller.js +184 -0
  16. data/app/javascript/controllers/kiso/command_dialog_controller.js +104 -0
  17. data/app/javascript/controllers/kiso/dropdown_menu_controller.js +684 -0
  18. data/app/javascript/controllers/kiso/index.d.ts +12 -0
  19. data/app/javascript/controllers/kiso/index.js +42 -0
  20. data/app/javascript/controllers/kiso/input_otp_controller.js +195 -0
  21. data/app/javascript/controllers/kiso/popover_controller.js +254 -0
  22. data/app/javascript/controllers/kiso/select_controller.js +307 -0
  23. data/app/javascript/controllers/kiso/sidebar_controller.js +84 -0
  24. data/app/javascript/controllers/kiso/theme_controller.js +89 -0
  25. data/app/javascript/controllers/kiso/toggle_controller.js +24 -0
  26. data/app/javascript/controllers/kiso/toggle_group_controller.js +128 -0
  27. data/app/javascript/kiso/utils/focusable.js +8 -0
  28. data/app/javascript/kiso/utils/highlight.js +43 -0
  29. data/app/javascript/kiso/utils/positioning.js +86 -0
  30. data/app/javascript/kiso/vendor/floating-ui-core.js +1 -0
  31. data/app/javascript/kiso/vendor/floating-ui-dom.js +1 -0
  32. data/app/views/kiso/components/_alert.html.erb +1 -1
  33. data/app/views/kiso/components/_avatar.html.erb +23 -0
  34. data/app/views/kiso/components/_badge.html.erb +1 -1
  35. data/app/views/kiso/components/_breadcrumb.html.erb +8 -0
  36. data/app/views/kiso/components/_button.html.erb +1 -1
  37. data/app/views/kiso/components/_card.html.erb +1 -1
  38. data/app/views/kiso/components/_checkbox.html.erb +7 -0
  39. data/app/views/kiso/components/_color_mode_button.html.erb +14 -0
  40. data/app/views/kiso/components/_color_mode_select.html.erb +24 -0
  41. data/app/views/kiso/components/_combobox.html.erb +12 -0
  42. data/app/views/kiso/components/_command.html.erb +7 -0
  43. data/app/views/kiso/components/_dashboard_group.html.erb +14 -0
  44. data/app/views/kiso/components/_dashboard_navbar.html.erb +7 -0
  45. data/app/views/kiso/components/_dashboard_panel.html.erb +7 -0
  46. data/app/views/kiso/components/_dashboard_sidebar.html.erb +11 -0
  47. data/app/views/kiso/components/_dashboard_toolbar.html.erb +7 -0
  48. data/app/views/kiso/components/_dropdown_menu.html.erb +7 -0
  49. data/app/views/kiso/components/{_empty_state.html.erb → _empty.html.erb} +2 -2
  50. data/app/views/kiso/components/_field.html.erb +12 -0
  51. data/app/views/kiso/components/_field_group.html.erb +7 -0
  52. data/app/views/kiso/components/_field_set.html.erb +7 -0
  53. data/app/views/kiso/components/_input.html.erb +8 -0
  54. data/app/views/kiso/components/_input_group.html.erb +8 -0
  55. data/app/views/kiso/components/_input_otp.html.erb +22 -0
  56. data/app/views/kiso/components/_kbd.html.erb +7 -0
  57. data/app/views/kiso/components/_label.html.erb +5 -0
  58. data/app/views/kiso/components/_nav.html.erb +7 -0
  59. data/app/views/kiso/components/_pagination.html.erb +9 -0
  60. data/app/views/kiso/components/_popover.html.erb +8 -0
  61. data/app/views/kiso/components/_radio_group.html.erb +8 -0
  62. data/app/views/kiso/components/_select.html.erb +8 -0
  63. data/app/views/kiso/components/_select_native.html.erb +16 -0
  64. data/app/views/kiso/components/_separator.html.erb +1 -1
  65. data/app/views/kiso/components/_stats_card.html.erb +1 -1
  66. data/app/views/kiso/components/_stats_grid.html.erb +1 -1
  67. data/app/views/kiso/components/_switch.html.erb +10 -0
  68. data/app/views/kiso/components/_table.html.erb +2 -1
  69. data/app/views/kiso/components/_textarea.html.erb +9 -0
  70. data/app/views/kiso/components/_toggle.html.erb +12 -0
  71. data/app/views/kiso/components/_toggle_group.html.erb +12 -0
  72. data/app/views/kiso/components/alert/_description.html.erb +1 -1
  73. data/app/views/kiso/components/alert/_title.html.erb +1 -1
  74. data/app/views/kiso/components/avatar/_badge.html.erb +7 -0
  75. data/app/views/kiso/components/avatar/_fallback.html.erb +7 -0
  76. data/app/views/kiso/components/avatar/_group.html.erb +7 -0
  77. data/app/views/kiso/components/avatar/_group_count.html.erb +7 -0
  78. data/app/views/kiso/components/avatar/_image.html.erb +6 -0
  79. data/app/views/kiso/components/breadcrumb/_ellipsis.html.erb +10 -0
  80. data/app/views/kiso/components/breadcrumb/_item.html.erb +7 -0
  81. data/app/views/kiso/components/breadcrumb/_link.html.erb +7 -0
  82. data/app/views/kiso/components/breadcrumb/_list.html.erb +7 -0
  83. data/app/views/kiso/components/breadcrumb/_page.html.erb +9 -0
  84. data/app/views/kiso/components/breadcrumb/_separator.html.erb +9 -0
  85. data/app/views/kiso/components/card/_action.html.erb +7 -0
  86. data/app/views/kiso/components/card/_content.html.erb +1 -1
  87. data/app/views/kiso/components/card/_description.html.erb +1 -1
  88. data/app/views/kiso/components/card/_footer.html.erb +1 -1
  89. data/app/views/kiso/components/card/_header.html.erb +1 -1
  90. data/app/views/kiso/components/card/_title.html.erb +1 -1
  91. data/app/views/kiso/components/combobox/_chip.html.erb +19 -0
  92. data/app/views/kiso/components/combobox/_chips.html.erb +20 -0
  93. data/app/views/kiso/components/combobox/_chips_input.html.erb +10 -0
  94. data/app/views/kiso/components/combobox/_content.html.erb +9 -0
  95. data/app/views/kiso/components/combobox/_empty.html.erb +9 -0
  96. data/app/views/kiso/components/combobox/_group.html.erb +8 -0
  97. data/app/views/kiso/components/combobox/_input.html.erb +23 -0
  98. data/app/views/kiso/components/combobox/_item.html.erb +19 -0
  99. data/app/views/kiso/components/combobox/_label.html.erb +7 -0
  100. data/app/views/kiso/components/combobox/_list.html.erb +10 -0
  101. data/app/views/kiso/components/combobox/_separator.html.erb +6 -0
  102. data/app/views/kiso/components/command/_dialog.html.erb +11 -0
  103. data/app/views/kiso/components/command/_empty.html.erb +9 -0
  104. data/app/views/kiso/components/command/_group.html.erb +14 -0
  105. data/app/views/kiso/components/command/_input.html.erb +16 -0
  106. data/app/views/kiso/components/command/_item.html.erb +13 -0
  107. data/app/views/kiso/components/command/_list.html.erb +10 -0
  108. data/app/views/kiso/components/command/_separator.html.erb +7 -0
  109. data/app/views/kiso/components/command/_shortcut.html.erb +7 -0
  110. data/app/views/kiso/components/dashboard_navbar/_toggle.html.erb +11 -0
  111. data/app/views/kiso/components/dashboard_sidebar/_collapse.html.erb +12 -0
  112. data/app/views/kiso/components/dashboard_sidebar/_footer.html.erb +7 -0
  113. data/app/views/kiso/components/dashboard_sidebar/_header.html.erb +7 -0
  114. data/app/views/kiso/components/dashboard_sidebar/_toggle.html.erb +11 -0
  115. data/app/views/kiso/components/dashboard_toolbar/_left.html.erb +7 -0
  116. data/app/views/kiso/components/dashboard_toolbar/_right.html.erb +7 -0
  117. data/app/views/kiso/components/dropdown_menu/_checkbox_item.html.erb +18 -0
  118. data/app/views/kiso/components/dropdown_menu/_content.html.erb +10 -0
  119. data/app/views/kiso/components/dropdown_menu/_group.html.erb +8 -0
  120. data/app/views/kiso/components/dropdown_menu/_item.html.erb +15 -0
  121. data/app/views/kiso/components/dropdown_menu/_label.html.erb +8 -0
  122. data/app/views/kiso/components/dropdown_menu/_radio_group.html.erb +10 -0
  123. data/app/views/kiso/components/dropdown_menu/_radio_item.html.erb +19 -0
  124. data/app/views/kiso/components/dropdown_menu/_separator.html.erb +6 -0
  125. data/app/views/kiso/components/dropdown_menu/_shortcut.html.erb +7 -0
  126. data/app/views/kiso/components/dropdown_menu/_sub.html.erb +8 -0
  127. data/app/views/kiso/components/dropdown_menu/_sub_content.html.erb +10 -0
  128. data/app/views/kiso/components/dropdown_menu/_sub_trigger.html.erb +12 -0
  129. data/app/views/kiso/components/dropdown_menu/_trigger.html.erb +9 -0
  130. data/app/views/kiso/components/empty/_content.html.erb +7 -0
  131. data/app/views/kiso/components/empty/_description.html.erb +7 -0
  132. data/app/views/kiso/components/empty/_header.html.erb +7 -0
  133. data/app/views/kiso/components/empty/_media.html.erb +7 -0
  134. data/app/views/kiso/components/empty/_title.html.erb +7 -0
  135. data/app/views/kiso/components/field/_content.html.erb +7 -0
  136. data/app/views/kiso/components/field/_description.html.erb +7 -0
  137. data/app/views/kiso/components/field/_error.html.erb +22 -0
  138. data/app/views/kiso/components/field/_label.html.erb +5 -0
  139. data/app/views/kiso/components/field/_separator.html.erb +15 -0
  140. data/app/views/kiso/components/field/_title.html.erb +7 -0
  141. data/app/views/kiso/components/field_set/_legend.html.erb +9 -0
  142. data/app/views/kiso/components/input_group/_addon.html.erb +7 -0
  143. data/app/views/kiso/components/input_otp/_group.html.erb +7 -0
  144. data/app/views/kiso/components/input_otp/_separator.html.erb +8 -0
  145. data/app/views/kiso/components/input_otp/_slot.html.erb +11 -0
  146. data/app/views/kiso/components/kbd/_group.html.erb +7 -0
  147. data/app/views/kiso/components/nav/_item.html.erb +15 -0
  148. data/app/views/kiso/components/nav/_section.html.erb +37 -0
  149. data/app/views/kiso/components/nav/_section_title.html.erb +7 -0
  150. data/app/views/kiso/components/pagination/_content.html.erb +7 -0
  151. data/app/views/kiso/components/pagination/_ellipsis.html.erb +9 -0
  152. data/app/views/kiso/components/pagination/_item.html.erb +7 -0
  153. data/app/views/kiso/components/pagination/_link.html.erb +9 -0
  154. data/app/views/kiso/components/pagination/_next.html.erb +12 -0
  155. data/app/views/kiso/components/pagination/_previous.html.erb +12 -0
  156. data/app/views/kiso/components/popover/_anchor.html.erb +8 -0
  157. data/app/views/kiso/components/popover/_content.html.erb +11 -0
  158. data/app/views/kiso/components/popover/_description.html.erb +7 -0
  159. data/app/views/kiso/components/popover/_header.html.erb +7 -0
  160. data/app/views/kiso/components/popover/_title.html.erb +7 -0
  161. data/app/views/kiso/components/popover/_trigger.html.erb +9 -0
  162. data/app/views/kiso/components/radio_group/_item.html.erb +6 -0
  163. data/app/views/kiso/components/select/_content.html.erb +10 -0
  164. data/app/views/kiso/components/select/_group.html.erb +8 -0
  165. data/app/views/kiso/components/select/_item.html.erb +19 -0
  166. data/app/views/kiso/components/select/_label.html.erb +7 -0
  167. data/app/views/kiso/components/select/_separator.html.erb +6 -0
  168. data/app/views/kiso/components/select/_trigger.html.erb +13 -0
  169. data/app/views/kiso/components/select/_value.html.erb +11 -0
  170. data/app/views/kiso/components/stats_card/_description.html.erb +1 -1
  171. data/app/views/kiso/components/stats_card/_header.html.erb +1 -1
  172. data/app/views/kiso/components/stats_card/_label.html.erb +1 -1
  173. data/app/views/kiso/components/stats_card/_value.html.erb +1 -1
  174. data/app/views/kiso/components/table/_body.html.erb +1 -1
  175. data/app/views/kiso/components/table/_caption.html.erb +1 -1
  176. data/app/views/kiso/components/table/_cell.html.erb +1 -1
  177. data/app/views/kiso/components/table/_footer.html.erb +1 -1
  178. data/app/views/kiso/components/table/_head.html.erb +1 -1
  179. data/app/views/kiso/components/table/_header.html.erb +1 -1
  180. data/app/views/kiso/components/table/_row.html.erb +1 -1
  181. data/app/views/kiso/components/toggle_group/_item.html.erb +13 -0
  182. data/config/deploy.docs.yml +31 -0
  183. data/config/deploy.yml +34 -0
  184. data/config/importmap.rb +10 -0
  185. data/lib/kiso/cli/base.rb +15 -0
  186. data/lib/kiso/cli/icons.rb +2 -1
  187. data/lib/kiso/cli/main.rb +6 -0
  188. data/lib/kiso/cli/make.rb +22 -12
  189. data/lib/kiso/configuration.rb +54 -0
  190. data/lib/kiso/engine.rb +36 -1
  191. data/lib/kiso/theme_overrides.rb +130 -0
  192. data/lib/kiso/themes/alert.rb +16 -1
  193. data/lib/kiso/themes/avatar.rb +53 -0
  194. data/lib/kiso/themes/badge.rb +15 -5
  195. data/lib/kiso/themes/breadcrumb.rb +44 -0
  196. data/lib/kiso/themes/button.rb +15 -2
  197. data/lib/kiso/themes/card.rb +18 -2
  198. data/lib/kiso/themes/checkbox.rb +33 -0
  199. data/lib/kiso/themes/color_mode_button.rb +15 -0
  200. data/lib/kiso/themes/color_mode_select.rb +7 -0
  201. data/lib/kiso/themes/combobox.rb +97 -0
  202. data/lib/kiso/themes/command.rb +79 -0
  203. data/lib/kiso/themes/dashboard.rb +51 -0
  204. data/lib/kiso/themes/dropdown_menu.rb +108 -0
  205. data/lib/kiso/themes/empty.rb +54 -0
  206. data/lib/kiso/themes/field.rb +76 -0
  207. data/lib/kiso/themes/field_group.rb +15 -0
  208. data/lib/kiso/themes/field_set.rb +32 -0
  209. data/lib/kiso/themes/input.rb +33 -0
  210. data/lib/kiso/themes/input_group.rb +39 -0
  211. data/lib/kiso/themes/input_otp.rb +46 -0
  212. data/lib/kiso/themes/kbd.rb +31 -0
  213. data/lib/kiso/themes/label.rb +16 -0
  214. data/lib/kiso/themes/nav.rb +27 -0
  215. data/lib/kiso/themes/pagination.rb +73 -0
  216. data/lib/kiso/themes/popover.rb +32 -0
  217. data/lib/kiso/themes/radio_group.rb +43 -0
  218. data/lib/kiso/themes/select.rb +78 -0
  219. data/lib/kiso/themes/select_native.rb +49 -0
  220. data/lib/kiso/themes/separator.rb +8 -2
  221. data/lib/kiso/themes/shared.rb +51 -0
  222. data/lib/kiso/themes/stats_card.rb +26 -14
  223. data/lib/kiso/themes/switch.rb +56 -0
  224. data/lib/kiso/themes/table.rb +18 -15
  225. data/lib/kiso/themes/textarea.rb +33 -0
  226. data/lib/kiso/themes/toggle.rb +71 -0
  227. data/lib/kiso/themes/toggle_group.rb +13 -0
  228. data/lib/kiso/version.rb +4 -1
  229. data/lib/kiso.rb +70 -2
  230. metadata +183 -22
  231. data/app/views/kiso/components/empty_state/_content.html.erb +0 -7
  232. data/app/views/kiso/components/empty_state/_description.html.erb +0 -7
  233. data/app/views/kiso/components/empty_state/_header.html.erb +0 -7
  234. data/app/views/kiso/components/empty_state/_media.html.erb +0 -7
  235. data/app/views/kiso/components/empty_state/_title.html.erb +0 -7
  236. data/lib/kiso/themes/empty_state.rb +0 -42
@@ -0,0 +1,616 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { highlightItem, wrapIndex } from "kiso-ui/utils/highlight"
3
+ import { startPositioning } from "kiso-ui/utils/positioning"
4
+
5
+ /**
6
+ * Combobox autocomplete with keyboard navigation, filtering, and form integration.
7
+ * Supports single-select and multi-select (with removable chips).
8
+ *
9
+ * @example
10
+ * <div data-controller="kiso--combobox" data-kiso--combobox-multiple-value="false">
11
+ * <div data-slot="combobox-input">
12
+ * <input data-kiso--combobox-target="input"
13
+ * data-action="input->kiso--combobox#filter focus->kiso--combobox#onInputFocus
14
+ * keydown->kiso--combobox#inputKeydown">
15
+ * <button data-kiso--combobox-target="trigger"
16
+ * data-action="click->kiso--combobox#toggle">
17
+ * </button>
18
+ * </div>
19
+ * <div data-kiso--combobox-target="content" hidden>
20
+ * <div data-kiso--combobox-target="list" role="listbox">
21
+ * <div data-kiso--combobox-target="item" data-value="rails"
22
+ * data-action="click->kiso--combobox#selectItem" role="option">
23
+ * <span data-slot="combobox-item-text">Rails</span>
24
+ * <span data-kiso--combobox-target="indicator" hidden>✓</span>
25
+ * </div>
26
+ * </div>
27
+ * <div data-kiso--combobox-target="empty" hidden>No results.</div>
28
+ * </div>
29
+ * <input type="hidden" data-kiso--combobox-target="hiddenInput" name="framework">
30
+ * </div>
31
+ *
32
+ * @property {HTMLInputElement} inputTarget - Text input for searching/filtering
33
+ * @property {HTMLElement} contentTarget - The dropdown panel
34
+ * @property {HTMLElement} listTarget - Scrollable list inside the dropdown
35
+ * @property {HTMLElement[]} itemTargets - Selectable option elements
36
+ * @property {HTMLElement[]} indicatorTargets - Checkmark indicators inside items
37
+ * @property {HTMLElement} emptyTarget - "No results" message element
38
+ * @property {HTMLInputElement} hiddenInputTarget - Hidden input for form submission
39
+ * @property {HTMLElement} triggerTarget - Chevron button to toggle dropdown
40
+ * @property {HTMLElement} chipsTarget - Multi-select chip container
41
+ * @property {HTMLElement[]} chipTargets - Individual chip elements
42
+ * @property {HTMLTemplateElement} chipTemplateTarget - Template for cloning new chips
43
+ * @property {boolean} multipleValue - Enables multi-select mode when true
44
+ *
45
+ * @fires kiso--combobox:change - When selection changes.
46
+ * Detail: `{ value: string }` (single) or `{ value: string[] }` (multiple).
47
+ */
48
+ export default class extends Controller {
49
+ static targets = [
50
+ "input",
51
+ "content",
52
+ "list",
53
+ "item",
54
+ "indicator",
55
+ "empty",
56
+ "hiddenInput",
57
+ "trigger",
58
+ "chips",
59
+ "chip",
60
+ "chipTemplate",
61
+ ]
62
+ static values = { multiple: { type: Boolean, default: false } }
63
+
64
+ connect() {
65
+ this._open = false
66
+ this._highlightedIndex = -1
67
+ this._selectedValues = new Set()
68
+ this._handleOutsideClick = this._handleOutsideClick.bind(this)
69
+
70
+ // Initialize selected state from pre-rendered chips (multi-select)
71
+ if (this.multipleValue && this.hasChipTarget) {
72
+ this.chipTargets.forEach((chip) => {
73
+ const value = chip.dataset.value
74
+ if (value) this._selectedValues.add(value)
75
+ })
76
+ this._syncIndicators()
77
+ this._syncHiddenInput()
78
+ }
79
+ }
80
+
81
+ disconnect() {
82
+ this._cleanupPosition?.()
83
+ this._removeGlobalListeners()
84
+ }
85
+
86
+ // --- Actions ---
87
+
88
+ /**
89
+ * Toggles the dropdown open or closed.
90
+ *
91
+ * @param {Event} event
92
+ */
93
+ toggle(event) {
94
+ event.preventDefault()
95
+ if (this._open) {
96
+ this.close()
97
+ } else {
98
+ this.open()
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Opens the dropdown when the input receives focus.
104
+ * Skipped when focus is returned programmatically after selection.
105
+ */
106
+ onInputFocus() {
107
+ if (!this._open && !this._suppressFocusOpen) {
108
+ this.open()
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Filters the item list based on the current input value.
114
+ * Hides non-matching items, updates the empty state, and auto-highlights
115
+ * the first visible item.
116
+ */
117
+ filter() {
118
+ const query = this.hasInputTarget ? this.inputTarget.value.toLowerCase().trim() : ""
119
+ let visibleCount = 0
120
+
121
+ this.itemTargets.forEach((item) => {
122
+ const text = this._itemText(item).toLowerCase()
123
+ const matches = text.includes(query)
124
+ item.hidden = !matches
125
+ if (matches) visibleCount++
126
+ })
127
+
128
+ // Show/hide empty state
129
+ if (this.hasEmptyTarget) {
130
+ this.emptyTarget.hidden = visibleCount > 0
131
+ }
132
+
133
+ // Show/hide group labels and separators based on visible items
134
+ this._updateGroupVisibility()
135
+
136
+ // Reset highlighting
137
+ this._highlightIndex(-1)
138
+
139
+ // Auto-highlight first visible item
140
+ const firstVisible = this._visibleEnabledItems
141
+ if (firstVisible.length > 0) {
142
+ this._highlightIndex(0)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Handles keyboard events on the input field.
148
+ * Supports ArrowDown/Up, Enter, Escape, Backspace (remove last chip),
149
+ * Tab, Home, and End.
150
+ *
151
+ * @param {KeyboardEvent} event
152
+ */
153
+ inputKeydown(event) {
154
+ switch (event.key) {
155
+ case "ArrowDown":
156
+ event.preventDefault()
157
+ if (!this._open) {
158
+ this.open()
159
+ } else {
160
+ this._moveHighlight(1)
161
+ }
162
+ break
163
+ case "ArrowUp":
164
+ event.preventDefault()
165
+ if (!this._open) {
166
+ this.open()
167
+ } else {
168
+ this._moveHighlight(-1)
169
+ }
170
+ break
171
+ case "Enter":
172
+ event.preventDefault()
173
+ if (this._open && this._highlightedIndex >= 0) {
174
+ const items = this._visibleEnabledItems
175
+ if (this._highlightedIndex < items.length) {
176
+ this._doSelect(items[this._highlightedIndex])
177
+ }
178
+ }
179
+ break
180
+ case "Escape":
181
+ event.preventDefault()
182
+ if (this._open) {
183
+ this.close()
184
+ }
185
+ break
186
+ case "Backspace":
187
+ // In multi-select, remove last chip if input is empty
188
+ if (this.multipleValue && this.hasInputTarget && this.inputTarget.value === "") {
189
+ this._removeLastChip()
190
+ }
191
+ break
192
+ case "Tab":
193
+ if (this._open) {
194
+ this.close()
195
+ }
196
+ break
197
+ case "Home":
198
+ if (this._open) {
199
+ event.preventDefault()
200
+ const items = this._visibleEnabledItems
201
+ if (items.length > 0) this._highlightIndex(0)
202
+ }
203
+ break
204
+ case "End":
205
+ if (this._open) {
206
+ event.preventDefault()
207
+ const items = this._visibleEnabledItems
208
+ if (items.length > 0) this._highlightIndex(items.length - 1)
209
+ }
210
+ break
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Selects an item when clicked.
216
+ *
217
+ * @param {Event} event - Click event from an item element
218
+ */
219
+ selectItem(event) {
220
+ const item = event.currentTarget
221
+ if (item.dataset.disabled === "true") return
222
+ this._doSelect(item)
223
+ }
224
+
225
+ /**
226
+ * Removes a chip in multi-select mode.
227
+ * Deselects the value and removes the chip element from the DOM.
228
+ *
229
+ * @param {Event} event - Click event from a chip's remove button
230
+ */
231
+ removeChip(event) {
232
+ const value = event.currentTarget.dataset.value
233
+ if (!value) return
234
+
235
+ this._selectedValues.delete(value)
236
+
237
+ // Remove the chip element
238
+ const chip = this.chipTargets.find((c) => c.dataset.value === value)
239
+ if (chip) chip.remove()
240
+
241
+ this._syncIndicators()
242
+ this._syncHiddenInput()
243
+ this.dispatch("change", { detail: { value: Array.from(this._selectedValues) } })
244
+
245
+ // Refocus the input
246
+ if (this.hasInputTarget) {
247
+ this.inputTarget.focus()
248
+ }
249
+ }
250
+
251
+ // --- Open / Close ---
252
+
253
+ /**
254
+ * Opens the dropdown, resets filtering, and highlights the selected
255
+ * item (single mode) or the first item (multi mode).
256
+ */
257
+ open() {
258
+ if (this._open) return
259
+
260
+ this._open = true
261
+ if (this.hasContentTarget) {
262
+ this.contentTarget.hidden = false
263
+ this._positionContent()
264
+ }
265
+
266
+ this._addGlobalListeners()
267
+
268
+ // Reset filter to show all items
269
+ this.itemTargets.forEach((item) => {
270
+ item.hidden = false
271
+ })
272
+ if (this.hasEmptyTarget) {
273
+ this.emptyTarget.hidden = true
274
+ }
275
+ this._updateGroupVisibility()
276
+
277
+ // Highlight the selected item (single mode) or first item
278
+ if (!this.multipleValue) {
279
+ const selectedIndex = this._visibleEnabledItems.findIndex(
280
+ (item) => item.getAttribute("aria-selected") === "true",
281
+ )
282
+ this._highlightIndex(selectedIndex >= 0 ? selectedIndex : 0)
283
+ } else {
284
+ this._highlightIndex(0)
285
+ }
286
+
287
+ // Update trigger aria
288
+ if (this.hasTriggerTarget) {
289
+ this.triggerTarget.setAttribute("aria-expanded", "true")
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Closes the dropdown and clears the filter text (multi-select mode only).
295
+ */
296
+ close() {
297
+ if (!this._open) return
298
+
299
+ this._cleanupPosition?.()
300
+ this._cleanupPosition = null
301
+ this._open = false
302
+ if (this.hasContentTarget) {
303
+ this.contentTarget.hidden = true
304
+ }
305
+ this._highlightIndex(-1)
306
+ this._removeGlobalListeners()
307
+
308
+ // Update trigger aria
309
+ if (this.hasTriggerTarget) {
310
+ this.triggerTarget.setAttribute("aria-expanded", "false")
311
+ }
312
+
313
+ // Clear filter text unless it shows selected value (single mode)
314
+ if (this.hasInputTarget && this.multipleValue) {
315
+ this.inputTarget.value = ""
316
+ }
317
+ }
318
+
319
+ // --- Private ---
320
+
321
+ /**
322
+ * Routes selection to single or multi-select handler.
323
+ *
324
+ * @param {HTMLElement} item - The item element to select
325
+ * @private
326
+ */
327
+ _doSelect(item) {
328
+ const value = item.dataset.value
329
+ if (!value) return
330
+
331
+ if (this.multipleValue) {
332
+ this._toggleMultiSelect(value, item)
333
+ } else {
334
+ this._singleSelect(value, item)
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Handles single-select: updates aria, indicators, hidden input,
340
+ * and closes the dropdown.
341
+ *
342
+ * @param {string} value - The selected value
343
+ * @param {HTMLElement} item - The selected item element
344
+ * @private
345
+ */
346
+ _singleSelect(value, item) {
347
+ const text = this._itemText(item)
348
+
349
+ // Update selected state on all items
350
+ this.itemTargets.forEach((i) => {
351
+ const isSelected = i.dataset.value === value
352
+ i.setAttribute("aria-selected", isSelected)
353
+ })
354
+
355
+ // Update indicators
356
+ this._syncIndicators()
357
+
358
+ // Set hidden input value
359
+ if (this.hasHiddenInputTarget) {
360
+ this.hiddenInputTarget.value = value
361
+ }
362
+
363
+ // Set input text to selected item
364
+ if (this.hasInputTarget) {
365
+ this.inputTarget.value = text
366
+ }
367
+
368
+ this.close()
369
+ this.dispatch("change", { detail: { value } })
370
+
371
+ // Focus the input after close, suppressing the auto-open behavior
372
+ if (this.hasInputTarget) {
373
+ this._suppressFocusOpen = true
374
+ this.inputTarget.focus()
375
+ this._suppressFocusOpen = false
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Handles multi-select toggle: adds/removes from selection,
381
+ * creates/removes chips, and re-filters the list.
382
+ *
383
+ * @param {string} value - The toggled value
384
+ * @param {HTMLElement} item - The toggled item element
385
+ * @private
386
+ */
387
+ _toggleMultiSelect(value, item) {
388
+ if (this._selectedValues.has(value)) {
389
+ // Deselect
390
+ this._selectedValues.delete(value)
391
+ item.setAttribute("aria-selected", "false")
392
+
393
+ // Remove the chip
394
+ const chip = this.chipTargets.find((c) => c.dataset.value === value)
395
+ if (chip) chip.remove()
396
+ } else {
397
+ // Select
398
+ this._selectedValues.add(value)
399
+ item.setAttribute("aria-selected", "true")
400
+
401
+ // Create a chip
402
+ this._createChip(value, this._itemText(item))
403
+ }
404
+
405
+ this._syncIndicators()
406
+ this._syncHiddenInput()
407
+ this.dispatch("change", { detail: { value: Array.from(this._selectedValues) } })
408
+
409
+ // Clear input and refocus
410
+ if (this.hasInputTarget) {
411
+ this.inputTarget.value = ""
412
+ this.inputTarget.focus()
413
+ }
414
+
415
+ // Re-filter to show all items again
416
+ this.filter()
417
+ }
418
+
419
+ /**
420
+ * Creates a chip element for a selected value in multi-select mode
421
+ * by cloning the server-rendered `<template>` and inserting it before the input.
422
+ *
423
+ * @param {string} value - The selected value
424
+ * @param {string} text - The display text
425
+ * @private
426
+ */
427
+ _createChip(value, text) {
428
+ if (!this.hasChipsTarget || !this.hasChipTemplateTarget) return
429
+
430
+ const chip = this.chipTemplateTarget.content.firstElementChild.cloneNode(true)
431
+ chip.dataset.value = value
432
+
433
+ const textEl = chip.querySelector("[data-slot='combobox-chip-text']")
434
+ if (textEl) textEl.textContent = text
435
+
436
+ const removeBtn = chip.querySelector("[data-slot='combobox-chip-remove']")
437
+ if (removeBtn) removeBtn.dataset.value = value
438
+
439
+ // Insert chip before the input element
440
+ const input = this.chipsTarget.querySelector("[data-slot='combobox-chip-input']")
441
+ if (input) {
442
+ this.chipsTarget.insertBefore(chip, input)
443
+ } else {
444
+ this.chipsTarget.appendChild(chip)
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Removes the last chip in multi-select mode (triggered by Backspace
450
+ * when the input is empty).
451
+ *
452
+ * @private
453
+ */
454
+ _removeLastChip() {
455
+ const chips = this.chipTargets
456
+ if (chips.length === 0) return
457
+
458
+ const lastChip = chips[chips.length - 1]
459
+ const value = lastChip.dataset.value
460
+ if (value) {
461
+ this._selectedValues.delete(value)
462
+ lastChip.remove()
463
+ this._syncIndicators()
464
+ this._syncHiddenInput()
465
+ this.dispatch("change", { detail: { value: Array.from(this._selectedValues) } })
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Syncs checkmark indicator visibility with current selection state.
471
+ *
472
+ * @private
473
+ */
474
+ _syncIndicators() {
475
+ this.itemTargets.forEach((item) => {
476
+ const value = item.dataset.value
477
+ const isSelected = this.multipleValue
478
+ ? this._selectedValues.has(value)
479
+ : item.getAttribute("aria-selected") === "true"
480
+
481
+ const indicator = item.querySelector("[data-slot='combobox-item-indicator']")
482
+ if (indicator) {
483
+ indicator.hidden = !isSelected
484
+ }
485
+ })
486
+ }
487
+
488
+ /**
489
+ * Syncs the hidden input value with the current selection.
490
+ * Multi-select joins values with commas.
491
+ *
492
+ * @private
493
+ */
494
+ _syncHiddenInput() {
495
+ if (!this.hasHiddenInputTarget) return
496
+
497
+ if (this.multipleValue) {
498
+ this.hiddenInputTarget.value = Array.from(this._selectedValues).join(",")
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Extracts the display text from an item element.
504
+ *
505
+ * @param {HTMLElement} item
506
+ * @returns {string}
507
+ * @private
508
+ */
509
+ _itemText(item) {
510
+ const textEl = item.querySelector("[data-slot='combobox-item-text']")
511
+ return textEl ? textEl.textContent.trim() : item.dataset.value || ""
512
+ }
513
+
514
+ /**
515
+ * Returns visible, non-disabled items.
516
+ *
517
+ * @returns {HTMLElement[]}
518
+ * @private
519
+ */
520
+ get _visibleEnabledItems() {
521
+ return this.itemTargets.filter((item) => !item.hidden && item.dataset.disabled !== "true")
522
+ }
523
+
524
+ /**
525
+ * Highlights an item at the given index and scrolls it into view.
526
+ * Pass -1 to clear all highlights.
527
+ *
528
+ * @param {number} index - Index within visible enabled items, or -1 to clear
529
+ * @private
530
+ */
531
+ _highlightIndex(index) {
532
+ this._highlightedIndex = index
533
+ highlightItem(this.itemTargets, this._visibleEnabledItems, index)
534
+ }
535
+
536
+ /**
537
+ * Moves the highlight by a given direction (+1 or -1), wrapping at boundaries.
538
+ *
539
+ * @param {number} direction - +1 for next, -1 for previous
540
+ * @private
541
+ */
542
+ _moveHighlight(direction) {
543
+ const items = this._visibleEnabledItems
544
+ if (items.length === 0) return
545
+ this._highlightIndex(wrapIndex(this._highlightedIndex, direction, items.length))
546
+ }
547
+
548
+ /**
549
+ * Shows/hides group labels and separators based on whether they
550
+ * contain any visible items.
551
+ *
552
+ * @private
553
+ */
554
+ _updateGroupVisibility() {
555
+ // Show/hide group labels based on whether they have visible items
556
+ if (!this.hasListTarget) return
557
+
558
+ const groups = this.listTarget.querySelectorAll("[data-slot='combobox-group']")
559
+ groups.forEach((group) => {
560
+ const items = group.querySelectorAll("[data-slot='combobox-item']")
561
+ const hasVisible = Array.from(items).some((item) => !item.hidden)
562
+ group.hidden = !hasVisible
563
+ })
564
+
565
+ // Show/hide separators based on adjacent visible groups
566
+ const separators = this.listTarget.querySelectorAll("[data-slot='combobox-separator']")
567
+ separators.forEach((sep) => {
568
+ const prevGroup = sep.previousElementSibling
569
+ const nextGroup = sep.nextElementSibling
570
+ const prevVisible = prevGroup && !prevGroup.hidden
571
+ const nextVisible = nextGroup && !nextGroup.hidden
572
+ sep.hidden = !(prevVisible && nextVisible)
573
+ })
574
+ }
575
+
576
+ /**
577
+ * Positions the dropdown relative to the input/chips container with matching width.
578
+ * Starts auto-updating on scroll/resize.
579
+ *
580
+ * @private
581
+ */
582
+ _positionContent() {
583
+ if (!this.hasContentTarget) return
584
+
585
+ const anchor =
586
+ this.element.querySelector("[data-slot='combobox-input']") ||
587
+ this.element.querySelector("[data-slot='combobox-chips']") ||
588
+ this.element
589
+
590
+ this._cleanupPosition = startPositioning(anchor, this.contentTarget, {
591
+ matchWidth: true,
592
+ })
593
+ }
594
+
595
+ /**
596
+ * Closes the dropdown when clicking outside the component.
597
+ *
598
+ * @param {MouseEvent} event
599
+ * @private
600
+ */
601
+ _handleOutsideClick(event) {
602
+ if (!this.element.contains(event.target)) {
603
+ this.close()
604
+ }
605
+ }
606
+
607
+ /** @private */
608
+ _addGlobalListeners() {
609
+ document.addEventListener("click", this._handleOutsideClick, true)
610
+ }
611
+
612
+ /** @private */
613
+ _removeGlobalListeners() {
614
+ document.removeEventListener("click", this._handleOutsideClick, true)
615
+ }
616
+ }