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,254 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import {
4
+ applyMenuTriggerState,
5
+ associateMenuElements,
6
+ isOwnedMenuElement,
7
+ MenuPopoverManager,
8
+ ownedMenuElements,
9
+ scopedMenuElement,
10
+ } from "@supermomonga/shadcn-view-components/menu_popover"
11
+
12
+ const ITEM_SCOPE_SELECTOR = "[data-slot='navigation-menu-item']"
13
+ const CONTENT_SELECTOR = "[data-slot='navigation-menu-content'][popover]"
14
+
15
+ // NavigationMenuはARIA menuではなく、通常のnav/linkと開閉領域を組み合わせる。
16
+ // LinkのTab/Enter操作を保ちつつ、トップレベル移動とTriggerごとのContentを管理する。
17
+ export default class NavigationMenuController extends Controller {
18
+ /** @type {HTMLElement[]} */
19
+ topControls = []
20
+
21
+ /** @type {Map<HTMLElement, HTMLElement>} */
22
+ triggerToPopover = new Map()
23
+
24
+ /** @type {Map<HTMLElement, HTMLElement>} */
25
+ popoverToTrigger = new Map()
26
+
27
+ /** @type {MenuPopoverManager | null} */
28
+ popovers = null
29
+
30
+ /** @type {(event: MouseEvent) => void} */
31
+ onClick = (event) => {
32
+ if (!(event.target instanceof Element) || !isOwnedMenuElement(this.root, event.target)) return
33
+
34
+ const link = event.target.closest("[data-slot='navigation-menu-link']")
35
+ if (link) this.closeAll()
36
+ }
37
+
38
+ /** @type {(event: KeyboardEvent) => void} */
39
+ onKeydown = (event) => this.navigate(event)
40
+
41
+ /** @type {(event: FocusEvent) => void} */
42
+ onFocusOut = (event) => {
43
+ const next = event.relatedTarget
44
+ if (next instanceof Node && this.popovers?.containsOpenPopover(next)) return
45
+ this.closeAll()
46
+ }
47
+
48
+ /** @type {(event: PointerEvent) => void} */
49
+ onDocPointerDown = (event) => {
50
+ if (!this.popovers) return
51
+ this.popovers.beginPointerDown()
52
+ if (!(event.target instanceof Node)) return
53
+
54
+ for (const popover of this.popovers.openPopovers().reverse()) {
55
+ const trigger = this.popoverToTrigger.get(popover)
56
+ if (popover.contains(event.target)) continue
57
+ if (trigger?.contains(event.target)) {
58
+ this.popovers.recordOpenTriggerPointerDown(event, popover, trigger)
59
+ continue
60
+ }
61
+ this.popovers.hide(popover)
62
+ }
63
+ }
64
+
65
+ connect() {
66
+ this.configurePairs()
67
+ this.popovers = new MenuPopoverManager(this.root, (popover, state) => this.syncState(popover, state))
68
+ this.popovers.connect()
69
+ this.root.addEventListener("click", this.onClick, true)
70
+ this.root.addEventListener("keydown", this.onKeydown, true)
71
+ this.root.addEventListener("focusout", this.onFocusOut)
72
+ document.addEventListener("pointerdown", this.onDocPointerDown)
73
+ }
74
+
75
+ disconnect() {
76
+ this.root.removeEventListener("click", this.onClick, true)
77
+ this.root.removeEventListener("keydown", this.onKeydown, true)
78
+ this.root.removeEventListener("focusout", this.onFocusOut)
79
+ document.removeEventListener("pointerdown", this.onDocPointerDown)
80
+ this.popovers?.disconnect()
81
+ this.popovers = null
82
+ this.topControls = []
83
+ this.triggerToPopover.clear()
84
+ this.popoverToTrigger.clear()
85
+ }
86
+
87
+ configurePairs() {
88
+ this.topControls = []
89
+ this.triggerToPopover.clear()
90
+ this.popoverToTrigger.clear()
91
+
92
+ for (const scope of ownedMenuElements(this.root, ITEM_SCOPE_SELECTOR)) {
93
+ const trigger = scopedMenuElement(
94
+ this.root,
95
+ scope,
96
+ ITEM_SCOPE_SELECTOR,
97
+ "[data-slot='navigation-menu-trigger']",
98
+ )
99
+ const popover = scopedMenuElement(this.root, scope, ITEM_SCOPE_SELECTOR, CONTENT_SELECTOR)
100
+ if (trigger && popover) {
101
+ associateMenuElements(trigger, popover, "shadcn-navigation-menu", { labelContent: false })
102
+ this.triggerToPopover.set(trigger, popover)
103
+ this.popoverToTrigger.set(popover, trigger)
104
+ this.topControls.push(trigger)
105
+ continue
106
+ }
107
+
108
+ const link = scopedMenuElement(
109
+ this.root,
110
+ scope,
111
+ ITEM_SCOPE_SELECTOR,
112
+ "[data-slot='navigation-menu-link']",
113
+ )
114
+ if (link && !link.closest(CONTENT_SELECTOR)) this.topControls.push(link)
115
+ }
116
+ }
117
+
118
+ /** @param {Event} event */
119
+ toggle(event) {
120
+ if (!(event.currentTarget instanceof HTMLElement)) return
121
+
122
+ const popover = this.triggerToPopover.get(event.currentTarget)
123
+ if (!popover || !this.popovers) return
124
+ if (this.popovers.consumeOpenTriggerPointer(event, event.currentTarget)) {
125
+ this.popovers.hide(popover)
126
+ return
127
+ }
128
+ if (this.popovers.isOpen(popover)) this.popovers.hide(popover)
129
+ else this.open(event.currentTarget, false, "first")
130
+ }
131
+
132
+ /** @param {KeyboardEvent} event */
133
+ navigate(event) {
134
+ const target = event.target
135
+ if (!(target instanceof Element) || !isOwnedMenuElement(this.root, target)) return
136
+
137
+ const content = /** @type {HTMLElement | null} */ (target.closest(CONTENT_SELECTOR))
138
+ if (content && this.popovers?.isOpen(content)) {
139
+ this.navigateContent(event, content)
140
+ return
141
+ }
142
+
143
+ const control = /** @type {HTMLElement | null} */ (
144
+ target.closest("[data-slot='navigation-menu-trigger'], [data-slot='navigation-menu-link']")
145
+ )
146
+ if (control && this.topControls.includes(control)) this.navigateTopLevel(event, control)
147
+ }
148
+
149
+ /** @param {KeyboardEvent} event @param {HTMLElement} control */
150
+ navigateTopLevel(event, control) {
151
+ const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End", "Escape"]
152
+ if (!keys.includes(event.key)) return
153
+ event.preventDefault()
154
+
155
+ const popover = this.triggerToPopover.get(control)
156
+ if (event.key === "Escape") {
157
+ if (popover) this.popovers?.hide(popover)
158
+ control.focus()
159
+ return
160
+ }
161
+ if ((event.key === "ArrowDown" || event.key === "ArrowUp") && popover) {
162
+ this.open(control, true, event.key === "ArrowDown" ? "first" : "last")
163
+ return
164
+ }
165
+
166
+ const next = this.topLevelDestination(control, event.key)
167
+ if (!next) return
168
+ const hadOpenPopover = this.popovers?.openPopovers().length !== 0
169
+ next.focus()
170
+ if (!hadOpenPopover) return
171
+
172
+ if (this.triggerToPopover.has(next)) this.open(next, false, "first")
173
+ else this.closeAll()
174
+ }
175
+
176
+ /** @param {KeyboardEvent} event @param {HTMLElement} content */
177
+ navigateContent(event, content) {
178
+ const keys = ["ArrowDown", "ArrowUp", "Home", "End", "Escape"]
179
+ if (!keys.includes(event.key)) return
180
+ event.preventDefault()
181
+
182
+ const trigger = this.popoverToTrigger.get(content)
183
+ if (event.key === "Escape") {
184
+ this.popovers?.hide(content)
185
+ trigger?.focus()
186
+ return
187
+ }
188
+
189
+ const links = this.contentControls(content)
190
+ if (links.length === 0) return
191
+ const target = event.target
192
+ const current = links.findIndex((link) => link === target || (target instanceof Node && link.contains(target)))
193
+ let next = current
194
+ if (event.key === "ArrowDown") next = (current + 1 + links.length) % links.length
195
+ if (event.key === "ArrowUp") next = (current - 1 + links.length) % links.length
196
+ if (event.key === "Home") next = 0
197
+ if (event.key === "End") next = links.length - 1
198
+ links[Math.max(0, next)]?.focus()
199
+ }
200
+
201
+ /** @param {HTMLElement} trigger @param {boolean} moveFocus @param {"first" | "last"} boundary */
202
+ open(trigger, moveFocus, boundary) {
203
+ const popover = this.triggerToPopover.get(trigger)
204
+ if (!popover || !this.popovers) return
205
+
206
+ this.popovers.hideAll([popover])
207
+ this.popovers.show(popover, trigger, { align: "start", side: "bottom", sideOffset: 8 })
208
+ if (!moveFocus) return
209
+
210
+ const controls = this.contentControls(popover)
211
+ const destination = boundary === "first" ? controls[0] : controls.at(-1)
212
+ destination?.focus()
213
+ }
214
+
215
+ closeAll() {
216
+ this.popovers?.hideAll()
217
+ }
218
+
219
+ /** @param {HTMLElement} popover @param {"open" | "closed"} state */
220
+ syncState(popover, state) {
221
+ applyMenuTriggerState(this.popoverToTrigger.get(popover), state)
222
+ }
223
+
224
+ /** @param {HTMLElement} content @returns {HTMLElement[]} */
225
+ contentControls(content) {
226
+ return ownedMenuElements(
227
+ this.root,
228
+ `${CONTENT_SELECTOR} a[href], ${CONTENT_SELECTOR} button:not([disabled]), ${CONTENT_SELECTOR} [tabindex]`,
229
+ )
230
+ .filter((element) => element.closest(CONTENT_SELECTOR) === content)
231
+ .filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-disabled") !== "true")
232
+ }
233
+
234
+ /** @param {HTMLElement} control @param {string} key @returns {HTMLElement | null} */
235
+ topLevelDestination(control, key) {
236
+ const controls = this.topControls.filter((candidate) => (
237
+ !candidate.hasAttribute("disabled") && candidate.getAttribute("aria-disabled") !== "true"
238
+ ))
239
+ const current = controls.indexOf(control)
240
+ if (current < 0 || controls.length === 0) return null
241
+ if (key === "Home") return controls[0]
242
+ if (key === "End") return controls.at(-1) ?? null
243
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return null
244
+
245
+ const rtl = this.root.ownerDocument.defaultView?.getComputedStyle(this.root).direction === "rtl"
246
+ const forward = key === "ArrowRight" ? !rtl : rtl
247
+ return controls[(current + (forward ? 1 : -1) + controls.length) % controls.length]
248
+ }
249
+
250
+ /** @returns {HTMLElement} */
251
+ get root() {
252
+ return /** @type {HTMLElement} */ (this.element)
253
+ }
254
+ }
@@ -0,0 +1,117 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import { startFloatingPosition } from "@supermomonga/shadcn-view-components/floating_position"
4
+ import { hideAfterExit } from "@supermomonga/shadcn-view-components/hide_after_exit"
5
+ import { applyStateAttrs } from "@supermomonga/shadcn-view-components/state_attrs"
6
+
7
+ const noop = () => {}
8
+
9
+ // Popover API(popover="auto")の開閉同期と位置合わせ。
10
+ // 軽い外側クリック解散(light dismiss)はネイティブが担い、ここでは
11
+ // data-state / aria-expanded の同期と、trigger 直下への位置合わせを行う。
12
+ // trigger からの明示的な閉じ操作では退出アニメーションを待つ
13
+ // (ネイティブの light dismiss は即時解散のためアニメーション無し)
14
+ export default class PopoverController extends Controller {
15
+ /** @type {HTMLElement | null} */
16
+ content = null
17
+
18
+ /** @type {HTMLElement | null} */
19
+ trigger = null
20
+
21
+ /** @type {() => void} */
22
+ cancelExit = noop
23
+
24
+ /** @type {{ update: () => void, destroy: () => void } | null} */
25
+ positioning = null
26
+
27
+ /** @type {() => void} */
28
+ onToggle = () => this.syncState()
29
+
30
+ connect() {
31
+ this.cancelPendingExit()
32
+ this.stopPositioning()
33
+ this.content = this.element.querySelector("[popover]")
34
+ this.trigger = this.element.querySelector("[data-slot='popover-trigger']")
35
+ this.content?.addEventListener("toggle", this.onToggle)
36
+ this.syncState()
37
+ }
38
+
39
+ disconnect() {
40
+ const content = this.content
41
+ const finishExit = content?.dataset.state === "closed" && content.matches(":popover-open")
42
+ this.cancelPendingExit()
43
+ this.stopPositioning()
44
+ content?.removeEventListener("toggle", this.onToggle)
45
+ if (finishExit) content.hidePopover()
46
+ this.content = null
47
+ this.trigger = null
48
+ }
49
+
50
+ toggle() {
51
+ const content = this.content
52
+ if (!content) return
53
+
54
+ const exiting = content.dataset.state === "closed" && content.matches(":popover-open")
55
+ if (content.matches(":popover-open") && !exiting) {
56
+ this.cancelPendingExit()
57
+ this.applyState("closed")
58
+ this.cancelExit = hideAfterExit(content, () => {
59
+ if (this.content !== content || content.dataset.state === "open") return
60
+ this.stopPositioning()
61
+ content.hidePopover()
62
+ })
63
+ return
64
+ }
65
+
66
+ this.cancelPendingExit()
67
+ this.applyState("open")
68
+ if (!content.matches(":popover-open")) content.showPopover()
69
+ this.startPositioning()
70
+ }
71
+
72
+ syncState() {
73
+ const content = this.content
74
+ if (!content) return
75
+
76
+ const state = content.matches(":popover-open") ? "open" : "closed"
77
+ if (state === "open") this.cancelPendingExit()
78
+ this.applyState(state)
79
+ if (state === "open") this.startPositioning()
80
+ else this.stopPositioning()
81
+ }
82
+
83
+ /** @param {"open" | "closed"} state */
84
+ applyState(state) {
85
+ if (!this.content) return
86
+
87
+ this.content.dataset.state = state
88
+ applyStateAttrs(this.content, state)
89
+ this.trigger?.setAttribute("aria-expanded", String(state === "open"))
90
+ }
91
+
92
+ startPositioning() {
93
+ const content = this.content
94
+ const trigger = this.trigger
95
+ if (!content || !trigger) return
96
+
97
+ this.stopPositioning()
98
+ this.positioning = startFloatingPosition({
99
+ align: "center",
100
+ anchor: trigger,
101
+ collisionPadding: 5,
102
+ floating: content,
103
+ side: "bottom",
104
+ sideOffset: 4,
105
+ })
106
+ }
107
+
108
+ stopPositioning() {
109
+ this.positioning?.destroy()
110
+ this.positioning = null
111
+ }
112
+
113
+ cancelPendingExit() {
114
+ this.cancelExit()
115
+ this.cancelExit = noop
116
+ }
117
+ }
@@ -0,0 +1,256 @@
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
+
10
+ const CONTROLLER = "shadcn--resizable"
11
+ const DEFAULT_MIN = 10
12
+ const DEFAULT_MAX = 90
13
+ const STEP = 5
14
+
15
+ /**
16
+ * @typedef {object} DragState
17
+ * @property {HTMLElement} before
18
+ * @property {HTMLElement} after
19
+ * @property {HTMLElement} handle
20
+ * @property {boolean} vertical
21
+ * @property {number} ratio
22
+ * @property {number} start
23
+ */
24
+
25
+ // ハンドルのドラッグ/矢印キーで前後パネルの flex-basis を配分する
26
+ /** @extends {Controller<HTMLElement>} */
27
+ export default class ResizableController extends Controller {
28
+ /** @type {DragState | null} */
29
+ dragging = null
30
+
31
+ /** @type {(event: MouseEvent) => void} */
32
+ onMove = (event) => this.drag(event)
33
+
34
+ onUp = () => this.endDrag()
35
+
36
+ connect() {
37
+ this.ensureRelationships()
38
+ for (const handle of this.handles) this.syncValue(handle)
39
+ }
40
+
41
+ disconnect() {
42
+ this.endDrag()
43
+ }
44
+
45
+ /**
46
+ * @param {Event} event
47
+ * @returns {HTMLElement | null}
48
+ */
49
+ handleFor(event) {
50
+ if (!(event.currentTarget instanceof Element)) return null
51
+ const handle = /** @type {HTMLElement | null} */ (
52
+ event.currentTarget.closest("[data-slot='resizable-handle']")
53
+ )
54
+ if (!handle || handle.closest(`[data-controller~='${CONTROLLER}']`) !== this.element) return null
55
+
56
+ this.ensureHandleRelationship(handle)
57
+ return handle
58
+ }
59
+
60
+ // セパレータ自身が horizontal のとき縦積みグループ(上下方向にリサイズ)。
61
+ // aria-orientation はセパレータの向きであり、グループの向きの逆
62
+ /** @param {HTMLElement} handle */
63
+ isVerticalGroup(handle) {
64
+ return handle.getAttribute("aria-orientation") === "horizontal"
65
+ }
66
+
67
+ // 前後の対はハンドルより手前に何個パネルがあるかで決める
68
+ // (子要素全体の index だとハンドル数が挟まる3パネル以上で対がずれる)
69
+ /**
70
+ * @param {HTMLElement | null} handle
71
+ * @returns {{ before: HTMLElement, after: HTMLElement } | null}
72
+ */
73
+ pairFor(handle) {
74
+ if (!handle) return null
75
+ const group = handle.closest("[data-slot='resizable-panel-group']")
76
+ if (group !== this.element) return null
77
+ const panels = this.panels
78
+ let count = 0
79
+ for (let el = handle.previousElementSibling; el; el = el.previousElementSibling) {
80
+ if (el.matches("[data-slot='resizable-panel']")) count++
81
+ }
82
+ const before = panels[count - 1]
83
+ const after = panels[count]
84
+ return before && after ? { before, after } : null
85
+ }
86
+
87
+ /** @param {MouseEvent} event */
88
+ startDrag(event) {
89
+ const handle = this.handleFor(event)
90
+ if (!handle) return
91
+ const pair = this.pairFor(handle)
92
+ if (!pair) return
93
+ event.preventDefault()
94
+ // preventDefaultするとフォーカス移動が阻害されるため明示的に移す(キーボード操作のため)
95
+ handle.focus()
96
+
97
+ // 同じcontrollerでdragを開始し直した場合もlistenerを重複させない。
98
+ this.endDrag()
99
+
100
+ // 開始時の比率を基準に以降は移動量だけで追従する
101
+ // (カーソルの絶対位置をそのまま比率にすると、掴んだ瞬間パネルが跳ぶ)
102
+ const vertical = this.isVerticalGroup(handle)
103
+ const beforeRect = pair.before.getBoundingClientRect()
104
+ const afterRect = pair.after.getBoundingClientRect()
105
+ const beforeSize = vertical ? beforeRect.height : beforeRect.width
106
+ const total = beforeSize + (vertical ? afterRect.height : afterRect.width) || 1
107
+ this.dragging = {
108
+ ...pair,
109
+ handle,
110
+ vertical,
111
+ ratio: beforeSize / total,
112
+ start: vertical ? event.clientY : event.clientX,
113
+ }
114
+ document.addEventListener("mousemove", this.onMove)
115
+ document.addEventListener("mouseup", this.onUp)
116
+ }
117
+
118
+ endDrag() {
119
+ document.removeEventListener("mousemove", this.onMove)
120
+ document.removeEventListener("mouseup", this.onUp)
121
+ this.dragging = null
122
+ }
123
+
124
+ /** @param {MouseEvent} event */
125
+ drag(event) {
126
+ if (!this.dragging) return
127
+ const { before, after, handle, vertical, ratio, start } = this.dragging
128
+ const group = /** @type {HTMLElement | null} */ (
129
+ before.closest("[data-slot='resizable-panel-group']")
130
+ )
131
+ if (!group) return
132
+ const rect = group.getBoundingClientRect()
133
+ const size = vertical ? rect.height : rect.width
134
+ if (size === 0) return
135
+ const delta = ((vertical ? event.clientY : event.clientX) - start) / size
136
+ this.applySplit(handle, before, after, this.clampRatio(handle, ratio + delta))
137
+ }
138
+
139
+ /** @param {KeyboardEvent} event */
140
+ nudge(event) {
141
+ const handle = this.handleFor(event)
142
+ const pair = handle && this.pairFor(handle)
143
+ if (!pair) return
144
+ const vertical = this.isVerticalGroup(handle)
145
+ const keys = vertical ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"]
146
+ if (![...keys, "Home", "End"].includes(event.key)) return
147
+ event.preventDefault()
148
+
149
+ const { min, max } = this.limitsFor(handle)
150
+ let next
151
+ if (event.key === "Home") next = min / 100
152
+ else if (event.key === "End") next = max / 100
153
+ else {
154
+ const positive = vertical ? event.key === "ArrowDown" : event.key === "ArrowRight"
155
+ next = this.currentRatio(pair.before, pair.after, handle) + (positive ? STEP : -STEP) / 100
156
+ }
157
+ this.applySplit(handle, pair.before, pair.after, this.clampRatio(handle, next))
158
+ }
159
+
160
+ // %指定済みならそれを、初期レイアウトでは実寸を読み取る。jsdom等で寸法を
161
+ // 得られない場合だけSSR済みaria-valuenow(既定50)へ戻る。
162
+ /** @param {HTMLElement} before @param {HTMLElement} after @param {HTMLElement} handle */
163
+ currentRatio(before, after, handle) {
164
+ const basis = before.style.flexBasis
165
+ if (basis.endsWith("%")) {
166
+ const percent = parseFloat(basis)
167
+ if (Number.isFinite(percent) && percent > 0) return percent / 100
168
+ }
169
+
170
+ const beforeRect = before.getBoundingClientRect()
171
+ const afterRect = after.getBoundingClientRect()
172
+ const vertical = this.isVerticalGroup(handle)
173
+ const beforeSize = vertical ? beforeRect.height : beforeRect.width
174
+ const afterSize = vertical ? afterRect.height : afterRect.width
175
+ if (beforeSize + afterSize > 0) return beforeSize / (beforeSize + afterSize)
176
+
177
+ const value = Number.parseFloat(handle.getAttribute("aria-valuenow") ?? "")
178
+ return Number.isFinite(value) ? value / 100 : 0.5
179
+ }
180
+
181
+ /**
182
+ * @param {HTMLElement} handle
183
+ * @param {HTMLElement} before
184
+ * @param {HTMLElement} after
185
+ * @param {number} ratio
186
+ */
187
+ applySplit(handle, before, after, ratio) {
188
+ before.style.flexBasis = `${ratio * 100}%`
189
+ before.style.flexGrow = "0"
190
+ after.style.flexBasis = `${(1 - ratio) * 100}%`
191
+ after.style.flexGrow = "0"
192
+ handle.setAttribute("aria-valuenow", this.percentValue(ratio))
193
+ }
194
+
195
+ ensureRelationships() {
196
+ const rootId = ensureRootId(this.element, "resizable")
197
+ this.panels.forEach((panel, index) => ensureId(panel, `${rootId}-panel-${index + 1}`))
198
+ this.handles.forEach((handle, index) => {
199
+ ensureId(handle, `${rootId}-handle-${index + 1}`)
200
+ this.ensureHandleRelationship(handle)
201
+ })
202
+ }
203
+
204
+ /** @param {HTMLElement} handle */
205
+ ensureHandleRelationship(handle) {
206
+ const pair = this.pairFor(handle)
207
+ if (!pair) return
208
+
209
+ const rootId = ensureRootId(this.element, "resizable")
210
+ const panelIndex = this.panels.indexOf(pair.before)
211
+ ensureId(pair.before, `${rootId}-panel-${panelIndex + 1}`)
212
+ setDefaultAttribute(handle, "aria-controls", pair.before.id)
213
+ setDefaultAttribute(handle, "aria-valuemin", String(DEFAULT_MIN))
214
+ setDefaultAttribute(handle, "aria-valuemax", String(DEFAULT_MAX))
215
+ setDefaultAttribute(handle, "aria-valuenow", "50")
216
+ }
217
+
218
+ /** @param {HTMLElement} handle */
219
+ syncValue(handle) {
220
+ const pair = this.pairFor(handle)
221
+ if (!pair) return
222
+
223
+ const ratio = this.currentRatio(pair.before, pair.after, handle)
224
+ handle.setAttribute("aria-valuenow", this.percentValue(ratio))
225
+ }
226
+
227
+ /** @param {HTMLElement} handle */
228
+ limitsFor(handle) {
229
+ const rawMin = Number.parseFloat(handle.getAttribute("aria-valuemin") ?? "")
230
+ const rawMax = Number.parseFloat(handle.getAttribute("aria-valuemax") ?? "")
231
+ const min = Number.isFinite(rawMin) ? rawMin : DEFAULT_MIN
232
+ const max = Number.isFinite(rawMax) && rawMax > min ? rawMax : DEFAULT_MAX
233
+ return { min, max }
234
+ }
235
+
236
+ /** @param {HTMLElement} handle @param {number} ratio */
237
+ clampRatio(handle, ratio) {
238
+ const { min, max } = this.limitsFor(handle)
239
+ return Math.min(max / 100, Math.max(min / 100, ratio))
240
+ }
241
+
242
+ /** @param {number} ratio */
243
+ percentValue(ratio) {
244
+ return String(Math.round(ratio * 10000) / 100)
245
+ }
246
+
247
+ /** @returns {HTMLElement[]} */
248
+ get panels() {
249
+ return ownedElements(this.element, ":scope > [data-slot='resizable-panel']", CONTROLLER)
250
+ }
251
+
252
+ /** @returns {HTMLElement[]} */
253
+ get handles() {
254
+ return ownedElements(this.element, ":scope > [data-slot='resizable-handle']", CONTROLLER)
255
+ }
256
+ }