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,491 @@
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 { startFloatingPosition } from "@supermomonga/shadcn-view-components/floating_position"
10
+ import { hideAfterExit } from "@supermomonga/shadcn-view-components/hide_after_exit"
11
+ import { applyStateAttrs } from "@supermomonga/shadcn-view-components/state_attrs"
12
+
13
+ const noop = () => {}
14
+ const IDENTIFIER = "shadcn--select"
15
+
16
+ // 単一選択のcombobox/listbox。確定値はhidden inputだけに保持し、
17
+ // 表示・ARIA・選択印は接続時と選択時にそこから導出する。
18
+ /** @extends {Controller<HTMLElement>} */
19
+ export default class SelectController extends Controller {
20
+ /** @type {HTMLElement | null} */
21
+ content = null
22
+
23
+ /** @type {HTMLElement | null} */
24
+ listbox = null
25
+
26
+ /** @type {HTMLButtonElement | null} */
27
+ trigger = null
28
+
29
+ /** @type {HTMLInputElement | null} */
30
+ input = null
31
+
32
+ /** @type {HTMLElement | null} */
33
+ valueElement = null
34
+
35
+ /** @type {HTMLElement | null} */
36
+ scrollUpButton = null
37
+
38
+ /** @type {HTMLElement | null} */
39
+ scrollDownButton = null
40
+
41
+ /** @type {number | null} */
42
+ scrollTimer = null
43
+
44
+ /** @type {"up" | "down" | null} */
45
+ scrollDirection = null
46
+
47
+ /** @type {() => void} */
48
+ cancelExit = noop
49
+
50
+ /** @type {boolean} */
51
+ isExiting = false
52
+
53
+ /** @type {{ update: () => void, destroy: () => void } | null} */
54
+ positioning = null
55
+
56
+ /** @type {() => void} */
57
+ onToggle = () => this.syncOpenState()
58
+
59
+ /** @type {() => void} */
60
+ onScroll = () => this.syncScrollButtons()
61
+
62
+ connect() {
63
+ this.cancelPendingExit()
64
+ this.stopPositioning()
65
+ this.content = this.owned("[data-slot='select-content']")[0] ?? null
66
+ this.listbox = this.owned("[data-slot='select-content'] [role='listbox']")[0] ?? null
67
+ this.trigger = /** @type {HTMLButtonElement | null} */ (
68
+ this.owned("[data-slot='select-trigger']")[0] ?? null
69
+ )
70
+ this.input = /** @type {HTMLInputElement | null} */ (
71
+ this.owned("input[data-slot='select-input']")[0] ?? null
72
+ )
73
+ this.valueElement = this.owned("[data-slot='select-value']")[0] ?? null
74
+ this.scrollUpButton = this.owned(
75
+ "[data-slot='select-content'] [data-slot='select-scroll-up-button']",
76
+ )[0] ?? null
77
+ this.scrollDownButton = this.owned(
78
+ "[data-slot='select-content'] [data-slot='select-scroll-down-button']",
79
+ )[0] ?? null
80
+ if (this.valueElement && this.valueElement.dataset.placeholderLabel === undefined) {
81
+ this.valueElement.dataset.placeholderLabel = this.valueElement.textContent?.trim() ?? ""
82
+ }
83
+ if (this.root.dataset.disabled === "true" && this.trigger) this.trigger.disabled = true
84
+
85
+ this.ensureRelationships()
86
+ this.content?.addEventListener("toggle", this.onToggle)
87
+ this.listbox?.addEventListener("scroll", this.onScroll)
88
+ this.syncSelection()
89
+ this.syncOpenState()
90
+ }
91
+
92
+ disconnect() {
93
+ const content = this.content
94
+ this.cancelPendingExit()
95
+ this.stopScroll()
96
+ this.stopPositioning()
97
+ content?.removeEventListener("toggle", this.onToggle)
98
+ this.listbox?.removeEventListener("scroll", this.onScroll)
99
+ if (content?.matches(":popover-open")) content.hidePopover()
100
+ if (content) {
101
+ content.dataset.state = "closed"
102
+ applyStateAttrs(content, "closed")
103
+ content.setAttribute("inert", "")
104
+ }
105
+ this.trigger?.setAttribute("aria-expanded", "false")
106
+ this.clearActiveItem()
107
+ this.content = null
108
+ this.listbox = null
109
+ this.trigger = null
110
+ this.input = null
111
+ this.valueElement = null
112
+ this.scrollUpButton = null
113
+ this.scrollDownButton = null
114
+ }
115
+
116
+ /** @param {Event} event */
117
+ toggle(event) {
118
+ event.preventDefault()
119
+ if (this.disabled || !this.content) return
120
+
121
+ const exiting = this.content.dataset.state === "closed" && this.content.matches(":popover-open")
122
+ if (this.content.matches(":popover-open") && !exiting) this.close()
123
+ else this.open()
124
+ }
125
+
126
+ /** @param {KeyboardEvent} event */
127
+ navigate(event) {
128
+ if (!(event.target instanceof Element) ||
129
+ event.target.closest(`[data-controller~='${IDENTIFIER}']`) !== this.element) return
130
+
131
+ const key = event.key === "Spacebar" ? " " : event.key
132
+ const keys = ["ArrowDown", "ArrowUp", "Home", "End", "Enter", " ", "Escape", "Tab"]
133
+ if (!keys.includes(key) || this.disabled) return
134
+
135
+ const physicallyOpen = this.content?.matches(":popover-open") ?? false
136
+ const open = physicallyOpen && this.content?.dataset.state !== "closed"
137
+ if (key === "Tab") {
138
+ if (open) this.close({ restoreFocus: false })
139
+ return
140
+ }
141
+ if (!open) {
142
+ if (key === "Escape") return
143
+ event.preventDefault()
144
+ if (key === "Home") this.open(this.enabledItems[0])
145
+ else if (key === "End") this.open(this.enabledItems.at(-1))
146
+ else if (key === "ArrowUp") this.open(this.selectedEnabledItem || this.enabledItems.at(-1))
147
+ else this.open(this.selectedEnabledItem || this.enabledItems[0])
148
+ return
149
+ }
150
+
151
+ event.preventDefault()
152
+ if (key === "Escape") {
153
+ this.close()
154
+ return
155
+ }
156
+ if (key === "Enter" || key === " ") {
157
+ if (this.activeItem) this.commit(this.activeItem)
158
+ return
159
+ }
160
+
161
+ const items = this.enabledItems
162
+ if (items.length === 0) return
163
+ const activeItem = this.activeItem
164
+ const current = activeItem ? items.indexOf(activeItem) : -1
165
+ let next = current
166
+ if (key === "ArrowDown") next = (current + 1 + items.length) % items.length
167
+ if (key === "ArrowUp") next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length
168
+ if (key === "Home") next = 0
169
+ if (key === "End") next = items.length - 1
170
+ this.setActiveItem(items[Math.max(0, next)])
171
+ }
172
+
173
+ /** @param {Event} event */
174
+ select(event) {
175
+ const item = event.currentTarget
176
+ if (!(item instanceof HTMLElement)) return
177
+
178
+ this.commit(item)
179
+ }
180
+
181
+ /** @param {Event} event */
182
+ highlight(event) {
183
+ const item = event.currentTarget
184
+ if (!(item instanceof HTMLElement) || !this.content?.matches(":popover-open") || this.itemDisabled(item)) return
185
+
186
+ this.setActiveItem(item)
187
+ }
188
+
189
+ /** @param {FocusEvent} event */
190
+ focusOut(event) {
191
+ const next = event.relatedTarget
192
+ if (next instanceof Node && this.root.contains(next)) return
193
+ if (this.content?.dataset.state === "open") this.close({ restoreFocus: false })
194
+ }
195
+
196
+ /** @param {MouseEvent} event */
197
+ startScroll(event) {
198
+ const button = event.currentTarget
199
+ if (!(button instanceof HTMLElement) || button.hidden || this.scrollTimer !== null) return
200
+
201
+ const direction = button.dataset.scrollDirection
202
+ if (direction !== "up" && direction !== "down") return
203
+
204
+ this.scrollDirection = direction
205
+ this.scrollTimer = window.setInterval(() => this.scrollOnce(), 40)
206
+ this.scrollOnce()
207
+ }
208
+
209
+ stopScroll() {
210
+ if (this.scrollTimer !== null) window.clearInterval(this.scrollTimer)
211
+ this.scrollTimer = null
212
+ this.scrollDirection = null
213
+ }
214
+
215
+ /** @param {HTMLElement} [preferredItem] */
216
+ open(preferredItem) {
217
+ const content = this.content
218
+ if (!content || this.disabled) return
219
+
220
+ this.cancelPendingExit()
221
+ this.applyOpenState("open")
222
+ if (!content.matches(":popover-open")) content.showPopover()
223
+ this.trigger?.focus({ preventScroll: true })
224
+ this.startPositioning()
225
+ this.setActiveItem(preferredItem || this.selectedEnabledItem || this.enabledItems[0])
226
+ this.syncScrollButtons()
227
+ }
228
+
229
+ /** @param {{ restoreFocus?: boolean }} [options] */
230
+ close({ restoreFocus = true } = {}) {
231
+ const content = this.content
232
+ if (!content) return
233
+
234
+ this.cancelPendingExit()
235
+ this.stopScroll()
236
+ this.applyOpenState("closed")
237
+ this.clearActiveItem()
238
+ if (restoreFocus) this.trigger?.focus()
239
+ if (!content.matches(":popover-open")) {
240
+ this.isExiting = false
241
+ this.stopPositioning()
242
+ return
243
+ }
244
+
245
+ this.isExiting = true
246
+ this.cancelExit = hideAfterExit(content, () => {
247
+ this.isExiting = false
248
+ if (this.content !== content || content.dataset.state === "open") return
249
+ this.stopPositioning()
250
+ content.hidePopover()
251
+ })
252
+ }
253
+
254
+ syncOpenState() {
255
+ const content = this.content
256
+ if (!content) return
257
+
258
+ const physicallyOpen = content.matches(":popover-open")
259
+ // showPopover() の toggle は別タスクで通知される。開いた直後に閉じた場合、
260
+ // 遅れて届いた open 通知で進行中の退出処理を取り消してはならない。
261
+ if (physicallyOpen && content.dataset.state === "closed" && this.isExiting) return
262
+ // open() は状態・フォーカス・位置決めを同期済み。遅れて届いた同じopen通知で
263
+ // キーボード移動後のactive itemを選択済み項目へ戻してはならない。
264
+ if (physicallyOpen && content.dataset.state === "open") {
265
+ this.syncScrollButtons()
266
+ return
267
+ }
268
+
269
+ const state = physicallyOpen ? "open" : "closed"
270
+ if (state === "open") this.cancelPendingExit()
271
+ this.applyOpenState(state)
272
+ if (state === "open") {
273
+ this.startPositioning()
274
+ this.setActiveItem(this.selectedEnabledItem || this.enabledItems[0])
275
+ this.syncScrollButtons()
276
+ } else {
277
+ this.stopScroll()
278
+ this.stopPositioning()
279
+ this.clearActiveItem()
280
+ this.syncScrollButtons()
281
+ }
282
+ }
283
+
284
+ /** @param {"open" | "closed"} state */
285
+ applyOpenState(state) {
286
+ if (!this.content) return
287
+
288
+ this.content.dataset.state = state
289
+ applyStateAttrs(this.content, state)
290
+ this.content.toggleAttribute("inert", state === "closed")
291
+ this.trigger?.setAttribute("aria-expanded", String(state === "open"))
292
+ }
293
+
294
+ /** @param {HTMLElement} item */
295
+ commit(item) {
296
+ if (this.disabled || this.itemDisabled(item) || !this.input) return
297
+
298
+ const value = item.dataset.value
299
+ if (value === undefined) return
300
+
301
+ const changed = this.input.dataset.valuePresent !== "true" || this.input.value !== value
302
+ this.input.value = value
303
+ this.input.dataset.valuePresent = "true"
304
+ this.syncSelection()
305
+ if (changed) {
306
+ this.input.dispatchEvent(new window.Event("input", { bubbles: true }))
307
+ this.input.dispatchEvent(new window.Event("change", { bubbles: true }))
308
+ }
309
+ this.close()
310
+ }
311
+
312
+ syncSelection() {
313
+ const hasValue = this.input?.dataset.valuePresent === "true"
314
+ const selected = hasValue ? this.items.find((item) => item.dataset.value === this.input?.value) : undefined
315
+ for (const item of this.items) {
316
+ const isSelected = item === selected
317
+ item.dataset.selected = String(isSelected)
318
+ item.setAttribute("aria-selected", String(isSelected))
319
+ const indicator = /** @type {HTMLElement | null} */ (item.querySelector("[data-indicator]"))
320
+ if (indicator) indicator.hidden = !isSelected
321
+ }
322
+
323
+ if (this.valueElement) {
324
+ const placeholder = this.valueElement.dataset.placeholderLabel ?? ""
325
+ this.valueElement.textContent = selected ? this.itemLabel(selected) : placeholder
326
+ this.valueElement.toggleAttribute("data-placeholder", !selected)
327
+ }
328
+ this.trigger?.toggleAttribute("data-placeholder", !selected)
329
+ if (this.trigger) {
330
+ setDefaultAttribute(this.trigger, "aria-label", this.valueElement?.textContent?.trim() || "選択肢")
331
+ }
332
+ }
333
+
334
+ ensureRelationships() {
335
+ if (!this.trigger || !this.listbox) return
336
+
337
+ const baseId = ensureRootId(this.root, "select")
338
+ ensureId(this.trigger, `${baseId}-trigger`)
339
+ ensureId(this.listbox, `${baseId}-listbox`)
340
+ setDefaultAttribute(this.trigger, "aria-controls", this.listbox.id)
341
+ setDefaultAttribute(this.listbox, "aria-labelledby", this.trigger.id)
342
+ this.items.forEach((item, index) => {
343
+ ensureId(item, `${baseId}-option-${index + 1}`)
344
+ })
345
+ this.owned("[data-slot='select-group']").forEach((group, index) => {
346
+ const label = this.owned("[data-slot='select-label']").find((candidate) => group.contains(candidate))
347
+ if (!label) return
348
+ ensureId(label, `${baseId}-group-${index + 1}-label`)
349
+ setDefaultAttribute(group, "aria-labelledby", label.id)
350
+ })
351
+ }
352
+
353
+ /** @param {HTMLElement | undefined} item */
354
+ setActiveItem(item) {
355
+ if (!item || this.itemDisabled(item)) {
356
+ this.clearActiveItem()
357
+ return
358
+ }
359
+
360
+ for (const candidate of this.items) {
361
+ const active = candidate === item
362
+ candidate.dataset.highlighted = String(active)
363
+ candidate.tabIndex = -1
364
+ }
365
+ this.trigger?.setAttribute("aria-activedescendant", item.id)
366
+ item.scrollIntoView?.({ block: "nearest" })
367
+ if (this.listbox) {
368
+ const enabledItems = this.enabledItems
369
+ if (item === enabledItems[0]) this.listbox.scrollTop = 0
370
+ else if (item === enabledItems.at(-1)) {
371
+ this.listbox.scrollTop = Math.max(0, this.listbox.scrollHeight - this.listbox.clientHeight)
372
+ }
373
+ this.syncScrollButtons()
374
+ }
375
+ }
376
+
377
+ clearActiveItem() {
378
+ for (const item of this.items) {
379
+ item.dataset.highlighted = "false"
380
+ item.tabIndex = -1
381
+ }
382
+ this.trigger?.removeAttribute("aria-activedescendant")
383
+ }
384
+
385
+ startPositioning() {
386
+ if (!this.content || !this.trigger) return
387
+
388
+ this.stopPositioning()
389
+ this.positioning = startFloatingPosition({
390
+ align: "center",
391
+ anchor: this.trigger,
392
+ collisionPadding: 5,
393
+ floating: this.content,
394
+ side: "bottom",
395
+ sideOffset: 4,
396
+ })
397
+ }
398
+
399
+ stopPositioning() {
400
+ this.positioning?.destroy()
401
+ this.positioning = null
402
+ }
403
+
404
+ cancelPendingExit() {
405
+ this.cancelExit()
406
+ this.cancelExit = noop
407
+ this.isExiting = false
408
+ }
409
+
410
+ syncScrollButtons() {
411
+ const content = this.content
412
+ const scroller = this.listbox
413
+ const canScroll = Boolean(
414
+ content && content.matches(":popover-open") && scroller && scroller.clientHeight > 0
415
+ )
416
+ const atTop = !scroller || scroller.scrollTop <= 1
417
+ const atBottom = !scroller || scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1
418
+ this.setScrollButtonVisible(this.scrollUpButton, canScroll && !atTop)
419
+ this.setScrollButtonVisible(this.scrollDownButton, canScroll && !atBottom)
420
+
421
+ const activeButton = this.scrollDirection === "up" ? this.scrollUpButton : this.scrollDownButton
422
+ if (activeButton?.hidden) this.stopScroll()
423
+ }
424
+
425
+ /** @param {HTMLElement | null} button @param {boolean} visible */
426
+ setScrollButtonVisible(button, visible) {
427
+ if (!button) return
428
+ button.hidden = !visible
429
+ button.toggleAttribute("data-visible", visible)
430
+ }
431
+
432
+ scrollOnce() {
433
+ const scroller = this.listbox
434
+ const itemHeight = this.enabledItems.find((item) => item.offsetHeight > 0)?.offsetHeight
435
+ if (!scroller || !itemHeight || !this.scrollDirection) {
436
+ this.stopScroll()
437
+ return
438
+ }
439
+
440
+ scroller.scrollTop += (this.scrollDirection === "up" ? -1 : 1) * itemHeight
441
+ this.syncScrollButtons()
442
+ }
443
+
444
+ /** @param {HTMLElement} item */
445
+ itemDisabled(item) {
446
+ return item.matches("[aria-disabled='true'], [data-disabled], [disabled]")
447
+ }
448
+
449
+ /** @param {HTMLElement} item */
450
+ itemLabel(item) {
451
+ const text = item.querySelector("span:not([data-indicator])")?.textContent ?? item.textContent
452
+ return text.trim()
453
+ }
454
+
455
+ /** @returns {HTMLElement[]} */
456
+ get items() {
457
+ return this.listbox ? this.owned("[role='option']") : []
458
+ }
459
+
460
+ /** @returns {HTMLElement[]} */
461
+ get enabledItems() {
462
+ return this.items.filter((item) => !this.itemDisabled(item))
463
+ }
464
+
465
+ /** @returns {HTMLElement | undefined} */
466
+ get selectedEnabledItem() {
467
+ const selected = this.items.find((item) => item.getAttribute("aria-selected") === "true")
468
+ return selected && !this.itemDisabled(selected) ? selected : undefined
469
+ }
470
+
471
+ /** @returns {HTMLElement | undefined} */
472
+ get activeItem() {
473
+ const id = this.trigger?.getAttribute("aria-activedescendant")
474
+ return id ? this.items.find((item) => item.id === id) : undefined
475
+ }
476
+
477
+ /** @returns {boolean} */
478
+ get disabled() {
479
+ return this.root.dataset.disabled === "true" || Boolean(this.trigger?.disabled)
480
+ }
481
+
482
+ /** @returns {HTMLElement} */
483
+ get root() {
484
+ return this.element
485
+ }
486
+
487
+ /** @param {string} selector @returns {HTMLElement[]} */
488
+ owned(selector) {
489
+ return ownedElements(this.root, selector, IDENTIFIER)
490
+ }
491
+ }
@@ -0,0 +1,30 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // サイドバーの開閉(10-roadmap Phase 4)。
4
+ // Provider の data-state(open/closed)を正とし、接続・再接続時に子要素と
5
+ // aria-expandedを冪等に同期する(Turbo cache復帰を含む — 05 §6.2)。
6
+ /** @extends {Controller<HTMLElement>} */
7
+ export default class SidebarController extends Controller {
8
+ connect() {
9
+ this.setState(this.element.dataset.state === "open" ? "open" : "closed")
10
+ }
11
+
12
+ toggle() {
13
+ const state = this.element.dataset.state === "open" ? "closed" : "open"
14
+ this.setState(state)
15
+ }
16
+
17
+ /** @param {"open" | "closed"} state */
18
+ setState(state) {
19
+ this.element.dataset.state = state
20
+ const sidebars = /** @type {NodeListOf<HTMLElement>} */ (
21
+ this.element.querySelectorAll("[data-slot='sidebar']")
22
+ )
23
+ for (const sidebar of sidebars) {
24
+ sidebar.dataset.state = state
25
+ }
26
+ for (const trigger of this.element.querySelectorAll("[data-slot='sidebar-trigger']")) {
27
+ trigger.setAttribute("aria-expanded", String(state === "open"))
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,74 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import { ownedElements } from "@supermomonga/shadcn-view-components/aria_relationships"
4
+
5
+ const CONTROLLER = "shadcn--slider"
6
+ const DEFAULT_MIN = 0
7
+ const DEFAULT_MAX = 100
8
+
9
+ /** @extends {Controller<HTMLElement>} */
10
+ export default class SliderController extends Controller {
11
+ connect() {
12
+ this.sync()
13
+ }
14
+
15
+ // native input[type=range]を唯一の値源にする。キーボード・ポインタ操作は
16
+ // ブラウザへ委譲し、その結果発火するinput/changeから装飾だけを同期する。
17
+ sync() {
18
+ const input = this.input
19
+ if (!input) return
20
+
21
+ const percentage = this.percentage(input)
22
+ const vertical = this.element.dataset.orientation === "vertical"
23
+
24
+ for (const range of this.ranges) {
25
+ range.style.width = vertical ? "" : percentage
26
+ range.style.height = vertical ? percentage : ""
27
+ }
28
+
29
+ for (const thumb of this.thumbs) {
30
+ thumb.style.left = vertical ? "50%" : percentage
31
+ thumb.style.bottom = vertical ? percentage : ""
32
+ }
33
+ }
34
+
35
+ /** @param {HTMLInputElement} input */
36
+ percentage(input) {
37
+ const min = this.numberOr(input.min, DEFAULT_MIN)
38
+ const max = this.numberOr(input.max, DEFAULT_MAX)
39
+ if (max <= min) return "0%"
40
+
41
+ const value = Number.isFinite(input.valueAsNumber)
42
+ ? input.valueAsNumber
43
+ : this.numberOr(input.value, min)
44
+ const ratio = Math.min(1, Math.max(0, (value - min) / (max - min)))
45
+ return `${ratio * 100}%`
46
+ }
47
+
48
+ /** @param {string} value @param {number} fallback */
49
+ numberOr(value, fallback) {
50
+ if (value.trim() === "") return fallback
51
+ const number = Number(value)
52
+ return Number.isFinite(number) ? number : fallback
53
+ }
54
+
55
+ /** @returns {HTMLInputElement | null} */
56
+ get input() {
57
+ const input = ownedElements(
58
+ this.element,
59
+ "input[type='range'][data-slot='slider-input']",
60
+ CONTROLLER,
61
+ )[0]
62
+ return /** @type {HTMLInputElement | null} */ (input ?? null)
63
+ }
64
+
65
+ /** @returns {HTMLElement[]} */
66
+ get ranges() {
67
+ return ownedElements(this.element, "[data-slot='slider-range']", CONTROLLER)
68
+ }
69
+
70
+ /** @returns {HTMLElement[]} */
71
+ get thumbs() {
72
+ return ownedElements(this.element, "[data-slot='slider-thumb']", CONTROLLER)
73
+ }
74
+ }