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,42 @@
1
+ import KisoComboboxController from "./combobox_controller.js"
2
+ import KisoCommandController from "./command_controller.js"
3
+ import KisoCommandDialogController from "./command_dialog_controller.js"
4
+ import KisoDropdownMenuController from "./dropdown_menu_controller.js"
5
+ import KisoInputOtpController from "./input_otp_controller.js"
6
+ import KisoPopoverController from "./popover_controller.js"
7
+ import KisoSelectController from "./select_controller.js"
8
+ import KisoSidebarController from "./sidebar_controller.js"
9
+ import KisoThemeController from "./theme_controller.js"
10
+ import KisoToggleController from "./toggle_controller.js"
11
+ import KisoToggleGroupController from "./toggle_group_controller.js"
12
+
13
+ const KisoUi = {
14
+ start(application) {
15
+ application.register("kiso--combobox", KisoComboboxController)
16
+ application.register("kiso--command", KisoCommandController)
17
+ application.register("kiso--command-dialog", KisoCommandDialogController)
18
+ application.register("kiso--dropdown-menu", KisoDropdownMenuController)
19
+ application.register("kiso--input-otp", KisoInputOtpController)
20
+ application.register("kiso--popover", KisoPopoverController)
21
+ application.register("kiso--select", KisoSelectController)
22
+ application.register("kiso--sidebar", KisoSidebarController)
23
+ application.register("kiso--theme", KisoThemeController)
24
+ application.register("kiso--toggle", KisoToggleController)
25
+ application.register("kiso--toggle-group", KisoToggleGroupController)
26
+ },
27
+ }
28
+
29
+ export default KisoUi
30
+ export {
31
+ KisoComboboxController,
32
+ KisoCommandController,
33
+ KisoCommandDialogController,
34
+ KisoDropdownMenuController,
35
+ KisoInputOtpController,
36
+ KisoPopoverController,
37
+ KisoSelectController,
38
+ KisoSidebarController,
39
+ KisoThemeController,
40
+ KisoToggleController,
41
+ KisoToggleGroupController,
42
+ }
@@ -0,0 +1,195 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Manages a one-time password input with individual visual character slots.
5
+ *
6
+ * A single transparent `<input>` overlays the visual slots. The browser handles
7
+ * all native input behavior (focus, paste, mobile autofill). This controller
8
+ * distributes the input value to slot elements and tracks the active slot via
9
+ * `selectionStart`.
10
+ *
11
+ * @example
12
+ * <div data-controller="kiso--input-otp"
13
+ * data-kiso--input-otp-length-value="6"
14
+ * data-kiso--input-otp-pattern-value="\\d">
15
+ * <input data-kiso--input-otp-target="input"
16
+ * type="text" maxlength="6" autocomplete="one-time-code"
17
+ * class="absolute inset-0 z-10 w-full h-full opacity-0">
18
+ * <div data-slot="input-otp-group">
19
+ * <div data-kiso--input-otp-target="slot" data-slot="input-otp-slot">
20
+ * <span data-slot="input-otp-slot-char"></span>
21
+ * <div data-slot="input-otp-caret" hidden>
22
+ * <div class="bg-foreground h-4 w-px animate-caret-blink"></div>
23
+ * </div>
24
+ * </div>
25
+ * </div>
26
+ * </div>
27
+ *
28
+ * @property {HTMLInputElement} inputTarget - The transparent real input element
29
+ * @property {HTMLElement[]} slotTargets - Visual slot div elements
30
+ * @property {Number} lengthValue - Expected OTP code length
31
+ * @property {String} patternValue - Regex pattern for allowed characters (default: digits only)
32
+ *
33
+ * @fires kiso--input-otp:change - When the OTP value changes.
34
+ * Detail: `{ value: string }`
35
+ * @fires kiso--input-otp:complete - When all slots are filled.
36
+ * Detail: `{ value: string }`
37
+ */
38
+ export default class extends Controller {
39
+ static targets = ["input", "slot"]
40
+ static values = {
41
+ length: { type: Number, default: 6 },
42
+ pattern: { type: String, default: "\\d" },
43
+ }
44
+
45
+ /**
46
+ * Binds event listeners and syncs slots from any pre-filled value.
47
+ */
48
+ connect() {
49
+ this._handleInput = this._handleInput.bind(this)
50
+ this._handleKeydown = this._handleKeydown.bind(this)
51
+ this._handleFocus = this._handleFocus.bind(this)
52
+ this._handleBlur = this._handleBlur.bind(this)
53
+ this._handleClick = this._handleClick.bind(this)
54
+
55
+ this.inputTarget.addEventListener("input", this._handleInput)
56
+ this.inputTarget.addEventListener("keydown", this._handleKeydown)
57
+ this.inputTarget.addEventListener("focus", this._handleFocus)
58
+ this.inputTarget.addEventListener("blur", this._handleBlur)
59
+ this.element.addEventListener("click", this._handleClick)
60
+
61
+ this._syncSlots()
62
+ }
63
+
64
+ /**
65
+ * Removes event listeners.
66
+ */
67
+ disconnect() {
68
+ this.inputTarget.removeEventListener("input", this._handleInput)
69
+ this.inputTarget.removeEventListener("keydown", this._handleKeydown)
70
+ this.inputTarget.removeEventListener("focus", this._handleFocus)
71
+ this.inputTarget.removeEventListener("blur", this._handleBlur)
72
+ this.element.removeEventListener("click", this._handleClick)
73
+ }
74
+
75
+ /**
76
+ * Filters input against the pattern, syncs slots, and dispatches events.
77
+ *
78
+ * @private
79
+ */
80
+ _handleInput() {
81
+ const regex = new RegExp(this.patternValue)
82
+ const filtered = this.inputTarget.value
83
+ .split("")
84
+ .filter((char) => regex.test(char))
85
+ .join("")
86
+ .slice(0, this.lengthValue)
87
+
88
+ if (filtered !== this.inputTarget.value) {
89
+ this.inputTarget.value = filtered
90
+ }
91
+
92
+ this._syncSlots()
93
+ this._updateActiveSlot()
94
+
95
+ this.dispatch("change", { detail: { value: filtered } })
96
+
97
+ if (filtered.length === this.lengthValue) {
98
+ this.dispatch("complete", { detail: { value: filtered } })
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Re-syncs the active slot after keydown events that may change selection
104
+ * (arrow keys, backspace, delete, home, end).
105
+ *
106
+ * @private
107
+ */
108
+ _handleKeydown() {
109
+ requestAnimationFrame(() => this._updateActiveSlot())
110
+ }
111
+
112
+ /**
113
+ * Shows the caret on the active slot when the input receives focus.
114
+ *
115
+ * @private
116
+ */
117
+ _handleFocus() {
118
+ this._updateActiveSlot()
119
+ }
120
+
121
+ /**
122
+ * Clears all active states and hides all carets on blur.
123
+ *
124
+ * @private
125
+ */
126
+ _handleBlur() {
127
+ this._clearActiveSlots()
128
+ }
129
+
130
+ /**
131
+ * Focuses the transparent input when clicking anywhere on the component.
132
+ * Places the cursor at the end of the current value.
133
+ *
134
+ * @private
135
+ */
136
+ _handleClick() {
137
+ this.inputTarget.focus()
138
+ const len = this.inputTarget.value.length
139
+ this.inputTarget.setSelectionRange(len, len)
140
+ this._updateActiveSlot()
141
+ }
142
+
143
+ /**
144
+ * Distributes input value characters to visual slot elements.
145
+ * Each slot's `[data-slot="input-otp-slot-char"]` span gets its character.
146
+ *
147
+ * @private
148
+ */
149
+ _syncSlots() {
150
+ const value = this.inputTarget.value
151
+ this.slotTargets.forEach((slot, index) => {
152
+ const charEl = slot.querySelector("[data-slot='input-otp-slot-char']")
153
+ if (charEl) {
154
+ charEl.textContent = value[index] || ""
155
+ }
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Sets `data-active="true"` on the slot matching the cursor position.
161
+ * Shows the blinking caret on the active empty slot.
162
+ *
163
+ * @private
164
+ */
165
+ _updateActiveSlot() {
166
+ if (document.activeElement !== this.inputTarget) return
167
+
168
+ const pos = Math.min(this.inputTarget.selectionStart ?? 0, this.lengthValue - 1)
169
+
170
+ this.slotTargets.forEach((slot, index) => {
171
+ const isActive = index === pos
172
+ const charEl = slot.querySelector("[data-slot='input-otp-slot-char']")
173
+ const caretEl = slot.querySelector("[data-slot='input-otp-caret']")
174
+ const hasChar = charEl && charEl.textContent !== ""
175
+
176
+ slot.dataset.active = isActive
177
+ if (caretEl) {
178
+ caretEl.hidden = !(isActive && !hasChar)
179
+ }
180
+ })
181
+ }
182
+
183
+ /**
184
+ * Removes all active states and hides all carets.
185
+ *
186
+ * @private
187
+ */
188
+ _clearActiveSlots() {
189
+ this.slotTargets.forEach((slot) => {
190
+ delete slot.dataset.active
191
+ const caretEl = slot.querySelector("[data-slot='input-otp-caret']")
192
+ if (caretEl) caretEl.hidden = true
193
+ })
194
+ }
195
+ }
@@ -0,0 +1,254 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { FOCUSABLE_SELECTOR } from "kiso-ui/utils/focusable"
3
+ import { startPositioning } from "kiso-ui/utils/positioning"
4
+
5
+ /**
6
+ * Popover controller — toggles a floating panel anchored to a trigger element.
7
+ * Supports configurable alignment (start, center, end), focus trapping,
8
+ * and close-on-outside-click / Escape.
9
+ *
10
+ * @example
11
+ * <div data-controller="kiso--popover" data-slot="popover">
12
+ * <button data-kiso--popover-target="trigger"
13
+ * data-action="click->kiso--popover#toggle keydown->kiso--popover#triggerKeydown">
14
+ * Open
15
+ * </button>
16
+ * <div data-kiso--popover-target="content" data-align="center" role="dialog" hidden>
17
+ * Content here
18
+ * </div>
19
+ * </div>
20
+ *
21
+ * @property {HTMLElement} triggerTarget - Button that opens/closes the popover
22
+ * @property {HTMLElement} contentTarget - The floating panel
23
+ * @property {HTMLElement} [anchorTarget] - Optional alternate positioning reference
24
+ */
25
+ export default class extends Controller {
26
+ static targets = ["trigger", "content", "anchor"]
27
+
28
+ connect() {
29
+ this._open = false
30
+ this._handleOutsideClick = this._handleOutsideClick.bind(this)
31
+ this._handleKeydown = this._handleKeydown.bind(this)
32
+
33
+ // Set ARIA attrs on the interactive element inside the trigger wrapper
34
+ this._triggerButton =
35
+ this.triggerTarget.querySelector("button, [tabindex]") || this.triggerTarget
36
+ this._triggerButton.setAttribute("aria-haspopup", "dialog")
37
+ this._triggerButton.setAttribute("aria-expanded", "false")
38
+ }
39
+
40
+ disconnect() {
41
+ this._cleanupPosition?.()
42
+ this._clearCloseTimers()
43
+ this._removeGlobalListeners()
44
+ }
45
+
46
+ /**
47
+ * Toggles the popover open or closed.
48
+ * Skips if the click was auto-generated by a Space keyup (already handled in triggerKeydown).
49
+ *
50
+ * @param {Event} event
51
+ */
52
+ toggle(event) {
53
+ if (this._ignoreNextClick) {
54
+ this._ignoreNextClick = false
55
+ event.preventDefault()
56
+ return
57
+ }
58
+ event.preventDefault()
59
+ if (this._open) {
60
+ this.close()
61
+ } else {
62
+ this.open()
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Opens the popover, positions it relative to the trigger (or anchor),
68
+ * and focuses the first focusable element inside.
69
+ */
70
+ open() {
71
+ if (this._open) return
72
+
73
+ this._clearCloseTimers()
74
+ this._open = true
75
+ this.contentTarget.hidden = false
76
+ this.contentTarget.setAttribute("data-state", "open")
77
+ this._triggerButton.setAttribute("aria-expanded", "true")
78
+ this.triggerTarget.setAttribute("data-state", "open")
79
+ this._positionContent()
80
+ this._addGlobalListeners()
81
+
82
+ // Focus the first focusable element inside the content
83
+ this._focusRaf = requestAnimationFrame(() => {
84
+ const focusable = this.contentTarget.querySelector(FOCUSABLE_SELECTOR)
85
+ if (focusable) {
86
+ focusable.focus()
87
+ }
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Closes the popover with a closing animation, then hides it.
93
+ * Returns focus to the trigger button.
94
+ */
95
+ close() {
96
+ if (!this._open) return
97
+
98
+ this._cleanupPosition?.()
99
+ this._cleanupPosition = null
100
+ this._clearCloseTimers()
101
+ this._open = false
102
+ this.contentTarget.setAttribute("data-state", "closed")
103
+ this._triggerButton.setAttribute("aria-expanded", "false")
104
+ this.triggerTarget.setAttribute("data-state", "closed")
105
+
106
+ // Hide after animation completes
107
+ this._animationEndHandler = () => {
108
+ this.contentTarget.hidden = true
109
+ this.contentTarget.removeEventListener("animationend", this._animationEndHandler)
110
+ }
111
+ this.contentTarget.addEventListener("animationend", this._animationEndHandler)
112
+
113
+ // Fallback: if no animation, hide immediately
114
+ this._closeTimeout = setTimeout(() => {
115
+ if (this.contentTarget.getAttribute("data-state") === "closed") {
116
+ this.contentTarget.hidden = true
117
+ }
118
+ }, 200)
119
+
120
+ this._removeGlobalListeners()
121
+ // Focus the button inside the trigger wrapper, or the trigger itself
122
+ const btn = this.triggerTarget.querySelector("button, [tabindex]")
123
+ ;(btn || this.triggerTarget).focus()
124
+ }
125
+
126
+ /**
127
+ * Opens the popover on ArrowDown, Space, or Enter when trigger is focused.
128
+ *
129
+ * @param {KeyboardEvent} event
130
+ */
131
+ triggerKeydown(event) {
132
+ switch (event.key) {
133
+ case "ArrowDown":
134
+ case " ":
135
+ case "Enter":
136
+ event.preventDefault()
137
+ if (!this._open) {
138
+ this._ignoreNextClick = true
139
+ this.open()
140
+ }
141
+ break
142
+ }
143
+ }
144
+
145
+ // --- Private ---
146
+
147
+ /**
148
+ * Cancels any pending close timers and animation handlers.
149
+ *
150
+ * @private
151
+ */
152
+ _clearCloseTimers() {
153
+ if (this._closeTimeout) {
154
+ clearTimeout(this._closeTimeout)
155
+ this._closeTimeout = null
156
+ }
157
+ if (this._focusRaf) {
158
+ cancelAnimationFrame(this._focusRaf)
159
+ this._focusRaf = null
160
+ }
161
+ if (this._animationEndHandler) {
162
+ this.contentTarget.removeEventListener("animationend", this._animationEndHandler)
163
+ this._animationEndHandler = null
164
+ }
165
+ }
166
+
167
+ /** @private */
168
+ static _alignToPlacement = {
169
+ start: "bottom-start",
170
+ center: "bottom",
171
+ end: "bottom-end",
172
+ }
173
+
174
+ /**
175
+ * Positions the content panel relative to the reference element (trigger or anchor).
176
+ * Respects `data-align` on the content element: "start", "end", or "center" (default).
177
+ * Starts auto-updating on scroll/resize.
178
+ *
179
+ * @private
180
+ */
181
+ _positionContent() {
182
+ const reference = this.hasAnchorTarget ? this.anchorTarget : this.triggerTarget
183
+ const align = this.contentTarget.dataset.align || "center"
184
+ const placement = this.constructor._alignToPlacement[align] || "bottom"
185
+
186
+ this._cleanupPosition = startPositioning(reference, this.contentTarget, {
187
+ placement,
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Closes the popover when clicking outside the component.
193
+ *
194
+ * @param {MouseEvent} event
195
+ * @private
196
+ */
197
+ _handleOutsideClick(event) {
198
+ if (!this.element.contains(event.target)) {
199
+ this.close()
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Handles Escape to close and Tab to trap focus within the popover content.
205
+ *
206
+ * @param {KeyboardEvent} event
207
+ * @private
208
+ */
209
+ _handleKeydown(event) {
210
+ if (!this._open) return
211
+
212
+ if (event.key === "Escape") {
213
+ event.preventDefault()
214
+ this.close()
215
+ }
216
+
217
+ // Trap focus inside content
218
+ if (event.key === "Tab") {
219
+ const focusableElements = this.contentTarget.querySelectorAll(FOCUSABLE_SELECTOR)
220
+
221
+ if (focusableElements.length === 0) {
222
+ event.preventDefault()
223
+ return
224
+ }
225
+
226
+ const first = focusableElements[0]
227
+ const last = focusableElements[focusableElements.length - 1]
228
+
229
+ if (event.shiftKey) {
230
+ if (document.activeElement === first) {
231
+ event.preventDefault()
232
+ last.focus()
233
+ }
234
+ } else {
235
+ if (document.activeElement === last) {
236
+ event.preventDefault()
237
+ first.focus()
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ /** @private */
244
+ _addGlobalListeners() {
245
+ document.addEventListener("click", this._handleOutsideClick, true)
246
+ document.addEventListener("keydown", this._handleKeydown)
247
+ }
248
+
249
+ /** @private */
250
+ _removeGlobalListeners() {
251
+ document.removeEventListener("click", this._handleOutsideClick, true)
252
+ document.removeEventListener("keydown", this._handleKeydown)
253
+ }
254
+ }