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,684 @@
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
+ /** @type {WeakMap<HTMLElement, {enterHandler: Function, leaveHandler: Function}>} */
6
+ const _subHandlers = new WeakMap()
7
+
8
+ /** @type {WeakMap<HTMLElement, Function>} */
9
+ const _subPositionCleanups = new WeakMap()
10
+
11
+ /**
12
+ * Dropdown menu with keyboard navigation, sub-menus, checkbox items, and radio items.
13
+ * Supports nested sub-menus with hover-to-open, type-ahead search, and full
14
+ * arrow-key navigation including ArrowRight/Left for sub-menu enter/exit.
15
+ *
16
+ * @example
17
+ * <div data-controller="kiso--dropdown-menu" data-slot="dropdown-menu">
18
+ * <div data-kiso--dropdown-menu-target="trigger"
19
+ * data-action="click->kiso--dropdown-menu#toggle keydown->kiso--dropdown-menu#triggerKeydown">
20
+ * <button>Open Menu</button>
21
+ * </div>
22
+ * <div data-kiso--dropdown-menu-target="content" role="menu" hidden>
23
+ * <div data-kiso--dropdown-menu-target="item" data-slot="dropdown-menu-item"
24
+ * data-action="click->kiso--dropdown-menu#selectItem" role="menuitem">
25
+ * Profile
26
+ * </div>
27
+ * </div>
28
+ * </div>
29
+ *
30
+ * @property {HTMLElement} triggerTarget - Button that opens/closes the menu
31
+ * @property {HTMLElement} contentTarget - The dropdown panel (menu)
32
+ * @property {HTMLElement[]} itemTargets - Standard menu items (role="menuitem")
33
+ * @property {HTMLElement[]} checkboxItemTargets - Checkbox toggle items (role="menuitemcheckbox")
34
+ * @property {HTMLElement[]} radioGroupTargets - Radio group containers
35
+ * @property {HTMLElement[]} radioItemTargets - Radio selection items (role="menuitemradio")
36
+ * @property {HTMLElement[]} subTargets - Sub-menu wrappers (contain subTrigger + subContent)
37
+ * @property {HTMLElement[]} subTriggerTargets - Elements that open nested sub-menus
38
+ * @property {HTMLElement[]} subContentTargets - Nested sub-menu panels
39
+ *
40
+ * @fires kiso--dropdown-menu:select - When a standard item is selected. Detail: `{ item: HTMLElement }`.
41
+ * @fires kiso--dropdown-menu:checkbox-change - When a checkbox item is toggled. Detail: `{ item: HTMLElement, checked: boolean }`.
42
+ * @fires kiso--dropdown-menu:radio-change - When a radio item is selected. Detail: `{ item: HTMLElement, value: string }`.
43
+ */
44
+ export default class extends Controller {
45
+ static targets = [
46
+ "trigger",
47
+ "content",
48
+ "item",
49
+ "checkboxItem",
50
+ "radioGroup",
51
+ "radioItem",
52
+ "sub",
53
+ "subTrigger",
54
+ "subContent",
55
+ ]
56
+
57
+ connect() {
58
+ this._open = false
59
+ this._handleOutsideClick = this._handleOutsideClick.bind(this)
60
+ this._handleKeydown = this._handleKeydown.bind(this)
61
+ this._handleMouseover = this._handleMouseover.bind(this)
62
+ this._closeSubTimeout = null
63
+
64
+ // Set ARIA attrs on the interactive element inside the trigger wrapper
65
+ this._triggerButton =
66
+ this.triggerTarget.querySelector("button, [tabindex]") || this.triggerTarget
67
+ this._triggerButton.setAttribute("aria-haspopup", "menu")
68
+ this._triggerButton.setAttribute("aria-expanded", "false")
69
+ }
70
+
71
+ disconnect() {
72
+ this._cleanupPosition?.()
73
+ this._closeAllSubs()
74
+ this._removeGlobalListeners()
75
+ if (this._closeSubTimeout) {
76
+ clearTimeout(this._closeSubTimeout)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Toggles the dropdown menu open or closed.
82
+ *
83
+ * @param {Event} event
84
+ */
85
+ toggle(event) {
86
+ event.preventDefault()
87
+ event.stopPropagation()
88
+ if (this._open) {
89
+ this.close()
90
+ } else {
91
+ this.open()
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Opens the dropdown, positions it below the trigger, highlights the first
97
+ * item, and attaches mouse hover delegation.
98
+ */
99
+ open() {
100
+ if (this._open) return
101
+
102
+ this._open = true
103
+ this.contentTarget.hidden = false
104
+ this._triggerButton.setAttribute("aria-expanded", "true")
105
+ this._positionContent()
106
+ this._addGlobalListeners()
107
+ this._highlightIndex(0)
108
+
109
+ // Mouse hover highlighting via event delegation
110
+ this.contentTarget.addEventListener("mouseover", this._handleMouseover)
111
+ }
112
+
113
+ /**
114
+ * Closes the dropdown, all sub-menus, removes listeners,
115
+ * and returns focus to the trigger.
116
+ */
117
+ close() {
118
+ if (!this._open) return
119
+
120
+ this._cleanupPosition?.()
121
+ this._cleanupPosition = null
122
+ this._open = false
123
+ this._lastHoveredItem = null
124
+ this._closeAllSubs()
125
+ this.contentTarget.hidden = true
126
+ this._triggerButton.setAttribute("aria-expanded", "false")
127
+ this._highlightIndex(-1)
128
+ this._removeGlobalListeners()
129
+ this.contentTarget.removeEventListener("mouseover", this._handleMouseover)
130
+
131
+ if (this._closeSubTimeout) {
132
+ clearTimeout(this._closeSubTimeout)
133
+ this._closeSubTimeout = null
134
+ }
135
+
136
+ // Focus the button inside the trigger wrapper, or the trigger itself
137
+ const btn = this.triggerTarget.querySelector("button, [tabindex]")
138
+ ;(btn || this.triggerTarget).focus()
139
+ }
140
+
141
+ /**
142
+ * Dispatches a "select" event for a standard menu item and closes the menu.
143
+ *
144
+ * @param {Event} event - Click event from an item element
145
+ */
146
+ selectItem(event) {
147
+ const item = event.currentTarget
148
+ if (item.dataset.disabled === "true") return
149
+
150
+ this.dispatch("select", { detail: { item } })
151
+ this.close()
152
+ }
153
+
154
+ /**
155
+ * Toggles a checkbox menu item's checked state and updates its indicator icon.
156
+ *
157
+ * @param {Event} event - Click event from a checkbox item element
158
+ */
159
+ toggleCheckboxItem(event) {
160
+ const item = event.currentTarget
161
+ if (item.dataset.disabled === "true") return
162
+
163
+ const currentChecked = item.getAttribute("aria-checked") === "true"
164
+ const newChecked = !currentChecked
165
+ item.setAttribute("aria-checked", newChecked)
166
+
167
+ // Toggle the indicator visibility
168
+ const indicator = item.querySelector("[data-slot='dropdown-menu-item-indicator']")
169
+ if (indicator) {
170
+ indicator.hidden = !newChecked
171
+ }
172
+
173
+ this.dispatch("checkbox-change", {
174
+ detail: { item, checked: newChecked },
175
+ })
176
+ }
177
+
178
+ /**
179
+ * Selects a radio item within its group, deselecting all siblings.
180
+ * Updates aria-checked and indicator icons.
181
+ *
182
+ * @param {Event} event - Click event from a radio item element
183
+ */
184
+ selectRadioItem(event) {
185
+ const item = event.currentTarget
186
+ if (item.dataset.disabled === "true") return
187
+
188
+ const value = item.dataset.value
189
+ const group = item.closest("[data-slot='dropdown-menu-radio-group']")
190
+
191
+ if (group) {
192
+ // Deselect all radio items in this group
193
+ const radioItems = group.querySelectorAll("[data-slot='dropdown-menu-radio-item']")
194
+ radioItems.forEach((radio) => {
195
+ radio.setAttribute("aria-checked", "false")
196
+ const indicator = radio.querySelector("[data-slot='dropdown-menu-item-indicator']")
197
+ if (indicator) indicator.hidden = true
198
+ })
199
+
200
+ // Select the clicked item
201
+ item.setAttribute("aria-checked", "true")
202
+ const indicator = item.querySelector("[data-slot='dropdown-menu-item-indicator']")
203
+ if (indicator) indicator.hidden = false
204
+
205
+ // Update group value
206
+ group.dataset.value = value
207
+ }
208
+
209
+ this.dispatch("radio-change", { detail: { item, value } })
210
+ }
211
+
212
+ /**
213
+ * Toggles a sub-menu open or closed when its trigger is clicked.
214
+ *
215
+ * @param {Event} event - Click event from a sub-trigger element
216
+ */
217
+ toggleSub(event) {
218
+ event.stopPropagation()
219
+ const subTrigger = event.currentTarget
220
+ const sub = subTrigger.closest("[data-slot='dropdown-menu-sub']")
221
+ if (!sub) return
222
+
223
+ const subContent = sub.querySelector("[data-slot='dropdown-menu-sub-content']")
224
+ if (!subContent) return
225
+
226
+ if (subContent.hidden) {
227
+ this._openSub(sub, subTrigger, subContent)
228
+ } else {
229
+ this._closeSub(sub, subTrigger, subContent)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Opens a sub-menu on hover, closing sibling sub-menus first.
235
+ * Cancels any pending close timeout.
236
+ *
237
+ * @param {Event} event - Mouseenter event from a sub-trigger element
238
+ */
239
+ openSubOnHover(event) {
240
+ if (this._closeSubTimeout) {
241
+ clearTimeout(this._closeSubTimeout)
242
+ this._closeSubTimeout = null
243
+ }
244
+
245
+ const subTrigger = event.currentTarget
246
+ const sub = subTrigger.closest("[data-slot='dropdown-menu-sub']")
247
+ if (!sub) return
248
+
249
+ const subContent = sub.querySelector("[data-slot='dropdown-menu-sub-content']")
250
+ if (!subContent || !subContent.hidden) return
251
+
252
+ // Close any other open sub-menus at the same level
253
+ const parent = sub.parentElement
254
+ if (parent) {
255
+ parent.querySelectorAll(":scope > [data-slot='dropdown-menu-sub']").forEach((otherSub) => {
256
+ if (otherSub !== sub) {
257
+ const otherContent = otherSub.querySelector("[data-slot='dropdown-menu-sub-content']")
258
+ const otherTrigger = otherSub.querySelector("[data-slot='dropdown-menu-sub-trigger']")
259
+ if (otherContent && !otherContent.hidden) {
260
+ this._closeSub(otherSub, otherTrigger, otherContent)
261
+ }
262
+ }
263
+ })
264
+ }
265
+
266
+ this._openSub(sub, subTrigger, subContent)
267
+ }
268
+
269
+ /**
270
+ * Opens the dropdown on ArrowDown, Space, Enter, or ArrowUp when
271
+ * the trigger is focused. ArrowUp highlights the last item.
272
+ *
273
+ * @param {KeyboardEvent} event
274
+ */
275
+ triggerKeydown(event) {
276
+ switch (event.key) {
277
+ case "ArrowDown":
278
+ case " ":
279
+ case "Enter":
280
+ event.preventDefault()
281
+ if (!this._open) {
282
+ this.open()
283
+ }
284
+ break
285
+ case "ArrowUp":
286
+ event.preventDefault()
287
+ if (!this._open) {
288
+ this.open()
289
+ // Highlight last item
290
+ const items = this._allMenuItems(this.contentTarget)
291
+ this._highlightIndex(items.length - 1)
292
+ }
293
+ break
294
+ }
295
+ }
296
+
297
+ // --- Private ---
298
+
299
+ /**
300
+ * Opens a sub-menu, positions it, and attaches mouseenter/mouseleave
301
+ * listeners for auto-close with a delay for gap crossing.
302
+ *
303
+ * @param {HTMLElement} sub - The sub wrapper element
304
+ * @param {HTMLElement} subTrigger - The sub-trigger element
305
+ * @param {HTMLElement} subContent - The sub-content panel
306
+ * @private
307
+ */
308
+ _openSub(sub, subTrigger, subContent) {
309
+ subContent.hidden = false
310
+ subTrigger.setAttribute("data-state", "open")
311
+ this._positionSubContent(subTrigger, subContent)
312
+
313
+ // Auto-close when mouse leaves sub-content (with delay for gap crossing)
314
+ const enterHandler = () => {
315
+ if (this._closeSubTimeout) {
316
+ clearTimeout(this._closeSubTimeout)
317
+ this._closeSubTimeout = null
318
+ }
319
+ }
320
+ const leaveHandler = () => {
321
+ this._closeSubTimeout = setTimeout(() => {
322
+ if (!subContent.hidden) {
323
+ this._closeSub(sub, subTrigger, subContent)
324
+ }
325
+ }, 150)
326
+ }
327
+ _subHandlers.set(subContent, { enterHandler, leaveHandler })
328
+ subContent.addEventListener("mouseenter", enterHandler)
329
+ subContent.addEventListener("mouseleave", leaveHandler)
330
+ }
331
+
332
+ /**
333
+ * Closes a sub-menu, cleans up hover listeners, and recursively
334
+ * closes any nested sub-menus.
335
+ *
336
+ * @param {HTMLElement} sub - The sub wrapper element
337
+ * @param {HTMLElement} subTrigger - The sub-trigger element
338
+ * @param {HTMLElement} subContent - The sub-content panel
339
+ * @private
340
+ */
341
+ _closeSub(sub, subTrigger, subContent) {
342
+ subContent.hidden = true
343
+ subTrigger.removeAttribute("data-state")
344
+
345
+ this._stopSubPositioning(subContent)
346
+ this._removeSubContentListeners(subContent)
347
+
348
+ // Close nested sub-menus recursively
349
+ subContent.querySelectorAll("[data-slot='dropdown-menu-sub-content']").forEach((nested) => {
350
+ nested.hidden = true
351
+ this._stopSubPositioning(nested)
352
+ this._removeSubContentListeners(nested)
353
+ })
354
+ subContent.querySelectorAll("[data-slot='dropdown-menu-sub-trigger']").forEach((nested) => {
355
+ nested.removeAttribute("data-state")
356
+ })
357
+ }
358
+
359
+ /**
360
+ * Closes all open sub-menus and cleans up their listeners.
361
+ *
362
+ * @private
363
+ */
364
+ _closeAllSubs() {
365
+ this.subContentTargets.forEach((subContent) => {
366
+ subContent.hidden = true
367
+ this._stopSubPositioning(subContent)
368
+ this._removeSubContentListeners(subContent)
369
+ })
370
+ this.subTriggerTargets.forEach((subTrigger) => {
371
+ subTrigger.removeAttribute("data-state")
372
+ })
373
+ }
374
+
375
+ /**
376
+ * Removes mouseenter/mouseleave handlers from a sub-content element.
377
+ *
378
+ * @param {HTMLElement} subContent
379
+ * @private
380
+ */
381
+ _removeSubContentListeners(subContent) {
382
+ const handlers = _subHandlers.get(subContent)
383
+ if (handlers) {
384
+ subContent.removeEventListener("mouseenter", handlers.enterHandler)
385
+ subContent.removeEventListener("mouseleave", handlers.leaveHandler)
386
+ _subHandlers.delete(subContent)
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Stops positioning for a sub-content element and removes its cleanup entry.
392
+ *
393
+ * @param {HTMLElement} subContent
394
+ * @private
395
+ */
396
+ _stopSubPositioning(subContent) {
397
+ _subPositionCleanups.get(subContent)?.()
398
+ _subPositionCleanups.delete(subContent)
399
+ }
400
+
401
+ /**
402
+ * Collects all focusable menu items within a container, skipping hidden
403
+ * sub-content panels and disabled items. Walks the DOM tree recursively.
404
+ *
405
+ * @param {HTMLElement} container - The menu or sub-content container to search
406
+ * @returns {HTMLElement[]} Ordered list of focusable items
407
+ * @private
408
+ */
409
+ _allMenuItems(container) {
410
+ const items = []
411
+ const walk = (el) => {
412
+ for (const child of el.children) {
413
+ const slot = child.dataset?.slot
414
+ // Skip hidden sub-content
415
+ if (slot === "dropdown-menu-sub-content" && child.hidden) {
416
+ continue
417
+ }
418
+ if (
419
+ slot === "dropdown-menu-item" ||
420
+ slot === "dropdown-menu-checkbox-item" ||
421
+ slot === "dropdown-menu-radio-item" ||
422
+ slot === "dropdown-menu-sub-trigger"
423
+ ) {
424
+ if (child.dataset.disabled !== "true") {
425
+ items.push(child)
426
+ }
427
+ }
428
+ // Recurse into groups, subs, radio-groups, etc.
429
+ if (child.children && child.children.length > 0) {
430
+ walk(child)
431
+ }
432
+ }
433
+ }
434
+ walk(container)
435
+ return items
436
+ }
437
+
438
+ /**
439
+ * Highlights a menu item at the given index and scrolls it into view.
440
+ * Pass -1 to clear all highlights.
441
+ *
442
+ * @param {number} index - Index within all menu items, or -1 to clear
443
+ * @private
444
+ */
445
+ _highlightIndex(index) {
446
+ const allItems = this._allMenuItems(this.contentTarget)
447
+ highlightItem(allItems, allItems, index)
448
+ }
449
+
450
+ /**
451
+ * Positions the dropdown content relative to the trigger.
452
+ * Starts auto-updating on scroll/resize.
453
+ *
454
+ * @private
455
+ */
456
+ _positionContent() {
457
+ this._cleanupPosition = startPositioning(this.triggerTarget, this.contentTarget)
458
+ }
459
+
460
+ /**
461
+ * Positions a sub-content panel to the right of its trigger using
462
+ * Floating UI with fixed positioning to escape parent overflow clipping.
463
+ *
464
+ * @param {HTMLElement} subTrigger - The sub-trigger element
465
+ * @param {HTMLElement} subContent - The sub-content panel to position
466
+ * @private
467
+ */
468
+ _positionSubContent(subTrigger, subContent) {
469
+ const cleanup = startPositioning(subTrigger, subContent, {
470
+ placement: "right-start",
471
+ strategy: "fixed",
472
+ })
473
+ _subPositionCleanups.set(subContent, cleanup)
474
+ }
475
+
476
+ /**
477
+ * Handles mouseover events via delegation on the content panel.
478
+ * Highlights hovered items and closes sibling sub-menus when hovering
479
+ * non-sub-trigger items.
480
+ *
481
+ * @param {MouseEvent} event
482
+ * @private
483
+ */
484
+ _handleMouseover(event) {
485
+ const item = event.target.closest(
486
+ "[data-slot='dropdown-menu-item'], " +
487
+ "[data-slot='dropdown-menu-checkbox-item'], " +
488
+ "[data-slot='dropdown-menu-radio-item'], " +
489
+ "[data-slot='dropdown-menu-sub-trigger']",
490
+ )
491
+ if (!item || !this.element.contains(item)) return
492
+ if (item.dataset.disabled === "true") return
493
+ if (item === this._lastHoveredItem) return
494
+ this._lastHoveredItem = item
495
+
496
+ this._clearAllHighlights()
497
+ item.setAttribute("data-highlighted", "")
498
+
499
+ // When hovering a regular item, close open subs at the same level
500
+ if (item.dataset.slot !== "dropdown-menu-sub-trigger") {
501
+ const parentContainer = item.closest(
502
+ "[data-slot='dropdown-menu-content'], [data-slot='dropdown-menu-sub-content']",
503
+ )
504
+ if (parentContainer) {
505
+ parentContainer.querySelectorAll("[data-slot='dropdown-menu-sub']").forEach((sub) => {
506
+ // Only close subs whose nearest content ancestor is this container
507
+ if (
508
+ sub.closest(
509
+ "[data-slot='dropdown-menu-content'], [data-slot='dropdown-menu-sub-content']",
510
+ ) === parentContainer
511
+ ) {
512
+ const sc = sub.querySelector("[data-slot='dropdown-menu-sub-content']")
513
+ const st = sub.querySelector("[data-slot='dropdown-menu-sub-trigger']")
514
+ if (sc && !sc.hidden) {
515
+ this._closeSub(sub, st, sc)
516
+ }
517
+ }
518
+ })
519
+ }
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Closes the dropdown when clicking outside the component,
525
+ * including outside any open fixed-positioned sub-content.
526
+ *
527
+ * @param {MouseEvent} event
528
+ * @private
529
+ */
530
+ _handleOutsideClick(event) {
531
+ // Check both the root element and any open fixed-positioned sub-contents
532
+ if (this.element.contains(event.target)) return
533
+ for (const subContent of this.subContentTargets) {
534
+ if (!subContent.hidden && subContent.contains(event.target)) return
535
+ }
536
+ this.close()
537
+ }
538
+
539
+ /**
540
+ * Handles keyboard navigation while the dropdown is open.
541
+ * Operates on the deepest open sub-menu container.
542
+ * Supports ArrowDown/Up, ArrowRight (enter sub), ArrowLeft (exit sub),
543
+ * Enter/Space (activate), Escape, Home, End, Tab, and type-ahead.
544
+ *
545
+ * @param {KeyboardEvent} event
546
+ * @private
547
+ */
548
+ _handleKeydown(event) {
549
+ if (!this._open) return
550
+
551
+ // Find the currently active sub-content (deepest open sub)
552
+ let activeContainer = this.contentTarget
553
+ const openSubs = Array.from(
554
+ this.element.querySelectorAll("[data-slot='dropdown-menu-sub-content']:not([hidden])"),
555
+ )
556
+ if (openSubs.length > 0) {
557
+ activeContainer = openSubs[openSubs.length - 1]
558
+ }
559
+
560
+ const items = this._allMenuItems(activeContainer)
561
+
562
+ // Find current highlighted in active container
563
+ let currentIndex = items.findIndex((item) => item.hasAttribute("data-highlighted"))
564
+
565
+ switch (event.key) {
566
+ case "ArrowDown":
567
+ event.preventDefault()
568
+ highlightItem(items, items, wrapIndex(currentIndex, 1, items.length))
569
+ break
570
+ case "ArrowUp":
571
+ event.preventDefault()
572
+ highlightItem(items, items, wrapIndex(currentIndex, -1, items.length))
573
+ break
574
+ case "ArrowRight":
575
+ event.preventDefault()
576
+ // If highlighted item is a sub-trigger, open it
577
+ if (currentIndex >= 0) {
578
+ const current = items[currentIndex]
579
+ if (current.dataset.slot === "dropdown-menu-sub-trigger") {
580
+ const sub = current.closest("[data-slot='dropdown-menu-sub']")
581
+ const subContent = sub?.querySelector("[data-slot='dropdown-menu-sub-content']")
582
+ if (sub && subContent && subContent.hidden) {
583
+ this._openSub(sub, current, subContent)
584
+ // Highlight first item in sub
585
+ const subItems = this._allMenuItems(subContent)
586
+ this._clearAllHighlights()
587
+ if (subItems[0]) {
588
+ subItems[0].setAttribute("data-highlighted", "")
589
+ }
590
+ }
591
+ }
592
+ }
593
+ break
594
+ case "ArrowLeft":
595
+ event.preventDefault()
596
+ // Close the current sub-menu if we're in one
597
+ if (activeContainer !== this.contentTarget) {
598
+ const sub = activeContainer.closest("[data-slot='dropdown-menu-sub']")
599
+ const subTrigger = sub?.querySelector("[data-slot='dropdown-menu-sub-trigger']")
600
+ if (sub && subTrigger) {
601
+ this._closeSub(sub, subTrigger, activeContainer)
602
+ this._clearAllHighlights()
603
+ subTrigger.setAttribute("data-highlighted", "")
604
+ }
605
+ }
606
+ break
607
+ case "Enter":
608
+ case " ":
609
+ event.preventDefault()
610
+ if (currentIndex >= 0 && currentIndex < items.length) {
611
+ const current = items[currentIndex]
612
+ // Trigger click on the highlighted item
613
+ current.click()
614
+ }
615
+ break
616
+ case "Escape":
617
+ event.preventDefault()
618
+ // If in a sub-menu, close just that sub
619
+ if (activeContainer !== this.contentTarget) {
620
+ const sub = activeContainer.closest("[data-slot='dropdown-menu-sub']")
621
+ const subTrigger = sub?.querySelector("[data-slot='dropdown-menu-sub-trigger']")
622
+ if (sub && subTrigger) {
623
+ this._closeSub(sub, subTrigger, activeContainer)
624
+ this._clearAllHighlights()
625
+ subTrigger.setAttribute("data-highlighted", "")
626
+ }
627
+ } else {
628
+ this.close()
629
+ }
630
+ break
631
+ case "Home":
632
+ event.preventDefault()
633
+ highlightItem(items, items, 0)
634
+ break
635
+ case "End":
636
+ event.preventDefault()
637
+ highlightItem(items, items, items.length - 1)
638
+ break
639
+ case "Tab":
640
+ this.close()
641
+ break
642
+ default:
643
+ // Type-ahead
644
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
645
+ const char = event.key.toLowerCase()
646
+ const startIndex = currentIndex + 1
647
+ for (let i = 0; i < items.length; i++) {
648
+ const idx = (startIndex + i) % items.length
649
+ const text = items[idx].textContent?.trim().toLowerCase()
650
+ if (text?.startsWith(char)) {
651
+ this._clearAllHighlights()
652
+ items[idx].setAttribute("data-highlighted", "")
653
+ items[idx].scrollIntoView({ block: "nearest" })
654
+ break
655
+ }
656
+ }
657
+ }
658
+ break
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Removes `data-highlighted` from all elements in the dropdown.
664
+ *
665
+ * @private
666
+ */
667
+ _clearAllHighlights() {
668
+ this.element
669
+ .querySelectorAll("[data-highlighted]")
670
+ .forEach((el) => el.removeAttribute("data-highlighted"))
671
+ }
672
+
673
+ /** @private */
674
+ _addGlobalListeners() {
675
+ document.addEventListener("click", this._handleOutsideClick, true)
676
+ document.addEventListener("keydown", this._handleKeydown)
677
+ }
678
+
679
+ /** @private */
680
+ _removeGlobalListeners() {
681
+ document.removeEventListener("click", this._handleOutsideClick, true)
682
+ document.removeEventListener("keydown", this._handleKeydown)
683
+ }
684
+ }
@@ -0,0 +1,12 @@
1
+ import { type Application, Controller } from "@hotwired/stimulus"
2
+
3
+ declare const KisoUi: {
4
+ start(application: Application): void
5
+ }
6
+
7
+ export default KisoUi
8
+ export const KisoComboboxController: typeof Controller
9
+ export const KisoDropdownMenuController: typeof Controller
10
+ export const KisoSelectController: typeof Controller
11
+ export const KisoToggleController: typeof Controller
12
+ export const KisoToggleGroupController: typeof Controller