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,430 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import {
4
+ activateMenuItemFromKeyboard,
5
+ applyMenuTriggerState,
6
+ associateMenuElements,
7
+ directMenuItems,
8
+ isOwnedMenuElement,
9
+ MenuPopoverManager,
10
+ ownedMenuElements,
11
+ scopedMenuElement,
12
+ } from "@supermomonga/shadcn-view-components/menu_popover"
13
+
14
+ const MENU_SCOPE_SELECTOR = "[data-slot='menubar-menu']"
15
+ const SUB_SCOPE_SELECTOR = "[data-slot='menubar-sub']"
16
+
17
+ // ARIA menubarパターン。トップレベルの左右移動と、各Menuに属するpopoverを
18
+ // 明示的なペアとして管理する。DropdownMenuの単一menu状態には依存しない。
19
+ export default class MenubarController extends Controller {
20
+ /** @type {HTMLElement[]} */
21
+ topTriggers = []
22
+
23
+ /** @type {Set<HTMLElement>} */
24
+ topPopovers = new Set()
25
+
26
+ /** @type {Map<HTMLElement, HTMLElement>} */
27
+ triggerToPopover = new Map()
28
+
29
+ /** @type {Map<HTMLElement, HTMLElement>} */
30
+ popoverToTrigger = new Map()
31
+
32
+ /** @type {MenuPopoverManager | null} */
33
+ popovers = null
34
+
35
+ /** @type {HTMLElement | null} */
36
+ suspendedTopTrigger = null
37
+
38
+ /** @type {number | null} */
39
+ restoreTopTriggerTimer = null
40
+
41
+ /** @type {(event: MouseEvent) => void} */
42
+ onClick = (event) => {
43
+ if (!(event.target instanceof Element) || !isOwnedMenuElement(this.root, event.target)) return
44
+
45
+ const item = /** @type {HTMLElement | null} */ (event.target.closest("[role^='menuitem']"))
46
+ if (!item || this.topTriggers.includes(item) || this.triggerToPopover.has(item)) return
47
+ const menu = /** @type {HTMLElement | null} */ (item.closest("[role='menu']"))
48
+ if (menu && this.popovers?.isOpen(menu)) this.closeAll()
49
+ }
50
+
51
+ /** @type {(event: KeyboardEvent) => void} */
52
+ onKeydown = (event) => this.navigate(event)
53
+
54
+ /** @type {(event: FocusEvent) => void} */
55
+ onFocusOut = (event) => {
56
+ const next = event.relatedTarget
57
+ if (!(next instanceof Node && this.popovers?.containsOpenPopover(next))) this.closeAll()
58
+ }
59
+
60
+ /** @type {(event: PointerEvent) => void} */
61
+ onDocPointerDown = (event) => {
62
+ if (!this.popovers) return
63
+ this.popovers.beginPointerDown()
64
+ if (!(event.target instanceof Node)) return
65
+
66
+ for (const popover of this.popovers.openPopovers().reverse()) {
67
+ const trigger = this.popoverToTrigger.get(popover)
68
+ if (popover.contains(event.target)) continue
69
+ if (trigger?.contains(event.target)) {
70
+ this.popovers.recordOpenTriggerPointerDown(event, popover, trigger)
71
+ continue
72
+ }
73
+ this.popovers.hide(popover)
74
+ }
75
+ }
76
+
77
+ connect() {
78
+ this.configurePairs()
79
+ this.initializeTopLevelFocus()
80
+ this.popovers = new MenuPopoverManager(this.root, (popover, state) => this.syncState(popover, state))
81
+ this.popovers.connect()
82
+ this.root.addEventListener("click", this.onClick, true)
83
+ this.root.addEventListener("keydown", this.onKeydown, true)
84
+ this.root.addEventListener("focusout", this.onFocusOut)
85
+ document.addEventListener("pointerdown", this.onDocPointerDown)
86
+ }
87
+
88
+ disconnect() {
89
+ this.root.removeEventListener("click", this.onClick, true)
90
+ this.root.removeEventListener("keydown", this.onKeydown, true)
91
+ this.root.removeEventListener("focusout", this.onFocusOut)
92
+ document.removeEventListener("pointerdown", this.onDocPointerDown)
93
+ this.restoreTopLevelTabStop()
94
+ this.popovers?.disconnect()
95
+ this.popovers = null
96
+ this.topTriggers = []
97
+ this.topPopovers.clear()
98
+ this.triggerToPopover.clear()
99
+ this.popoverToTrigger.clear()
100
+ }
101
+
102
+ configurePairs() {
103
+ this.topTriggers = []
104
+ this.topPopovers.clear()
105
+ this.triggerToPopover.clear()
106
+ this.popoverToTrigger.clear()
107
+
108
+ for (const scope of ownedMenuElements(this.root, MENU_SCOPE_SELECTOR)) {
109
+ const trigger = scopedMenuElement(
110
+ this.root,
111
+ scope,
112
+ MENU_SCOPE_SELECTOR,
113
+ "[data-slot='menubar-trigger']",
114
+ )
115
+ const popover = scopedMenuElement(
116
+ this.root,
117
+ scope,
118
+ MENU_SCOPE_SELECTOR,
119
+ "[data-slot='menubar-content'][popover]",
120
+ )
121
+ if (!trigger || !popover) continue
122
+
123
+ this.addPair(trigger, popover, "shadcn-menubar")
124
+ this.topTriggers.push(trigger)
125
+ this.topPopovers.add(popover)
126
+ }
127
+
128
+ for (const scope of ownedMenuElements(this.root, SUB_SCOPE_SELECTOR)) {
129
+ const trigger = scopedMenuElement(
130
+ this.root,
131
+ scope,
132
+ SUB_SCOPE_SELECTOR,
133
+ "[data-slot='menubar-sub-trigger']",
134
+ )
135
+ const popover = scopedMenuElement(
136
+ this.root,
137
+ scope,
138
+ SUB_SCOPE_SELECTOR,
139
+ "[data-slot='menubar-sub-content'][popover]",
140
+ )
141
+ if (trigger && popover) this.addPair(trigger, popover, "shadcn-menubar-submenu")
142
+ }
143
+ }
144
+
145
+ /** @param {HTMLElement} trigger @param {HTMLElement} popover @param {string} prefix */
146
+ addPair(trigger, popover, prefix) {
147
+ associateMenuElements(trigger, popover, prefix)
148
+ this.triggerToPopover.set(trigger, popover)
149
+ this.popoverToTrigger.set(popover, trigger)
150
+ }
151
+
152
+ initializeTopLevelFocus() {
153
+ const triggers = this.enabledTopTriggers()
154
+ const active = triggers.find((trigger) => trigger.tabIndex === 0) ?? triggers[0]
155
+ for (const trigger of this.topTriggers) trigger.tabIndex = trigger === active ? 0 : -1
156
+ }
157
+
158
+ suspendTopLevelTabStop() {
159
+ this.restoreTopLevelTabStop()
160
+ this.suspendedTopTrigger = this.enabledTopTriggers().find((trigger) => trigger.tabIndex === 0) ?? null
161
+ for (const item of ownedMenuElements(this.root, "[role^='menuitem']")) item.tabIndex = -1
162
+ this.restoreTopTriggerTimer = window.setTimeout(() => this.restoreTopLevelTabStop(), 0)
163
+ }
164
+
165
+ restoreTopLevelTabStop() {
166
+ const trigger = this.suspendedTopTrigger
167
+ this.cancelTopLevelTabStopRestoration()
168
+ if (!trigger) return
169
+ if (this.topTriggers.includes(trigger)
170
+ && !trigger.hasAttribute("disabled")
171
+ && trigger.getAttribute("aria-disabled") !== "true") {
172
+ this.focusTopTrigger(trigger)
173
+ } else {
174
+ this.initializeTopLevelFocus()
175
+ }
176
+ }
177
+
178
+ cancelTopLevelTabStopRestoration() {
179
+ if (this.restoreTopTriggerTimer !== null) window.clearTimeout(this.restoreTopTriggerTimer)
180
+ this.restoreTopTriggerTimer = null
181
+ this.suspendedTopTrigger = null
182
+ }
183
+
184
+ /** @param {Event} event */
185
+ toggle(event) {
186
+ if (!(event.currentTarget instanceof HTMLElement)) return
187
+
188
+ const popover = this.triggerToPopover.get(event.currentTarget)
189
+ if (!popover || !this.topPopovers.has(popover) || !this.popovers) return
190
+ this.focusTopTrigger(event.currentTarget)
191
+ if (this.popovers.consumeOpenTriggerPointer(event, event.currentTarget)) {
192
+ this.closeMenu(popover)
193
+ return
194
+ }
195
+ if (this.popovers.isOpen(popover)) this.closeMenu(popover)
196
+ else this.openTopLevel(event.currentTarget, "first")
197
+ }
198
+
199
+ /** @param {Event} event */
200
+ toggleSub(event) {
201
+ if (!(event.currentTarget instanceof HTMLElement)) return
202
+
203
+ const popover = this.triggerToPopover.get(event.currentTarget)
204
+ if (!popover || this.topPopovers.has(popover) || !this.popovers) return
205
+ if (this.popovers.consumeOpenTriggerPointer(event, event.currentTarget)) {
206
+ this.closeMenu(popover)
207
+ return
208
+ }
209
+ if (this.popovers.isOpen(popover)) this.closeMenu(popover)
210
+ else this.openSubmenu(event.currentTarget, "first")
211
+ }
212
+
213
+ activate() {
214
+ this.closeAll()
215
+ }
216
+
217
+ /** @param {KeyboardEvent} event */
218
+ navigate(event) {
219
+ const target = event.target
220
+ if (!(target instanceof Element) || !isOwnedMenuElement(this.root, target)) return
221
+
222
+ if (event.key === "Tab" && (this.popovers?.openPopovers().length ?? 0) > 0) {
223
+ this.suspendTopLevelTabStop()
224
+ this.closeAll()
225
+ return
226
+ }
227
+
228
+ const topTrigger = /** @type {HTMLElement | null} */ (target.closest("[data-slot='menubar-trigger']"))
229
+ if (topTrigger && this.topTriggers.includes(topTrigger)) {
230
+ this.navigateTopLevel(event, topTrigger)
231
+ return
232
+ }
233
+
234
+ const menu = /** @type {HTMLElement | null} */ (target.closest("[role='menu']"))
235
+ if (menu && this.popovers?.isOpen(menu)) this.navigateMenu(event, menu)
236
+ }
237
+
238
+ /** @param {KeyboardEvent} event @param {HTMLElement} trigger */
239
+ navigateTopLevel(event, trigger) {
240
+ const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End", "Escape"]
241
+ if (!keys.includes(event.key)) return
242
+ event.preventDefault()
243
+
244
+ if (event.key === "Escape") {
245
+ this.closeAll()
246
+ trigger.focus()
247
+ return
248
+ }
249
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
250
+ this.openTopLevel(trigger, event.key === "ArrowDown" ? "first" : "last")
251
+ return
252
+ }
253
+
254
+ const next = this.topLevelDestination(trigger, event.key)
255
+ if (!next) return
256
+ const hadOpenMenu = this.openTopLevelPopover() !== null
257
+ this.focusTopTrigger(next)
258
+ next.focus()
259
+ if (hadOpenMenu) this.openTopLevel(next, "first")
260
+ }
261
+
262
+ /** @param {KeyboardEvent} event @param {HTMLElement} menu */
263
+ navigateMenu(event, menu) {
264
+ const items = directMenuItems(this.root, menu)
265
+ const target = event.target
266
+ const active = menu.ownerDocument.activeElement
267
+ const current = items.findIndex((item) => (
268
+ item === target || (target instanceof Node && item.contains(target))
269
+ || item === active || (active instanceof Node && item.contains(active))
270
+ ))
271
+ if (activateMenuItemFromKeyboard(event, items[current])) return
272
+
273
+ const keys = ["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", "Home", "End", "Escape"]
274
+ if (!keys.includes(event.key)) return
275
+ event.preventDefault()
276
+ if (event.key === "Escape") {
277
+ this.escapeMenu(menu)
278
+ return
279
+ }
280
+ if (event.key === "ArrowRight" && current >= 0) {
281
+ const submenu = this.triggerToPopover.get(items[current])
282
+ if (submenu && !this.topPopovers.has(submenu)) {
283
+ this.openSubmenu(items[current], "first")
284
+ return
285
+ }
286
+ }
287
+ if (event.key === "ArrowLeft" && !this.topPopovers.has(menu)) {
288
+ const parentTrigger = this.popoverToTrigger.get(menu)
289
+ this.closeMenu(menu)
290
+ parentTrigger?.focus()
291
+ return
292
+ }
293
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
294
+ const owner = this.topLevelOwner(menu)
295
+ const next = owner ? this.topLevelDestination(owner, event.key) : null
296
+ if (next) {
297
+ this.focusTopTrigger(next)
298
+ this.openTopLevel(next, "first")
299
+ }
300
+ return
301
+ }
302
+ if (items.length === 0) return
303
+
304
+ let nextIndex = current
305
+ if (event.key === "ArrowDown") nextIndex = (current + 1 + items.length) % items.length
306
+ if (event.key === "ArrowUp") nextIndex = (current - 1 + items.length) % items.length
307
+ if (event.key === "Home") nextIndex = 0
308
+ if (event.key === "End") nextIndex = items.length - 1
309
+ this.focusItem(menu, items[Math.max(0, nextIndex)])
310
+ }
311
+
312
+ /** @param {HTMLElement} menu */
313
+ escapeMenu(menu) {
314
+ const trigger = this.popoverToTrigger.get(menu)
315
+ if (this.topPopovers.has(menu)) {
316
+ this.closeAll()
317
+ this.focusTopTrigger(trigger)
318
+ trigger?.focus()
319
+ return
320
+ }
321
+
322
+ this.closeMenu(menu)
323
+ trigger?.focus()
324
+ }
325
+
326
+ /** @param {HTMLElement} trigger @param {"first" | "last"} boundary */
327
+ openTopLevel(trigger, boundary) {
328
+ const popover = this.triggerToPopover.get(trigger)
329
+ if (!popover || !this.topPopovers.has(popover) || !this.popovers) return
330
+
331
+ this.popovers.hideAll([popover])
332
+ this.popovers.show(popover, trigger, { align: "start", alignOffset: -4, side: "bottom", sideOffset: 8 })
333
+ this.focusBoundaryItem(popover, boundary)
334
+ }
335
+
336
+ /** @param {HTMLElement} trigger @param {"first" | "last"} boundary */
337
+ openSubmenu(trigger, boundary) {
338
+ const popover = this.triggerToPopover.get(trigger)
339
+ if (!popover || this.topPopovers.has(popover) || !this.popovers) return
340
+
341
+ this.popovers.show(popover, trigger, { align: "start", alignOffset: -3, side: "right", sideOffset: 0 })
342
+ this.focusBoundaryItem(popover, boundary)
343
+ }
344
+
345
+ /** @param {HTMLElement} menu */
346
+ closeMenu(menu) {
347
+ this.closeDescendants(menu)
348
+ this.popovers?.hide(menu)
349
+ }
350
+
351
+ closeAll() {
352
+ this.popovers?.hideAll()
353
+ }
354
+
355
+ /** @param {HTMLElement} parent */
356
+ closeDescendants(parent) {
357
+ for (const popover of this.popovers?.openPopovers() ?? []) {
358
+ if (popover !== parent && parent.contains(popover)) this.popovers?.hide(popover)
359
+ }
360
+ }
361
+
362
+ /** @param {HTMLElement} popover @param {"open" | "closed"} state */
363
+ syncState(popover, state) {
364
+ applyMenuTriggerState(this.popoverToTrigger.get(popover), state)
365
+ if (state === "closed") this.closeDescendants(popover)
366
+ }
367
+
368
+ /** @param {HTMLElement} trigger @param {string} key */
369
+ topLevelDestination(trigger, key) {
370
+ const triggers = this.enabledTopTriggers()
371
+ const current = triggers.indexOf(trigger)
372
+ if (current < 0 || triggers.length === 0) return null
373
+ if (key === "Home") return triggers[0]
374
+ if (key === "End") return triggers.at(-1) ?? null
375
+
376
+ const rtl = this.root.ownerDocument.defaultView?.getComputedStyle(this.root).direction === "rtl"
377
+ const forward = key === "ArrowRight" ? !rtl : rtl
378
+ return triggers[(current + (forward ? 1 : -1) + triggers.length) % triggers.length]
379
+ }
380
+
381
+ /** @returns {HTMLElement[]} */
382
+ enabledTopTriggers() {
383
+ return this.topTriggers.filter((trigger) => (
384
+ !trigger.hasAttribute("disabled") && trigger.getAttribute("aria-disabled") !== "true"
385
+ ))
386
+ }
387
+
388
+ /** @param {HTMLElement | undefined} trigger */
389
+ focusTopTrigger(trigger) {
390
+ if (!trigger) return
391
+
392
+ this.cancelTopLevelTabStopRestoration()
393
+ for (const candidate of this.topTriggers) candidate.tabIndex = candidate === trigger ? 0 : -1
394
+ }
395
+
396
+ /** @returns {HTMLElement | null} */
397
+ openTopLevelPopover() {
398
+ return [...this.topPopovers].find((popover) => this.popovers?.isOpen(popover)) ?? null
399
+ }
400
+
401
+ /** @param {HTMLElement} menu @returns {HTMLElement | null} */
402
+ topLevelOwner(menu) {
403
+ if (this.topPopovers.has(menu)) return this.popoverToTrigger.get(menu) ?? null
404
+ const popover = [...this.topPopovers].find((candidate) => candidate.contains(menu))
405
+ return popover ? this.popoverToTrigger.get(popover) ?? null : null
406
+ }
407
+
408
+ /** @param {HTMLElement} menu @param {"first" | "last"} boundary */
409
+ focusBoundaryItem(menu, boundary) {
410
+ const items = directMenuItems(this.root, menu)
411
+ this.focusItem(menu, boundary === "first" ? items[0] : items.at(-1))
412
+ }
413
+
414
+ /** @param {HTMLElement} menu @param {HTMLElement | undefined} item */
415
+ focusItem(menu, item) {
416
+ if (!item) return
417
+
418
+ for (const candidate of directMenuItems(this.root, menu)) {
419
+ const highlighted = candidate === item
420
+ candidate.dataset.highlighted = highlighted ? "true" : "false"
421
+ candidate.tabIndex = highlighted ? 0 : -1
422
+ }
423
+ item.focus()
424
+ }
425
+
426
+ /** @returns {HTMLElement} */
427
+ get root() {
428
+ return /** @type {HTMLElement} */ (this.element)
429
+ }
430
+ }
@@ -0,0 +1,37 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // メッセージスクロール領域の追従と「一番下へ」ボタン(Phase 4)
4
+ /** @extends {Controller<HTMLElement>} */
5
+ export default class MessageScrollerController extends Controller {
6
+ /** @type {HTMLElement | null} */
7
+ viewport = null
8
+
9
+ onScroll = () => this.refresh()
10
+
11
+ connect() {
12
+ this.viewport = /** @type {HTMLElement | null} */ (
13
+ this.element.querySelector("[data-slot='message-scroller-viewport']")
14
+ )
15
+ this.viewport?.addEventListener("scroll", this.onScroll, { passive: true })
16
+ this.refresh()
17
+ }
18
+
19
+ disconnect() {
20
+ this.viewport?.removeEventListener("scroll", this.onScroll)
21
+ this.viewport = null
22
+ }
23
+
24
+ scrollToBottom() {
25
+ this.viewport?.scrollTo({ top: this.viewport.scrollHeight, behavior: "smooth" })
26
+ }
27
+
28
+ refresh() {
29
+ if (!this.viewport) return
30
+ const button = /** @type {HTMLButtonElement | null} */ (
31
+ this.element.querySelector("[data-slot='message-scroller-button']")
32
+ )
33
+ if (!button) return
34
+ const distance = this.viewport.scrollHeight - this.viewport.scrollTop - this.viewport.clientHeight
35
+ button.hidden = distance < 24
36
+ }
37
+ }