shadcn_view_components 0.2.0

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 (177) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +590 -0
  3. data/LICENSE +49 -0
  4. data/README.md +542 -0
  5. data/app/assets/javascripts/shadcn/aria_relationships.js +88 -0
  6. data/app/assets/javascripts/shadcn/controllers/accordion_controller.js +241 -0
  7. data/app/assets/javascripts/shadcn/controllers/calendar_controller.js +98 -0
  8. data/app/assets/javascripts/shadcn/controllers/carousel_controller.js +305 -0
  9. data/app/assets/javascripts/shadcn/controllers/checked_state_controller.js +121 -0
  10. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +613 -0
  11. data/app/assets/javascripts/shadcn/controllers/command_controller.js +87 -0
  12. data/app/assets/javascripts/shadcn/controllers/dialog_controller.js +208 -0
  13. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +145 -0
  14. data/app/assets/javascripts/shadcn/controllers/input_otp_controller.js +416 -0
  15. data/app/assets/javascripts/shadcn/controllers/menu_controller.js +286 -0
  16. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +430 -0
  17. data/app/assets/javascripts/shadcn/controllers/message_scroller_controller.js +37 -0
  18. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +254 -0
  19. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +117 -0
  20. data/app/assets/javascripts/shadcn/controllers/resizable_controller.js +256 -0
  21. data/app/assets/javascripts/shadcn/controllers/select_controller.js +491 -0
  22. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +30 -0
  23. data/app/assets/javascripts/shadcn/controllers/slider_controller.js +74 -0
  24. data/app/assets/javascripts/shadcn/controllers/tabs_controller.js +146 -0
  25. data/app/assets/javascripts/shadcn/controllers/toast_controller.js +81 -0
  26. data/app/assets/javascripts/shadcn/controllers/toggle_controller.js +21 -0
  27. data/app/assets/javascripts/shadcn/controllers/toggle_group_controller.js +37 -0
  28. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +123 -0
  29. data/app/assets/javascripts/shadcn/floating_position.js +660 -0
  30. data/app/assets/javascripts/shadcn/hide_after_exit.js +53 -0
  31. data/app/assets/javascripts/shadcn/index.js +65 -0
  32. data/app/assets/javascripts/shadcn/menu_popover.js +352 -0
  33. data/app/assets/javascripts/shadcn/package.json +17 -0
  34. data/app/assets/javascripts/shadcn/state_attrs.js +13 -0
  35. data/app/assets/stylesheets/shadcn/shadcn.css +766 -0
  36. data/app/assets/tailwind/shadcn_view_components/engine.css +11 -0
  37. data/app/components/shadcn/accordion.rb +121 -0
  38. data/app/components/shadcn/alert.rb +31 -0
  39. data/app/components/shadcn/alert_dialog.rb +165 -0
  40. data/app/components/shadcn/aspect_ratio.rb +34 -0
  41. data/app/components/shadcn/attachment.rb +86 -0
  42. data/app/components/shadcn/avatar.rb +55 -0
  43. data/app/components/shadcn/badge.rb +32 -0
  44. data/app/components/shadcn/base_component.rb +239 -0
  45. data/app/components/shadcn/breadcrumb.rb +70 -0
  46. data/app/components/shadcn/bubble.rb +48 -0
  47. data/app/components/shadcn/button.html.erb +1 -0
  48. data/app/components/shadcn/button.rb +41 -0
  49. data/app/components/shadcn/button_group.rb +41 -0
  50. data/app/components/shadcn/button_styled.rb +51 -0
  51. data/app/components/shadcn/calendar.rb +276 -0
  52. data/app/components/shadcn/card.rb +18 -0
  53. data/app/components/shadcn/carousel.rb +236 -0
  54. data/app/components/shadcn/chart.rb +57 -0
  55. data/app/components/shadcn/checkbox.rb +64 -0
  56. data/app/components/shadcn/collapsible.rb +25 -0
  57. data/app/components/shadcn/combobox.rb +552 -0
  58. data/app/components/shadcn/command.rb +149 -0
  59. data/app/components/shadcn/context_menu.rb +79 -0
  60. data/app/components/shadcn/dialog.rb +180 -0
  61. data/app/components/shadcn/direction_provider.rb +18 -0
  62. data/app/components/shadcn/drawer.rb +150 -0
  63. data/app/components/shadcn/dropdown_menu.rb +242 -0
  64. data/app/components/shadcn/empty.rb +40 -0
  65. data/app/components/shadcn/field.rb +106 -0
  66. data/app/components/shadcn/floating_position_options.rb +132 -0
  67. data/app/components/shadcn/form.rb +105 -0
  68. data/app/components/shadcn/hover_card.rb +66 -0
  69. data/app/components/shadcn/input.rb +13 -0
  70. data/app/components/shadcn/input_group/button.rb +48 -0
  71. data/app/components/shadcn/input_group.rb +51 -0
  72. data/app/components/shadcn/input_otp.rb +265 -0
  73. data/app/components/shadcn/item.rb +108 -0
  74. data/app/components/shadcn/kbd.rb +9 -0
  75. data/app/components/shadcn/label.rb +12 -0
  76. data/app/components/shadcn/marker.rb +37 -0
  77. data/app/components/shadcn/menubar.rb +110 -0
  78. data/app/components/shadcn/message.rb +29 -0
  79. data/app/components/shadcn/message_scroller.rb +52 -0
  80. data/app/components/shadcn/native_checked_state.rb +74 -0
  81. data/app/components/shadcn/native_select.rb +78 -0
  82. data/app/components/shadcn/navigation_menu.rb +122 -0
  83. data/app/components/shadcn/pagination.rb +160 -0
  84. data/app/components/shadcn/popover.rb +68 -0
  85. data/app/components/shadcn/preview_base.rb +107 -0
  86. data/app/components/shadcn/progress.rb +49 -0
  87. data/app/components/shadcn/radio_group.rb +74 -0
  88. data/app/components/shadcn/resizable.rb +77 -0
  89. data/app/components/shadcn/scroll_area.rb +59 -0
  90. data/app/components/shadcn/select.rb +356 -0
  91. data/app/components/shadcn/separator.rb +42 -0
  92. data/app/components/shadcn/sheet.rb +157 -0
  93. data/app/components/shadcn/sidebar.rb +332 -0
  94. data/app/components/shadcn/skeleton.rb +13 -0
  95. data/app/components/shadcn/slider.rb +279 -0
  96. data/app/components/shadcn/sonner.rb +30 -0
  97. data/app/components/shadcn/spinner.rb +32 -0
  98. data/app/components/shadcn/switch.rb +79 -0
  99. data/app/components/shadcn/table.rb +46 -0
  100. data/app/components/shadcn/tabs.rb +119 -0
  101. data/app/components/shadcn/textarea.rb +7 -0
  102. data/app/components/shadcn/toggle.rb +66 -0
  103. data/app/components/shadcn/toggle_group.rb +127 -0
  104. data/app/components/shadcn/tooltip.rb +74 -0
  105. data/config/importmap.rb +5 -0
  106. data/lib/generators/shadcn_view_components/install_generator.rb +182 -0
  107. data/lib/shadcn_view_components/classes.rb +89 -0
  108. data/lib/shadcn_view_components/component_naming.rb +21 -0
  109. data/lib/shadcn_view_components/contracts/calendar.rb +95 -0
  110. data/lib/shadcn_view_components/engine.rb +56 -0
  111. data/lib/shadcn_view_components/generated/contracts/accordion.rb +112 -0
  112. data/lib/shadcn_view_components/generated/contracts/alert-dialog.rb +300 -0
  113. data/lib/shadcn_view_components/generated/contracts/alert.rb +104 -0
  114. data/lib/shadcn_view_components/generated/contracts/aspect-ratio.rb +34 -0
  115. data/lib/shadcn_view_components/generated/contracts/attachment.rb +224 -0
  116. data/lib/shadcn_view_components/generated/contracts/avatar.rb +149 -0
  117. data/lib/shadcn_view_components/generated/contracts/badge.rb +39 -0
  118. data/lib/shadcn_view_components/generated/contracts/breadcrumb.rb +172 -0
  119. data/lib/shadcn_view_components/generated/contracts/bubble.rb +112 -0
  120. data/lib/shadcn_view_components/generated/contracts/button-group.rb +81 -0
  121. data/lib/shadcn_view_components/generated/contracts/button.rb +81 -0
  122. data/lib/shadcn_view_components/generated/contracts/card.rb +172 -0
  123. data/lib/shadcn_view_components/generated/contracts/carousel.rb +133 -0
  124. data/lib/shadcn_view_components/generated/contracts/chart.rb +109 -0
  125. data/lib/shadcn_view_components/generated/contracts/checkbox.rb +35 -0
  126. data/lib/shadcn_view_components/generated/contracts/collapsible.rb +89 -0
  127. data/lib/shadcn_view_components/generated/contracts/combobox.rb +368 -0
  128. data/lib/shadcn_view_components/generated/contracts/command.rb +222 -0
  129. data/lib/shadcn_view_components/generated/contracts/context-menu.rb +378 -0
  130. data/lib/shadcn_view_components/generated/contracts/dialog.rb +258 -0
  131. data/lib/shadcn_view_components/generated/contracts/direction.rb +34 -0
  132. data/lib/shadcn_view_components/generated/contracts/drawer.rb +280 -0
  133. data/lib/shadcn_view_components/generated/contracts/dropdown-menu.rb +380 -0
  134. data/lib/shadcn_view_components/generated/contracts/empty.rb +150 -0
  135. data/lib/shadcn_view_components/generated/contracts/field.rb +244 -0
  136. data/lib/shadcn_view_components/generated/contracts/form.rb +62 -0
  137. data/lib/shadcn_view_components/generated/contracts/hover-card.rb +90 -0
  138. data/lib/shadcn_view_components/generated/contracts/input-group.rb +155 -0
  139. data/lib/shadcn_view_components/generated/contracts/input-otp.rb +106 -0
  140. data/lib/shadcn_view_components/generated/contracts/input.rb +34 -0
  141. data/lib/shadcn_view_components/generated/contracts/item.rb +251 -0
  142. data/lib/shadcn_view_components/generated/contracts/kbd.rb +57 -0
  143. data/lib/shadcn_view_components/generated/contracts/label.rb +34 -0
  144. data/lib/shadcn_view_components/generated/contracts/marker.rb +82 -0
  145. data/lib/shadcn_view_components/generated/contracts/menubar.rb +394 -0
  146. data/lib/shadcn_view_components/generated/contracts/message-scroller.rb +149 -0
  147. data/lib/shadcn_view_components/generated/contracts/message.rb +149 -0
  148. data/lib/shadcn_view_components/generated/contracts/native-select.rb +82 -0
  149. data/lib/shadcn_view_components/generated/contracts/navigation-menu.rb +195 -0
  150. data/lib/shadcn_view_components/generated/contracts/pagination.rb +176 -0
  151. data/lib/shadcn_view_components/generated/contracts/popover.rb +159 -0
  152. data/lib/shadcn_view_components/generated/contracts/progress.rb +126 -0
  153. data/lib/shadcn_view_components/generated/contracts/radio-group.rb +58 -0
  154. data/lib/shadcn_view_components/generated/contracts/resizable.rb +85 -0
  155. data/lib/shadcn_view_components/generated/contracts/scroll-area.rb +59 -0
  156. data/lib/shadcn_view_components/generated/contracts/select.rb +245 -0
  157. data/lib/shadcn_view_components/generated/contracts/separator.rb +34 -0
  158. data/lib/shadcn_view_components/generated/contracts/sheet.rb +209 -0
  159. data/lib/shadcn_view_components/generated/contracts/sidebar.rb +558 -0
  160. data/lib/shadcn_view_components/generated/contracts/skeleton.rb +34 -0
  161. data/lib/shadcn_view_components/generated/contracts/slider.rb +38 -0
  162. data/lib/shadcn_view_components/generated/contracts/sonner.rb +36 -0
  163. data/lib/shadcn_view_components/generated/contracts/spinner.rb +34 -0
  164. data/lib/shadcn_view_components/generated/contracts/switch.rb +35 -0
  165. data/lib/shadcn_view_components/generated/contracts/table.rb +199 -0
  166. data/lib/shadcn_view_components/generated/contracts/tabs.rb +104 -0
  167. data/lib/shadcn_view_components/generated/contracts/textarea.rb +34 -0
  168. data/lib/shadcn_view_components/generated/contracts/toggle-group.rb +62 -0
  169. data/lib/shadcn_view_components/generated/contracts/toggle.rb +39 -0
  170. data/lib/shadcn_view_components/generated/contracts/tooltip.rb +116 -0
  171. data/lib/shadcn_view_components/numeric_property_validation.rb +124 -0
  172. data/lib/shadcn_view_components/property_contract_definitions.rb +92 -0
  173. data/lib/shadcn_view_components/property_contracts.rb +42 -0
  174. data/lib/shadcn_view_components/property_validation.rb +55 -0
  175. data/lib/shadcn_view_components/version.rb +6 -0
  176. data/lib/shadcn_view_components.rb +21 -0
  177. metadata +446 -0
@@ -0,0 +1,613 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import {
4
+ ensureId,
5
+ ensureRootId,
6
+ ownedElements,
7
+ setDefaultAttribute,
8
+ } from "@supermomonga/shadcn-view-components/aria_relationships"
9
+ import { hideAfterExit } from "@supermomonga/shadcn-view-components/hide_after_exit"
10
+
11
+ const noop = () => {}
12
+ const IDENTIFIER = "shadcn--combobox"
13
+
14
+ // 検索文字列・キーボード上のハイライト・確定したフォーム値を分離して管理する。
15
+ // 確定値の唯一の情報源は root 直下の select[data-slot=combobox-form-control]。
16
+ /** @extends {Controller<HTMLElement>} */
17
+ export default class ComboboxController extends Controller {
18
+ /** @type {string} */
19
+ query = ""
20
+
21
+ /** @type {(event: KeyboardEvent) => void} */
22
+ onKeydown = (event) => this.navigate(event)
23
+
24
+ /** @type {() => void} */
25
+ onListToggle = () => this.syncNativeListState()
26
+
27
+ /** @type {() => void} */
28
+ onFormControlFocus = () => this.input?.focus()
29
+
30
+ /** @type {(event: PointerEvent) => void} */
31
+ onDocumentPointerDown = (event) => this.closeFromOutsidePointer(event)
32
+
33
+ /** @type {() => void} */
34
+ onFocusOut = () => this.closeAfterFocusChange()
35
+
36
+ /** @type {() => void} */
37
+ cancelListExit = noop
38
+
39
+ /** @type {boolean} */
40
+ desiredListOpen = false
41
+
42
+ /** @type {boolean} */
43
+ listExitPending = false
44
+
45
+ connect() {
46
+ /** @type {HTMLSelectElement | null} */
47
+ this.formControl = /** @type {HTMLSelectElement | null} */ (
48
+ this.owned("select[data-slot='combobox-form-control']")[0] ?? null
49
+ )
50
+ /** @type {HTMLInputElement | null} */
51
+ this.emptyFormControl = /** @type {HTMLInputElement | null} */ (
52
+ this.owned("input[data-slot='combobox-empty-form-control']")[0] ?? null
53
+ )
54
+ this.multiple = this.formControl?.multiple ?? false
55
+ this.query = ""
56
+ /** @type {HTMLElement | null} */
57
+ this.chips = this.owned("[data-slot='combobox-chips']")[0] ?? null
58
+ /** @type {HTMLInputElement | null} */
59
+ this.input = /** @type {HTMLInputElement | null} */ (
60
+ (this.chips && this.owned("[data-slot='combobox-chip-input']")[0]) ||
61
+ this.owned("input[role='combobox']")[0] || null
62
+ )
63
+ /** @type {HTMLElement | null} */
64
+ this.empty = this.owned("[data-slot='combobox-empty']")[0] ?? null
65
+ /** @type {HTMLTemplateElement | null} */
66
+ const template = this.chips?.querySelector(":scope > template[data-slot='combobox-chip-template']") ?? null
67
+ /** @type {HTMLElement | null} */
68
+ this.chipTemplate = template?.content.querySelector("[data-slot='combobox-chip']") ?? null
69
+ /** @type {HTMLElement | null} */
70
+ this.list = this.owned("[data-slot='combobox-content'][popover]")[0] ?? null
71
+
72
+ this.ensureRelationships()
73
+
74
+ this.element.addEventListener("keydown", this.onKeydown, true)
75
+ this.element.addEventListener("focusout", this.onFocusOut)
76
+ document.addEventListener("pointerdown", this.onDocumentPointerDown, true)
77
+ this.formControl?.addEventListener("focus", this.onFormControlFocus)
78
+ this.cancelPendingListExit()
79
+ if (this.list) {
80
+ this.desiredListOpen = this.list.matches(":popover-open")
81
+ this.list.addEventListener("toggle", this.onListToggle)
82
+ this.syncListState(this.desiredListOpen)
83
+ }
84
+
85
+ this.applyDisabledState()
86
+ this.normalizeFormControl()
87
+ this.syncCommittedPresentation()
88
+ this.restoreCommittedQuery()
89
+ }
90
+
91
+ disconnect() {
92
+ const list = this.list
93
+ const finishExit = list?.dataset.state === "closed" && list.matches(":popover-open")
94
+ this.cancelPendingListExit()
95
+ this.desiredListOpen = false
96
+ // Turbo が切断中の DOM をキャッシュしても、一時的な検索文字列を残さない。
97
+ this.restoreCommittedQuery()
98
+ this.element.removeEventListener("keydown", this.onKeydown, true)
99
+ this.element.removeEventListener("focusout", this.onFocusOut)
100
+ document.removeEventListener("pointerdown", this.onDocumentPointerDown, true)
101
+ this.formControl?.removeEventListener("focus", this.onFormControlFocus)
102
+ list?.removeEventListener("toggle", this.onListToggle)
103
+ if (finishExit) list.hidePopover()
104
+ }
105
+
106
+ /** @returns {HTMLElement[]} */
107
+ get items() {
108
+ return this.owned("[data-slot='combobox-item']")
109
+ }
110
+
111
+ /** @returns {HTMLElement[]} */
112
+ get visibleItems() {
113
+ return this.items.filter((item) => !item.hidden)
114
+ }
115
+
116
+ /** @returns {HTMLElement[]} */
117
+ get enabledVisibleItems() {
118
+ return this.visibleItems.filter((item) => !this.itemDisabled(item))
119
+ }
120
+
121
+ /** @returns {string[]} */
122
+ get rawCommittedValues() {
123
+ if (!this.formControl) return []
124
+ return [...this.formControl.selectedOptions].map((option) => option.value)
125
+ }
126
+
127
+ /** @returns {string[]} */
128
+ get committedValues() {
129
+ return this.normalizeValues(this.rawCommittedValues)
130
+ }
131
+
132
+ /** @returns {boolean} */
133
+ get disabled() {
134
+ return this.formControl?.disabled ?? false
135
+ }
136
+
137
+ /** @returns {boolean} */
138
+ get listOpen() {
139
+ return Boolean(this.desiredListOpen && this.list?.matches(":popover-open"))
140
+ }
141
+
142
+ /** @param {Event} [event] */
143
+ filter(event) {
144
+ if (!this.input) return
145
+ if (event?.target === this.input) {
146
+ this.query = this.input.value.trim().toLowerCase()
147
+ if (!this.multiple) this.clearCommittedValueForEditedQuery()
148
+ }
149
+
150
+ const items = this.items
151
+ let visible = 0
152
+ for (const item of items) {
153
+ const text = `${item.dataset.value ?? ""} ${this.itemLabel(item)}`.toLowerCase()
154
+ const match = this.query === "" || text.includes(this.query)
155
+ item.hidden = !match
156
+ if (match) visible += 1
157
+ }
158
+
159
+ const groups = this.owned("[data-slot='combobox-group']")
160
+ for (const group of groups) {
161
+ const groupItems = items.filter((item) => group.contains(item))
162
+ group.hidden = groupItems.every((item) => item.hidden)
163
+ }
164
+ const empty = visible === 0
165
+ if (this.empty) this.empty.hidden = !empty
166
+ this.list?.toggleAttribute("data-empty", empty)
167
+ this.owned("[data-slot='combobox-list']")[0]?.toggleAttribute("data-empty", empty)
168
+ this.highlight(this.enabledVisibleItems[0])
169
+ }
170
+
171
+ /** @param {KeyboardEvent} event */
172
+ navigate(event) {
173
+ if (!(event.target instanceof Element) ||
174
+ event.target.closest(`[data-controller~='${IDENTIFIER}']`) !== this.element) return
175
+
176
+ if (event.key === "Escape" && this.listOpen) {
177
+ event.preventDefault()
178
+ event.stopPropagation()
179
+ this.closeList()
180
+ this.input?.focus()
181
+ return
182
+ }
183
+
184
+ const keys = ["ArrowDown", "ArrowUp", "Enter", "Home", "End"]
185
+ if (event.target !== this.input || !keys.includes(event.key) || this.disabled) return
186
+
187
+ // 閉じた候補に残るhighlightは確定対象ではない。単一選択ではフォーム送信など
188
+ // input本来のEnterへ委ね、複数選択だけを自由入力として扱う。
189
+ if (!this.listOpen) {
190
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
191
+ event.preventDefault()
192
+ this.openList()
193
+ const items = this.enabledVisibleItems
194
+ const item = ["ArrowUp", "End"].includes(event.key) ? items.at(-1) : items[0]
195
+ this.highlight(item)
196
+ return
197
+ }
198
+ if (event.key === "Enter" && this.multiple) {
199
+ event.preventDefault()
200
+ this.commitFreeValue(this.input?.value)
201
+ }
202
+ return
203
+ }
204
+
205
+ if (event.key === "Enter" && this.multiple) {
206
+ event.preventDefault()
207
+ const highlighted = this.enabledVisibleItems.find((item) => item.hasAttribute("data-highlighted"))
208
+ if (highlighted) highlighted.click()
209
+ else this.commitFreeValue(this.input?.value)
210
+ return
211
+ }
212
+
213
+ const items = this.enabledVisibleItems
214
+ if (items.length === 0) {
215
+ if (event.key === "Enter") event.preventDefault()
216
+ return
217
+ }
218
+ event.preventDefault()
219
+ if (event.key === "Enter") {
220
+ items.find((item) => item.hasAttribute("data-highlighted"))?.click()
221
+ return
222
+ }
223
+
224
+ const current = items.findIndex((item) => item.hasAttribute("data-highlighted"))
225
+ let next = current
226
+ if (event.key === "ArrowDown") next = (current + 1 + items.length) % items.length
227
+ if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length
228
+ if (event.key === "Home") next = 0
229
+ if (event.key === "End") next = items.length - 1
230
+ this.highlight(items[Math.max(0, next)])
231
+ }
232
+
233
+ /** @param {Event} event */
234
+ select(event) {
235
+ const item = event.currentTarget
236
+ if (!(item instanceof HTMLElement) || this.disabled || this.itemDisabled(item)) return
237
+
238
+ const value = item.dataset.value
239
+ if (!value) return
240
+ this.highlight(item)
241
+ if (this.multiple) {
242
+ this.writeCommittedValues([...this.committedValues, value], { notify: true })
243
+ if (this.input) this.input.value = ""
244
+ this.query = ""
245
+ this.filter()
246
+ return
247
+ }
248
+
249
+ this.writeCommittedValues([value], { notify: true })
250
+ this.closeList()
251
+ }
252
+
253
+ /** @param {HTMLElement | undefined} item */
254
+ highlight(item) {
255
+ for (const candidate of this.items) {
256
+ candidate.toggleAttribute("data-highlighted", candidate === item)
257
+ }
258
+ this.syncActiveDescendant(item)
259
+ }
260
+
261
+ /** @param {Event} event */
262
+ removeChip(event) {
263
+ if (this.disabled) return
264
+ const target = event.currentTarget
265
+ if (!(target instanceof HTMLElement)) return
266
+ const chip = /** @type {HTMLElement | null} */ (target.closest("[data-slot='combobox-chip']"))
267
+ const value = chip?.dataset.value
268
+ if (!value) return
269
+
270
+ this.writeCommittedValues(this.committedValues.filter((candidate) => candidate !== value), { notify: true })
271
+ }
272
+
273
+ /** @param {string | null | undefined} rawValue */
274
+ commitFreeValue(rawValue) {
275
+ const value = (rawValue ?? "").trim()
276
+ if (!this.multiple || this.disabled || value === "") return
277
+
278
+ this.writeCommittedValues([...this.committedValues, value], { notify: true })
279
+ if (this.input) this.input.value = ""
280
+ this.query = ""
281
+ this.filter()
282
+ }
283
+
284
+ normalizeFormControl() {
285
+ const normalized = this.normalizeValues(this.rawCommittedValues)
286
+ const expected = !this.multiple && normalized.length === 0 ? [""] : normalized
287
+ if (!this.sameValues(this.rawCommittedValues, expected)) this.replaceFormOptions(normalized)
288
+ this.syncEmptyFormControl()
289
+ }
290
+
291
+ /**
292
+ * @param {string[]} values
293
+ * @param {{ notify: boolean }} options
294
+ * @returns {boolean}
295
+ */
296
+ writeCommittedValues(values, { notify }) {
297
+ const normalized = this.normalizeValues(values)
298
+ if (this.sameValues(this.committedValues, normalized)) return false
299
+
300
+ this.replaceFormOptions(normalized)
301
+ this.syncCommittedPresentation()
302
+ if (notify) this.dispatchCommittedValueEvents()
303
+ return true
304
+ }
305
+
306
+ /** @param {string[]} values */
307
+ replaceFormOptions(values) {
308
+ if (!this.formControl) return
309
+ const optionValues = !this.multiple && values.length === 0 ? [""] : values
310
+ const options = optionValues.map((value) => {
311
+ const option = document.createElement("option")
312
+ option.value = value
313
+ option.textContent = value
314
+ option.selected = true
315
+ option.defaultSelected = true
316
+ return option
317
+ })
318
+ this.formControl.replaceChildren(...options)
319
+ this.syncEmptyFormControl()
320
+ }
321
+
322
+ syncEmptyFormControl() {
323
+ if (!this.emptyFormControl) return
324
+ this.emptyFormControl.disabled = this.disabled || this.committedValues.length > 0
325
+ }
326
+
327
+ syncCommittedPresentation() {
328
+ const values = this.committedValues
329
+ const selected = new Set(values)
330
+ for (const item of this.items) {
331
+ const isSelected = selected.has(item.dataset.value ?? "")
332
+ item.dataset.selected = String(isSelected)
333
+ item.setAttribute("aria-selected", String(isSelected))
334
+ const indicator = /** @type {HTMLElement | null} */ (item.querySelector("[data-indicator]"))
335
+ if (indicator) indicator.hidden = !isSelected
336
+ }
337
+
338
+ if (this.multiple) {
339
+ this.syncChips(values)
340
+ } else if (this.input && values[0] !== undefined) {
341
+ this.input.value = this.labelForValue(values[0])
342
+ }
343
+ }
344
+
345
+ /** @param {string[]} values */
346
+ syncChips(values) {
347
+ if (!this.chips) return
348
+ /** @type {Map<string, HTMLElement>} */
349
+ const existing = new Map()
350
+ // remove() による live HTMLCollection の index shift で次の chip を飛ばさない。
351
+ for (const child of [...this.chips.children]) {
352
+ if (!(child instanceof HTMLElement) || child.dataset.slot !== "combobox-chip") continue
353
+ const value = child.dataset.value
354
+ if (!value || existing.has(value) || !values.includes(value)) {
355
+ child.remove()
356
+ } else {
357
+ existing.set(value, child)
358
+ }
359
+ }
360
+
361
+ const template = this.chips.querySelector(":scope > template[data-slot='combobox-chip-template']")
362
+ let reference = this.input?.parentElement === this.chips ? this.input : template
363
+ // 末尾から並びを確定し、既に正しい位置にある chip は動かさない。
364
+ // Stimulus の action を持つ既存要素を不要に再接続させないためである。
365
+ for (const value of [...values].reverse()) {
366
+ const chip = existing.get(value) ?? this.buildChip(value, this.labelForValue(value))
367
+ if (chip.nextElementSibling !== reference) this.chips.insertBefore(chip, reference)
368
+ reference = chip
369
+ existing.delete(value)
370
+ }
371
+ for (const chip of existing.values()) chip.remove()
372
+ this.applyDisabledState()
373
+ }
374
+
375
+ /** @param {string} value @param {string} label @returns {HTMLElement} */
376
+ buildChip(value, label) {
377
+ if (!this.chipTemplate) throw new Error("Combobox chips template is missing")
378
+
379
+ const chip = /** @type {HTMLElement} */ (this.chipTemplate.cloneNode(true))
380
+ chip.removeAttribute("id")
381
+ chip.querySelectorAll("[id]").forEach((element) => element.removeAttribute("id"))
382
+ chip.dataset.value = value
383
+ const labelNode = chip.querySelector("[data-slot='combobox-chip-label']")
384
+ if (!labelNode) throw new Error("Combobox chips template label is missing")
385
+ labelNode.textContent = label
386
+ return chip
387
+ }
388
+
389
+ /** @param {string} value @returns {string} */
390
+ labelForValue(value) {
391
+ const item = this.items.find((candidate) => candidate.dataset.value === value)
392
+ return item ? this.itemLabel(item) : value
393
+ }
394
+
395
+ /** @param {HTMLElement} item @returns {string} */
396
+ itemLabel(item) {
397
+ return item.textContent?.trim() ?? ""
398
+ }
399
+
400
+ clearCommittedValueForEditedQuery() {
401
+ const value = this.committedValues[0]
402
+ if (value === undefined || this.input?.value !== "") return
403
+
404
+ this.writeCommittedValues([], { notify: true })
405
+ }
406
+
407
+ restoreCommittedQuery() {
408
+ this.query = ""
409
+ if (!this.input) return
410
+ const value = this.committedValues[0]
411
+ this.input.value = !this.multiple && value !== undefined ? this.labelForValue(value) : ""
412
+ this.filter()
413
+ }
414
+
415
+ dispatchCommittedValueEvents() {
416
+ if (!this.formControl) return
417
+ this.formControl.dispatchEvent(new window.Event("input", { bubbles: true }))
418
+ this.formControl.dispatchEvent(new window.Event("change", { bubbles: true }))
419
+ }
420
+
421
+ applyDisabledState() {
422
+ this.syncEmptyFormControl()
423
+ if (this.input) {
424
+ this.input.disabled = this.disabled
425
+ if (this.formControl?.required) {
426
+ this.input.setAttribute("aria-required", "true")
427
+ } else {
428
+ this.input.removeAttribute("aria-required")
429
+ }
430
+ }
431
+ if (this.list) {
432
+ if (this.multiple) {
433
+ this.list.setAttribute("aria-multiselectable", "true")
434
+ } else {
435
+ this.list.removeAttribute("aria-multiselectable")
436
+ }
437
+ }
438
+ if (!this.disabled) return
439
+ const buttons = /** @type {HTMLButtonElement[]} */ (this.owned(
440
+ "button[data-action*='shadcn--combobox#toggleList'], [data-slot='combobox-chip-remove']"
441
+ ))
442
+ for (const button of buttons) button.disabled = true
443
+ }
444
+
445
+ /** @param {HTMLElement} item @returns {boolean} */
446
+ itemDisabled(item) {
447
+ return item.hasAttribute("disabled") || item.hasAttribute("data-disabled") ||
448
+ item.getAttribute("aria-disabled") === "true"
449
+ }
450
+
451
+ /** @param {string[]} values @returns {string[]} */
452
+ normalizeValues(values) {
453
+ const normalized = [...new Set(values.filter((value) => value !== ""))]
454
+ return this.multiple ? normalized : normalized.slice(0, 1)
455
+ }
456
+
457
+ /** @param {string[]} left @param {string[]} right @returns {boolean} */
458
+ sameValues(left, right) {
459
+ return left.length === right.length && left.every((value, index) => value === right[index])
460
+ }
461
+
462
+ /** @param {boolean} open */
463
+ syncListState(open) {
464
+ if (!this.list) return
465
+ this.list.dataset.state = open ? "open" : "closed"
466
+ if (open) this.list.dataset.side = "bottom"
467
+ if (open) {
468
+ this.list.setAttribute("data-open", "")
469
+ this.list.removeAttribute("data-closed")
470
+ } else {
471
+ this.list.setAttribute("data-closed", "")
472
+ this.list.removeAttribute("data-open")
473
+ // 単一選択では検索表示を確定ラベルへ戻す。複数選択の入力欄は
474
+ // focus 移動に伴う popover の遅延 close で編集中の場合があるため触らない。
475
+ if (!this.multiple) this.restoreCommittedQuery()
476
+ }
477
+ this.input?.setAttribute("aria-expanded", String(open))
478
+ for (const trigger of this.toggleTriggers) trigger.setAttribute("aria-expanded", String(open))
479
+ this.syncActiveDescendant(
480
+ open ? this.enabledVisibleItems.find((item) => item.hasAttribute("data-highlighted")) : undefined,
481
+ )
482
+ }
483
+
484
+ syncNativeListState() {
485
+ const list = this.list
486
+ if (!list) return
487
+
488
+ const open = list.matches(":popover-open")
489
+ // showPopover() の toggle は遅れて届く場合がある。close 要求後の退出中は
490
+ // 実要素がまだ :popover-open でも、閉じた論理状態を再び開かない。
491
+ if (open && !this.desiredListOpen && this.listExitPending) return
492
+
493
+ this.desiredListOpen = open
494
+ this.cancelPendingListExit()
495
+ this.syncListState(open)
496
+ }
497
+
498
+ cancelPendingListExit() {
499
+ const cancel = this.cancelListExit
500
+ this.cancelListExit = noop
501
+ this.listExitPending = false
502
+ cancel()
503
+ }
504
+
505
+ toggleList() {
506
+ const list = this.list
507
+ if (!list || this.disabled) return
508
+
509
+ if (this.listOpen) {
510
+ this.closeList()
511
+ } else {
512
+ this.openList()
513
+ }
514
+ }
515
+
516
+ openList() {
517
+ const list = this.list
518
+ if (!list || this.disabled) return
519
+
520
+ this.desiredListOpen = true
521
+ this.cancelPendingListExit()
522
+ this.query = this.multiple ? (this.input?.value ?? "").trim().toLowerCase() : ""
523
+ this.filter()
524
+ this.syncListState(true)
525
+ if (!list.matches(":popover-open")) list.showPopover()
526
+ this.input?.focus()
527
+ }
528
+
529
+ closeList() {
530
+ const list = this.list
531
+ if (!list || !this.desiredListOpen) return
532
+
533
+ this.desiredListOpen = false
534
+ this.cancelPendingListExit()
535
+ this.syncListState(false)
536
+ if (!list.matches(":popover-open")) return
537
+
538
+ this.listExitPending = true
539
+ let completed = false
540
+ const cancel = hideAfterExit(list, () => {
541
+ completed = true
542
+ this.listExitPending = false
543
+ this.cancelListExit = noop
544
+ if (this.desiredListOpen) return
545
+ list.hidePopover()
546
+ })
547
+ if (!completed) this.cancelListExit = cancel
548
+ }
549
+
550
+ /** @param {PointerEvent} event */
551
+ closeFromOutsidePointer(event) {
552
+ const target = event.target
553
+ if (!this.listOpen || !(target instanceof Node) || this.element.contains(target)) return
554
+ this.closeList()
555
+ }
556
+
557
+ closeAfterFocusChange() {
558
+ // focusout中はactiveElementが一時的にbodyになるブラウザ操作もあるため、
559
+ // 一連のfocus遷移が終わってからroot外へ出たかを判定する。
560
+ window.queueMicrotask(() => {
561
+ if (!this.element.isConnected || !this.listOpen) return
562
+ const activeElement = document.activeElement
563
+ if (activeElement && this.element.contains(activeElement)) return
564
+ this.closeList()
565
+ })
566
+ }
567
+
568
+ ensureRelationships() {
569
+ const rootId = ensureRootId(this.root, "combobox")
570
+ const list = this.list
571
+ if (!list) return
572
+
573
+ const listId = ensureId(list, `${rootId}-listbox`)
574
+ if (this.formControl) ensureId(this.formControl, `${rootId}-form-control`)
575
+ this.items.forEach((item, index) => ensureId(item, `${rootId}-option-${index + 1}`))
576
+
577
+ if (this.input) {
578
+ const inputId = ensureId(this.input, `${rootId}-input`)
579
+ setDefaultAttribute(this.input, "aria-controls", listId)
580
+ setDefaultAttribute(list, "aria-labelledby", inputId)
581
+ }
582
+ this.toggleTriggers.forEach((trigger, index) => {
583
+ ensureId(trigger, `${rootId}-trigger-${index + 1}`)
584
+ setDefaultAttribute(trigger, "aria-controls", listId)
585
+ })
586
+ }
587
+
588
+ /** @param {HTMLElement | undefined} item */
589
+ syncActiveDescendant(item) {
590
+ if (!this.input) return
591
+ if (!this.listOpen || !item || item.hidden) {
592
+ this.input.removeAttribute("aria-activedescendant")
593
+ return
594
+ }
595
+
596
+ this.input.setAttribute("aria-activedescendant", item.id)
597
+ }
598
+
599
+ /** @returns {HTMLElement[]} */
600
+ get toggleTriggers() {
601
+ return this.owned("button[data-action*='shadcn--combobox#toggleList']")
602
+ }
603
+
604
+ /** @param {string} selector @returns {HTMLElement[]} */
605
+ owned(selector) {
606
+ return ownedElements(this.root, selector, IDENTIFIER)
607
+ }
608
+
609
+ /** @returns {HTMLElement} */
610
+ get root() {
611
+ return /** @type {HTMLElement} */ (this.element)
612
+ }
613
+ }
@@ -0,0 +1,87 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // SSR済みのCommand項目を検索文字列で絞り込み、キーボード上のハイライトを管理する。
4
+ // Comboboxの確定値とフォーム連携は専用controllerが担当する。
5
+ /** @extends {Controller<HTMLElement>} */
6
+ export default class CommandController extends Controller {
7
+ /** @type {(event: KeyboardEvent) => void} */
8
+ onKeydown = (event) => this.navigate(event)
9
+
10
+ connect() {
11
+ /** @type {HTMLInputElement | null} */
12
+ this.input = this.element.querySelector("[data-slot='command-input']")
13
+ /** @type {HTMLElement | null} */
14
+ this.empty = this.element.querySelector("[data-slot='command-empty']")
15
+ this.element.addEventListener("keydown", this.onKeydown, true)
16
+ this.filter()
17
+ }
18
+
19
+ disconnect() {
20
+ this.element.removeEventListener("keydown", this.onKeydown, true)
21
+ }
22
+
23
+ /** @returns {HTMLElement[]} */
24
+ get items() {
25
+ /** @type {NodeListOf<HTMLElement>} */
26
+ const items = this.element.querySelectorAll("[data-slot='command-item']")
27
+ return [...items]
28
+ }
29
+
30
+ /** @returns {HTMLElement[]} */
31
+ get visibleItems() {
32
+ return this.items.filter((item) => !item.hidden)
33
+ }
34
+
35
+ filter() {
36
+ if (!this.input) return
37
+ const query = this.input.value.trim().toLowerCase()
38
+ let visible = 0
39
+ for (const item of this.items) {
40
+ const text = `${item.dataset.value ?? ""} ${item.textContent}`.toLowerCase()
41
+ const match = query === "" || text.includes(query)
42
+ item.hidden = !match
43
+ if (match) visible += 1
44
+ }
45
+
46
+ /** @type {NodeListOf<HTMLElement>} */
47
+ const groups = this.element.querySelectorAll("[data-slot='command-group']")
48
+ for (const group of groups) {
49
+ /** @type {NodeListOf<HTMLElement>} */
50
+ const groupItems = group.querySelectorAll("[data-slot='command-item']")
51
+ group.hidden = [...groupItems].every((item) => item.hidden)
52
+ }
53
+ if (this.empty) this.empty.hidden = visible > 0
54
+ this.highlight(this.visibleItems[0])
55
+ }
56
+
57
+ /** @param {KeyboardEvent} event */
58
+ navigate(event) {
59
+ const keys = ["ArrowDown", "ArrowUp", "Enter", "Home", "End"]
60
+ if (!keys.includes(event.key)) return
61
+ const items = this.visibleItems
62
+ if (items.length === 0) return
63
+ event.preventDefault()
64
+
65
+ if (event.key === "Enter") {
66
+ items.find((item) => item.dataset.selected === "true")?.click()
67
+ return
68
+ }
69
+ const current = items.findIndex((item) => item.dataset.selected === "true")
70
+ let next = current
71
+ if (event.key === "ArrowDown") next = (current + 1 + items.length) % items.length
72
+ if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length
73
+ if (event.key === "Home") next = 0
74
+ if (event.key === "End") next = items.length - 1
75
+ this.highlight(items[Math.max(0, next)])
76
+ }
77
+
78
+ /** @param {HTMLElement | undefined} item */
79
+ highlight(item) {
80
+ for (const candidate of this.items) {
81
+ const selected = candidate === item
82
+ candidate.dataset.selected = selected ? "true" : "false"
83
+ if (selected) candidate.setAttribute("aria-selected", "true")
84
+ else candidate.removeAttribute("aria-selected")
85
+ }
86
+ }
87
+ }