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,307 @@
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
+ * Custom select dropdown with keyboard navigation and form integration.
7
+ * Renders a trigger button, hidden listbox, and syncs selection to a hidden input.
8
+ *
9
+ * @example
10
+ * <div data-controller="kiso--select" data-slot="select">
11
+ * <button data-kiso--select-target="trigger"
12
+ * data-action="click->kiso--select#toggle keydown->kiso--select#triggerKeydown">
13
+ * <span data-kiso--select-target="valueDisplay" data-placeholder="Pick one...">
14
+ * <span class="text-muted-foreground">Pick one...</span>
15
+ * </span>
16
+ * </button>
17
+ * <div data-kiso--select-target="content" role="listbox" hidden>
18
+ * <div data-kiso--select-target="item" data-value="apple"
19
+ * data-action="click->kiso--select#selectItem" role="option">
20
+ * <span data-kiso--select-target="indicator" hidden>✓</span>
21
+ * <span>Apple</span>
22
+ * </div>
23
+ * </div>
24
+ * <input type="hidden" data-kiso--select-target="hiddenInput" name="fruit">
25
+ * </div>
26
+ *
27
+ * @property {HTMLElement} triggerTarget - Button that opens/closes the dropdown
28
+ * @property {HTMLElement} contentTarget - The dropdown panel (listbox)
29
+ * @property {HTMLElement[]} itemTargets - Selectable option elements
30
+ * @property {HTMLElement[]} indicatorTargets - Checkmark indicators inside items
31
+ * @property {HTMLInputElement} hiddenInputTarget - Hidden input for form submission
32
+ * @property {HTMLElement} valueDisplayTarget - Span showing the current selection text
33
+ *
34
+ * @fires kiso--select:change - When selection changes. Detail: `{ value: string }`.
35
+ */
36
+ export default class extends Controller {
37
+ static targets = ["trigger", "content", "item", "indicator", "hiddenInput", "valueDisplay"]
38
+
39
+ connect() {
40
+ this._open = false
41
+ this._highlightedIndex = -1
42
+ this._handleOutsideClick = this._handleOutsideClick.bind(this)
43
+ this._handleKeydown = this._handleKeydown.bind(this)
44
+ }
45
+
46
+ disconnect() {
47
+ this._cleanupPosition?.()
48
+ this._removeGlobalListeners()
49
+ }
50
+
51
+ /**
52
+ * Toggles the dropdown open or closed.
53
+ * Skips if the click was auto-generated by a Space keyup (already handled in triggerKeydown).
54
+ *
55
+ * @param {Event} event
56
+ */
57
+ toggle(event) {
58
+ if (this._ignoreNextClick) {
59
+ this._ignoreNextClick = false
60
+ event.preventDefault()
61
+ return
62
+ }
63
+ event.preventDefault()
64
+ if (this._open) {
65
+ this.close()
66
+ } else {
67
+ this.open()
68
+ }
69
+ }
70
+
71
+ /** Opens the dropdown, positions it, and highlights the selected or first item. */
72
+ open() {
73
+ if (this._open || this.triggerTarget.disabled) return
74
+
75
+ this._open = true
76
+ this.contentTarget.hidden = false
77
+ this.triggerTarget.setAttribute("aria-expanded", "true")
78
+ this._positionContent()
79
+ this._addGlobalListeners()
80
+
81
+ // Highlight the currently selected item, or the first item
82
+ const selectedIndex = this._enabledItems.findIndex(
83
+ (item) => item.getAttribute("aria-selected") === "true",
84
+ )
85
+ this._highlightIndex(selectedIndex >= 0 ? selectedIndex : 0)
86
+ }
87
+
88
+ /** Closes the dropdown and returns focus to the trigger. */
89
+ close() {
90
+ if (!this._open) return
91
+
92
+ this._cleanupPosition?.()
93
+ this._cleanupPosition = null
94
+ this._open = false
95
+ this.contentTarget.hidden = true
96
+ this.triggerTarget.setAttribute("aria-expanded", "false")
97
+ this._highlightIndex(-1)
98
+ this._removeGlobalListeners()
99
+ this.triggerTarget.focus()
100
+ }
101
+
102
+ /**
103
+ * Selects an item when clicked.
104
+ *
105
+ * @param {Event} event - Click event from an item element
106
+ */
107
+ selectItem(event) {
108
+ const item = event.currentTarget
109
+ if (item.dataset.disabled === "true") return
110
+
111
+ const value = item.dataset.value
112
+ const text = item.querySelector("[data-slot='select-item-text']")?.textContent?.trim() || value
113
+
114
+ this._setValue(value, text)
115
+ this.close()
116
+ }
117
+
118
+ /**
119
+ * Opens the dropdown on ArrowDown, ArrowUp, Space, or Enter when trigger is focused.
120
+ *
121
+ * @param {KeyboardEvent} event
122
+ */
123
+ triggerKeydown(event) {
124
+ switch (event.key) {
125
+ case "ArrowDown":
126
+ case "ArrowUp":
127
+ case " ":
128
+ case "Enter":
129
+ event.preventDefault()
130
+ if (!this._open) {
131
+ // Stop propagation so the global _handleKeydown (added during open())
132
+ // does not also process this same keydown event — otherwise Space/Enter
133
+ // would immediately select the highlighted item and close the dropdown.
134
+ event.stopPropagation()
135
+ this._ignoreNextClick = true
136
+ this.open()
137
+ }
138
+ break
139
+ }
140
+ }
141
+
142
+ // --- Private ---
143
+
144
+ /**
145
+ * Updates the hidden input, display text, aria-selected states, and indicators.
146
+ *
147
+ * @param {string} value - The selected value
148
+ * @param {string} text - The display text for the selection
149
+ * @private
150
+ */
151
+ _setValue(value, text) {
152
+ // Update hidden input
153
+ if (this.hasHiddenInputTarget) {
154
+ this.hiddenInputTarget.value = value
155
+ }
156
+
157
+ // Update displayed value
158
+ if (this.hasValueDisplayTarget) {
159
+ this.valueDisplayTarget.textContent = text
160
+ }
161
+
162
+ // Update aria-selected on items and show/hide indicators
163
+ this.itemTargets.forEach((item) => {
164
+ const isSelected = item.dataset.value === value
165
+ item.setAttribute("aria-selected", isSelected)
166
+
167
+ // Find the indicator within this item
168
+ const indicator = item.querySelector("[data-slot='select-item-indicator']")
169
+ if (indicator) {
170
+ indicator.hidden = !isSelected
171
+ }
172
+ })
173
+
174
+ // Dispatch change event
175
+ this.dispatch("change", { detail: { value } })
176
+ }
177
+
178
+ /**
179
+ * Returns items that are not disabled.
180
+ *
181
+ * @returns {HTMLElement[]}
182
+ * @private
183
+ */
184
+ get _enabledItems() {
185
+ return this.itemTargets.filter((item) => item.dataset.disabled !== "true")
186
+ }
187
+
188
+ /**
189
+ * Highlights an item at the given index and scrolls it into view.
190
+ * Pass -1 to clear all highlights.
191
+ *
192
+ * @param {number} index - Index within enabled items, or -1 to clear
193
+ * @private
194
+ */
195
+ _highlightIndex(index) {
196
+ this._highlightedIndex = index
197
+ highlightItem(this.itemTargets, this._enabledItems, index)
198
+ }
199
+
200
+ /**
201
+ * Positions the dropdown relative to the trigger with matching width.
202
+ * Starts auto-updating on scroll/resize.
203
+ *
204
+ * @private
205
+ */
206
+ _positionContent() {
207
+ this._cleanupPosition = startPositioning(this.triggerTarget, this.contentTarget, {
208
+ matchWidth: true,
209
+ })
210
+ }
211
+
212
+ /**
213
+ * Closes the dropdown when clicking outside the component.
214
+ *
215
+ * @param {MouseEvent} event
216
+ * @private
217
+ */
218
+ _handleOutsideClick(event) {
219
+ if (!this.element.contains(event.target)) {
220
+ this.close()
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Handles keyboard navigation while the dropdown is open.
226
+ * Supports ArrowDown/Up, Enter, Space, Escape, Home, End, Tab, and type-ahead.
227
+ *
228
+ * @param {KeyboardEvent} event
229
+ * @private
230
+ */
231
+ _handleKeydown(event) {
232
+ if (!this._open) return
233
+
234
+ const items = this._enabledItems
235
+
236
+ switch (event.key) {
237
+ case "ArrowDown":
238
+ event.preventDefault()
239
+ this._highlightIndex(wrapIndex(this._highlightedIndex, 1, items.length))
240
+ break
241
+ case "ArrowUp":
242
+ event.preventDefault()
243
+ this._highlightIndex(wrapIndex(this._highlightedIndex, -1, items.length))
244
+ break
245
+ case "Enter":
246
+ case " ":
247
+ event.preventDefault()
248
+ if (this._highlightedIndex >= 0 && this._highlightedIndex < items.length) {
249
+ const item = items[this._highlightedIndex]
250
+ if (item.dataset.disabled !== "true") {
251
+ const value = item.dataset.value
252
+ const text =
253
+ item.querySelector("[data-slot='select-item-text']")?.textContent?.trim() || value
254
+ this._setValue(value, text)
255
+ this.close()
256
+ }
257
+ }
258
+ break
259
+ case "Escape":
260
+ event.preventDefault()
261
+ this.close()
262
+ break
263
+ case "Home":
264
+ event.preventDefault()
265
+ this._highlightIndex(0)
266
+ break
267
+ case "End":
268
+ event.preventDefault()
269
+ this._highlightIndex(items.length - 1)
270
+ break
271
+ case "Tab":
272
+ this.close()
273
+ break
274
+ default:
275
+ // Type-ahead: focus first item starting with typed character
276
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
277
+ const char = event.key.toLowerCase()
278
+ const startIndex = this._highlightedIndex + 1
279
+ const matchIndex = items.findIndex((item, i) => {
280
+ const actualIndex = (startIndex + i) % items.length
281
+ const text = items[actualIndex]
282
+ .querySelector("[data-slot='select-item-text']")
283
+ ?.textContent?.trim()
284
+ .toLowerCase()
285
+ return text?.startsWith(char)
286
+ })
287
+ if (matchIndex >= 0) {
288
+ const actualIndex = (startIndex + matchIndex) % items.length
289
+ this._highlightIndex(actualIndex)
290
+ }
291
+ }
292
+ break
293
+ }
294
+ }
295
+
296
+ /** @private */
297
+ _addGlobalListeners() {
298
+ document.addEventListener("click", this._handleOutsideClick, true)
299
+ document.addEventListener("keydown", this._handleKeydown)
300
+ }
301
+
302
+ /** @private */
303
+ _removeGlobalListeners() {
304
+ document.removeEventListener("click", this._handleOutsideClick, true)
305
+ document.removeEventListener("keydown", this._handleKeydown)
306
+ }
307
+ }
@@ -0,0 +1,84 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Kiso sidebar toggle controller.
5
+ *
6
+ * Manages the dashboard sidebar open/closed state via a single
7
+ * `data-sidebar-open` attribute on the controller element. CSS cascade
8
+ * handles all visual changes — this controller only manages the
9
+ * boolean attribute and persists the preference to a cookie for
10
+ * FOUC-free server-side restoration on the next page load.
11
+ *
12
+ * Register as `kiso--sidebar` (the engine index does this automatically).
13
+ *
14
+ * @example
15
+ * <div data-slot="dashboard-group"
16
+ * data-controller="kiso--sidebar"
17
+ * data-sidebar-open="true">
18
+ * <header data-slot="dashboard-navbar">
19
+ * <button data-kiso--sidebar-target="trigger"
20
+ * data-action="click->kiso--sidebar#toggle"
21
+ * aria-expanded="true"
22
+ * aria-controls="dashboard-sidebar">
23
+ * <!-- hamburger icon -->
24
+ * </button>
25
+ * </header>
26
+ * <aside data-slot="dashboard-sidebar" id="dashboard-sidebar">
27
+ * <div data-slot="dashboard-sidebar-inner">
28
+ * <!-- sidebar content -->
29
+ * </div>
30
+ * </aside>
31
+ * <main data-slot="dashboard-panel"><!-- page content --></main>
32
+ * <div data-slot="dashboard-scrim"
33
+ * data-kiso--sidebar-target="scrim"
34
+ * data-action="click->kiso--sidebar#closeOnMobile"
35
+ * aria-hidden="true"></div>
36
+ * </div>
37
+ *
38
+ * @property {Element[]} triggerTargets - Toggle/collapse buttons that control the sidebar
39
+ * @property {Element} scrimTarget - The mobile overlay scrim
40
+ */
41
+ export default class extends Controller {
42
+ static targets = ["trigger", "scrim"]
43
+
44
+ /**
45
+ * Toggles the sidebar open/closed state.
46
+ *
47
+ * Flips `data-sidebar-open` on the controller element, syncs
48
+ * `aria-expanded` on the trigger target, and persists the new
49
+ * state to a one-year cookie for FOUC-free server-side restoration.
50
+ */
51
+ toggle() {
52
+ const isOpen = this.element.dataset.sidebarOpen !== "false"
53
+ this.#setState(!isOpen)
54
+ }
55
+
56
+ /**
57
+ * Closes the sidebar on mobile viewports only.
58
+ *
59
+ * Connected to the scrim click event. Tapping the overlay outside
60
+ * the mobile sidebar dismisses it without affecting desktop layout.
61
+ */
62
+ closeOnMobile() {
63
+ if (matchMedia("(max-width: 767px)").matches) {
64
+ this.#setState(false)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Sets the sidebar state, syncs aria-expanded, and persists to cookie.
70
+ *
71
+ * @param {boolean} open - Whether the sidebar should be open
72
+ * @private
73
+ */
74
+ #setState(open) {
75
+ const value = String(open)
76
+
77
+ this.element.dataset.sidebarOpen = value
78
+ document.cookie = `sidebar_open=${value};path=/;max-age=31536000;SameSite=Lax`
79
+
80
+ for (const trigger of this.triggerTargets) {
81
+ trigger.setAttribute("aria-expanded", value)
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,89 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Kiso theme toggle controller.
5
+ *
6
+ * Toggles the `.dark` class on `<html>` and persists the preference
7
+ * to both `localStorage` and a cookie. Works in concert with the
8
+ * `kiso_theme_script` helper which prevents FOUC on initial page load.
9
+ *
10
+ * Supports three modes: "light", "dark", "system". The `toggle` action
11
+ * cycles light ↔ dark. The `set` action accepts any of the three values.
12
+ *
13
+ * Register as `kiso--theme` (the engine index does this automatically).
14
+ *
15
+ * @example Toggle button (light ↔ dark)
16
+ * <button data-controller="kiso--theme"
17
+ * data-action="click->kiso--theme#toggle"
18
+ * aria-label="Toggle dark mode">
19
+ * <!-- sun / moon icon -->
20
+ * </button>
21
+ *
22
+ * @example Set a specific mode via kiso--select:change
23
+ * <div data-controller="kiso--theme"
24
+ * data-action="kiso--select:change->kiso--theme#set">
25
+ * <!-- kui(:select) with light/dark/system items -->
26
+ * </div>
27
+ *
28
+ * @fires kiso:theme-change on document.documentElement when theme changes
29
+ */
30
+ export default class extends Controller {
31
+ /**
32
+ * Toggles dark mode on the document root.
33
+ *
34
+ * Cycles between light and dark. Persists the preference
35
+ * to `localStorage` and a one-year cookie.
36
+ */
37
+ toggle() {
38
+ const dark = document.documentElement.classList.toggle("dark")
39
+ this.#persist(dark ? "dark" : "light")
40
+ }
41
+
42
+ /**
43
+ * Sets a specific theme preference.
44
+ *
45
+ * Accepts "light", "dark", or "system" via event detail.
46
+ * When "system", resolves to the OS preference via matchMedia.
47
+ *
48
+ * @param {CustomEvent} event - Event with detail.value
49
+ */
50
+ set(event) {
51
+ const preference = event.detail?.value
52
+ if (!preference) return
53
+
54
+ this.#apply(preference)
55
+ this.#persist(preference)
56
+ }
57
+
58
+ /**
59
+ * Resolves a preference to an actual theme and applies it.
60
+ *
61
+ * @param {string} preference - "light", "dark", or "system"
62
+ * @private
63
+ */
64
+ #apply(preference) {
65
+ let resolved = preference
66
+ if (preference === "system") {
67
+ resolved = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
68
+ }
69
+ document.documentElement.classList.toggle("dark", resolved === "dark")
70
+ }
71
+
72
+ /**
73
+ * Persists preference and dispatches change event.
74
+ *
75
+ * @param {string} preference - "light", "dark", or "system"
76
+ * @private
77
+ */
78
+ #persist(preference) {
79
+ localStorage.setItem("theme", preference)
80
+ document.cookie = `theme=${preference};path=/;max-age=31536000;SameSite=Lax`
81
+
82
+ document.documentElement.dispatchEvent(
83
+ new CustomEvent("kiso:theme-change", {
84
+ detail: { theme: preference },
85
+ bubbles: true,
86
+ }),
87
+ )
88
+ }
89
+ }
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Manages pressed state for a standalone toggle button.
5
+ * Toggles `data-state` between "on" and "off" and updates `aria-pressed`.
6
+ *
7
+ * @example
8
+ * <button data-controller="kiso--toggle"
9
+ * data-action="click->kiso--toggle#toggle"
10
+ * data-state="off" aria-pressed="false">
11
+ * Bold
12
+ * </button>
13
+ */
14
+ export default class extends Controller {
15
+ /**
16
+ * Toggles the pressed state of the element.
17
+ * Flips `data-state` between "on"/"off" and syncs `aria-pressed`.
18
+ */
19
+ toggle() {
20
+ const pressed = this.element.dataset.state === "on"
21
+ this.element.dataset.state = pressed ? "off" : "on"
22
+ this.element.setAttribute("aria-pressed", !pressed)
23
+ }
24
+ }
@@ -0,0 +1,128 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Manages selection state for a group of toggle buttons.
5
+ * Supports single (radio-like) and multiple (checkbox-like) selection modes.
6
+ *
7
+ * @example
8
+ * <div data-controller="kiso--toggle-group"
9
+ * data-kiso--toggle-group-type-value="single">
10
+ * <button data-kiso--toggle-group-target="item"
11
+ * data-action="click->kiso--toggle-group#toggle"
12
+ * data-value="left" data-state="off" aria-pressed="false">
13
+ * Left
14
+ * </button>
15
+ * </div>
16
+ *
17
+ * @property {HTMLElement[]} itemTargets - Toggle buttons in the group
18
+ * @property {string} typeValue - Selection mode: "single" or "multiple"
19
+ * @property {string} variantValue - Inherited variant for styling context
20
+ * @property {string} sizeValue - Inherited size for styling context
21
+ *
22
+ * @fires kiso--toggle-group:change - When selection changes.
23
+ * Detail: `{ value: string | null }` (single) or `{ value: string[] }` (multiple).
24
+ */
25
+ export default class extends Controller {
26
+ static targets = ["item"]
27
+ static values = {
28
+ type: { type: String, default: "single" },
29
+ variant: { type: String, default: "default" },
30
+ size: { type: String, default: "default" },
31
+ }
32
+
33
+ /**
34
+ * Handles a toggle click on one of the group items.
35
+ * In single mode, deselects all others first (allows deselect).
36
+ * In multiple mode, toggles the clicked item independently.
37
+ *
38
+ * @param {Event} event - The click event from a group item
39
+ */
40
+ toggle(event) {
41
+ const item = event.currentTarget
42
+ const pressed = item.dataset.state === "on"
43
+
44
+ if (this.typeValue === "single") {
45
+ // In single mode, deselect all others first
46
+ this.itemTargets.forEach((target) => {
47
+ target.dataset.state = "off"
48
+ target.setAttribute("aria-pressed", "false")
49
+ })
50
+
51
+ // Toggle the clicked item (allow deselect in single mode)
52
+ if (!pressed) {
53
+ item.dataset.state = "on"
54
+ item.setAttribute("aria-pressed", "true")
55
+ }
56
+ } else {
57
+ // In multiple mode, toggle independently
58
+ item.dataset.state = pressed ? "off" : "on"
59
+ item.setAttribute("aria-pressed", !pressed)
60
+ }
61
+
62
+ this.#dispatchChange()
63
+ }
64
+
65
+ /** Sets up arrow-key navigation between items. */
66
+ connect() {
67
+ this.element.addEventListener("keydown", this.#handleKeydown)
68
+ }
69
+
70
+ /** Tears down the keydown listener. */
71
+ disconnect() {
72
+ this.element.removeEventListener("keydown", this.#handleKeydown)
73
+ }
74
+
75
+ /**
76
+ * Handles arrow key, Home, and End navigation between items.
77
+ * Wraps around at boundaries.
78
+ *
79
+ * @param {KeyboardEvent} event
80
+ */
81
+ #handleKeydown = (event) => {
82
+ const items = this.itemTargets.filter((item) => !item.disabled)
83
+ const currentIndex = items.indexOf(document.activeElement)
84
+
85
+ if (currentIndex === -1) return
86
+
87
+ let nextIndex
88
+ switch (event.key) {
89
+ case "ArrowRight":
90
+ case "ArrowDown":
91
+ event.preventDefault()
92
+ nextIndex = (currentIndex + 1) % items.length
93
+ items[nextIndex].focus()
94
+ break
95
+ case "ArrowLeft":
96
+ case "ArrowUp":
97
+ event.preventDefault()
98
+ nextIndex = (currentIndex - 1 + items.length) % items.length
99
+ items[nextIndex].focus()
100
+ break
101
+ case "Home":
102
+ event.preventDefault()
103
+ items[0].focus()
104
+ break
105
+ case "End":
106
+ event.preventDefault()
107
+ items[items.length - 1].focus()
108
+ break
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Dispatches a "change" event with the currently selected value(s).
114
+ * Single mode emits `{ value: string | null }`,
115
+ * multiple mode emits `{ value: string[] }`.
116
+ */
117
+ #dispatchChange() {
118
+ const selectedValues = this.itemTargets
119
+ .filter((item) => item.dataset.state === "on")
120
+ .map((item) => item.dataset.value)
121
+
122
+ this.dispatch("change", {
123
+ detail: {
124
+ value: this.typeValue === "single" ? selectedValues[0] || null : selectedValues,
125
+ },
126
+ })
127
+ }
128
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CSS selector for all natively focusable elements that are not disabled
3
+ * or explicitly removed from the tab order.
4
+ *
5
+ * @type {string}
6
+ */
7
+ export const FOCUSABLE_SELECTOR =
8
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared highlight and index utilities for list-based components.
3
+ * Used by select, combobox, command, and dropdown_menu controllers.
4
+ *
5
+ * @module utils/highlight
6
+ */
7
+
8
+ /**
9
+ * Highlights an item at the given index. Clears the attribute from all
10
+ * clearItems, then sets it on the item at `index` within `items` and
11
+ * scrolls it into view.
12
+ *
13
+ * @param {HTMLElement[]} clearItems - Items to remove the attribute from
14
+ * @param {HTMLElement[]} items - Items to index into for highlighting
15
+ * @param {number} index - Index to highlight, or -1 to clear only
16
+ * @param {Object} [options]
17
+ * @param {string} [options.attr="data-highlighted"] - The attribute to toggle
18
+ */
19
+ export function highlightItem(clearItems, items, index, { attr = "data-highlighted" } = {}) {
20
+ clearItems.forEach((item) => item.removeAttribute(attr))
21
+
22
+ if (index >= 0 && index < items.length) {
23
+ items[index].setAttribute(attr, "")
24
+ items[index].scrollIntoView({ block: "nearest" })
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Wraps an index within a range, cycling from end to start and vice versa.
30
+ *
31
+ * @param {number} current - Current index
32
+ * @param {number} direction - +1 for next, -1 for previous
33
+ * @param {number} length - Total number of items
34
+ * @returns {number} The wrapped index, or -1 if length is 0
35
+ */
36
+ export function wrapIndex(current, direction, length) {
37
+ if (length === 0) return -1
38
+
39
+ let next = current + direction
40
+ if (next < 0) next = length - 1
41
+ if (next >= length) next = 0
42
+ return next
43
+ }