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,416 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import { ownedElements } from "@supermomonga/shadcn-view-components/aria_relationships"
4
+
5
+ const IDENTIFIER = "shadcn--input-otp"
6
+ const STYLE_ID = "input-otp-style"
7
+ const AUTOFILL_STYLES = "background: transparent !important; color: transparent !important; " +
8
+ "border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; " +
9
+ "-webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;"
10
+ const STYLE_RULES = [
11
+ "[data-input-otp]::selection { background: transparent !important; color: transparent !important; }",
12
+ `[data-input-otp]:autofill { ${AUTOFILL_STYLES} }`,
13
+ `[data-input-otp]:-webkit-autofill { ${AUTOFILL_STYLES} }`,
14
+ "@supports (-webkit-touch-callout: none) { [data-input-otp] { letter-spacing: -.6em !important; " +
15
+ "font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; " +
16
+ "left: -1px !important; right: 1px !important; } }",
17
+ "[data-input-otp] + * { pointer-events: all !important; }",
18
+ ]
19
+
20
+ function ensureRuntimeStyles() {
21
+ if (document.getElementById(STYLE_ID)) return
22
+
23
+ const style = document.createElement("style")
24
+ style.id = STYLE_ID
25
+ document.head.appendChild(style)
26
+ if (!style.sheet) return
27
+
28
+ for (const rule of STYLE_RULES) safeInsertRule(style.sheet, rule)
29
+ }
30
+
31
+ /** @param {CSSStyleSheet} sheet @param {string} rule */
32
+ function safeInsertRule(sheet, rule) {
33
+ try {
34
+ sheet.insertRule(rule)
35
+ } catch {
36
+ globalThis.console.error("input-otp could not insert CSS rule:", rule)
37
+ }
38
+ }
39
+
40
+ /** @extends {Controller<HTMLElement>} */
41
+ export default class InputOTPController extends Controller {
42
+ /** @type {HTMLInputElement | null} */
43
+ input = null
44
+
45
+ /** @type {HTMLElement | null} */
46
+ display = null
47
+
48
+ /** @type {HTMLFormElement | null} */
49
+ form = null
50
+
51
+ /** @type {HTMLElement[]} */
52
+ slots = []
53
+
54
+ /** @type {RegExp | null} */
55
+ pattern = null
56
+
57
+ /** @type {boolean} */
58
+ composing = false
59
+
60
+ /** @type {boolean} */
61
+ isIOS = false
62
+
63
+ /** @type {string} */
64
+ acceptedValue = ""
65
+
66
+ /** @type {number} */
67
+ acceptedSelectionStart = 0
68
+
69
+ /** @type {number} */
70
+ acceptedSelectionEnd = 0
71
+
72
+ /** @type {"forward" | "backward" | "none"} */
73
+ acceptedSelectionDirection = "none"
74
+
75
+ /** @type {number | null} */
76
+ previousSelectionStart = null
77
+
78
+ /** @type {number | null} */
79
+ previousSelectionEnd = null
80
+
81
+ /** @type {"forward" | "backward" | "none" | null} */
82
+ previousSelectionDirection = null
83
+
84
+ /** @type {() => void} */
85
+ onSelectionChange = () => {
86
+ if (this.composing) return
87
+ this.normalizeSelection()
88
+ }
89
+
90
+ /** @type {(event: ClipboardEvent) => void} */
91
+ onPaste = (event) => this.paste(event)
92
+
93
+ /** @type {() => void} */
94
+ onFormReset = () => {
95
+ const input = this.input
96
+ window.queueMicrotask(() => {
97
+ if (!input || this.input !== input || !this.element.isConnected) return
98
+
99
+ // reset後のdefaultValueは初期値と同じくpattern検証を経ないbaselineにする。
100
+ this.acceptBaseline()
101
+ this.syncPresentation()
102
+ })
103
+ }
104
+
105
+ connect() {
106
+ this.input = /** @type {HTMLInputElement | null} */ (
107
+ ownedElements(this.element, "input[data-slot='input-otp']", IDENTIFIER)[0] ?? null
108
+ )
109
+ this.display = ownedElements(this.element, "[data-input-otp-display]", IDENTIFIER)[0] ?? null
110
+ this.slots = ownedElements(
111
+ this.element,
112
+ "[data-slot='input-otp-slot'][data-index]",
113
+ IDENTIFIER,
114
+ )
115
+ this.form = this.input?.form ?? null
116
+ this.composing = false
117
+ this.isIOS = Boolean(window.CSS?.supports?.("-webkit-touch-callout", "none"))
118
+
119
+ if (!this.input) return
120
+
121
+ // input-otp 1.4.2と同じく、patternは利用者が完全一致を含めて指定する。
122
+ // 空patternは制限なしとして扱い、不正な正規表現は接続時にfail-fastする。
123
+ this.pattern = this.input.pattern ? new RegExp(this.input.pattern) : null
124
+ ensureRuntimeStyles()
125
+ this.acceptBaseline()
126
+ this.syncPresentation()
127
+
128
+ document.addEventListener("selectionchange", this.onSelectionChange, true)
129
+ this.form?.addEventListener("reset", this.onFormReset)
130
+ this.input.addEventListener("paste", this.onPaste)
131
+ }
132
+
133
+ disconnect() {
134
+ document.removeEventListener("selectionchange", this.onSelectionChange, true)
135
+ this.form?.removeEventListener("reset", this.onFormReset)
136
+ this.input?.removeEventListener("paste", this.onPaste)
137
+ this.clearActivePresentation()
138
+
139
+ this.input = null
140
+ this.display = null
141
+ this.form = null
142
+ this.slots = []
143
+ this.pattern = null
144
+ this.composing = false
145
+ this.isIOS = false
146
+ this.previousSelectionStart = null
147
+ this.previousSelectionEnd = null
148
+ this.previousSelectionDirection = null
149
+ }
150
+
151
+ /** @param {Event} [event] */
152
+ sync(event) {
153
+ const inputEvent = /** @type {InputEvent | undefined} */ (event)
154
+ if (!this.input || this.composing || inputEvent?.isComposing) return
155
+
156
+ if (event?.type === "focus") this.normalizeFocusSelection()
157
+ if (!event || event.type === "input" || event.type === "change") this.acceptOrRestoreValue()
158
+ this.syncPresentation()
159
+ }
160
+
161
+ compositionStart() {
162
+ this.composing = true
163
+ }
164
+
165
+ compositionEnd() {
166
+ if (!this.input) return
167
+
168
+ this.composing = false
169
+ this.acceptOrRestoreValue()
170
+ this.syncPresentation()
171
+ }
172
+
173
+ acceptBaseline() {
174
+ if (!this.input) return
175
+
176
+ const baseline = this.truncate(this.input.value)
177
+ const selection = this.selectionWithin(baseline.length)
178
+ if (baseline !== this.input.value) this.input.value = baseline
179
+ this.applySelection(selection)
180
+ this.acceptedValue = baseline
181
+ this.captureAcceptedSelection()
182
+ this.capturePreviousSelection()
183
+ }
184
+
185
+ acceptOrRestoreValue() {
186
+ const input = this.input
187
+ if (!input) return
188
+
189
+ const previousLength = this.acceptedValue.length
190
+ const rawValue = input.value
191
+ const candidate = this.truncate(rawValue)
192
+ const selection = this.selectionWithin(candidate.length)
193
+ const changed = candidate !== this.acceptedValue
194
+ const rejected = changed && candidate !== "" && this.pattern && !this.pattern.test(candidate)
195
+
196
+ if (rejected) {
197
+ this.restoreAcceptedValue()
198
+ return
199
+ }
200
+
201
+ if (candidate !== rawValue) input.value = candidate
202
+ this.applySelection(selection)
203
+ this.acceptedValue = candidate
204
+ this.captureAcceptedSelection()
205
+ if (candidate.length < previousLength) document.dispatchEvent(new window.Event("selectionchange"))
206
+ }
207
+
208
+ normalizeFocusSelection() {
209
+ const input = this.input
210
+ if (!input) return
211
+
212
+ const start = Math.min(input.value.length, Math.max(0, this.maximumLength - 1))
213
+ input.setSelectionRange(start, input.value.length)
214
+ this.capturePreviousSelection()
215
+ }
216
+
217
+ normalizeSelection() {
218
+ const input = this.input
219
+ if (!input) return
220
+ if (document.activeElement !== input) {
221
+ this.syncPresentation()
222
+ return
223
+ }
224
+
225
+ const selectionStart = input.selectionStart
226
+ const selectionEnd = input.selectionEnd
227
+ const maximum = this.maximumLength
228
+ const value = input.value
229
+ let normalizedStart = -1
230
+ let normalizedEnd = -1
231
+ /** @type {"forward" | "backward" | "none" | undefined} */
232
+ let normalizedDirection
233
+
234
+ if (value.length !== 0 && selectionStart !== null && selectionEnd !== null) {
235
+ const collapsed = selectionStart === selectionEnd
236
+ const inserting = selectionStart === value.length && value.length < maximum
237
+
238
+ if (collapsed && !inserting) {
239
+ const caret = selectionStart
240
+ if (caret === 0) {
241
+ normalizedStart = 0
242
+ normalizedEnd = 1
243
+ normalizedDirection = "forward"
244
+ } else if (caret === maximum) {
245
+ normalizedStart = caret - 1
246
+ normalizedEnd = caret
247
+ normalizedDirection = "backward"
248
+ } else if (maximum > 1 && value.length > 1) {
249
+ let offset = 0
250
+ if (this.previousSelectionStart !== null && this.previousSelectionEnd !== null) {
251
+ normalizedDirection = caret < this.previousSelectionEnd ? "backward" : "forward"
252
+ const wasInserting = this.previousSelectionStart === this.previousSelectionEnd &&
253
+ this.previousSelectionStart < maximum
254
+ if (normalizedDirection === "backward" && !wasInserting) offset = -1
255
+ }
256
+ normalizedStart = offset + caret
257
+ normalizedEnd = offset + caret + 1
258
+ }
259
+ }
260
+ }
261
+
262
+ if (normalizedStart !== -1 && normalizedEnd !== -1 && normalizedStart !== normalizedEnd) {
263
+ input.setSelectionRange(normalizedStart, normalizedEnd, normalizedDirection)
264
+ }
265
+ this.capturePreviousSelection()
266
+ this.syncPresentation()
267
+ }
268
+
269
+ /** @param {ClipboardEvent} event */
270
+ paste(event) {
271
+ const input = this.input
272
+ if (!this.isIOS || !input || !event.clipboardData) return
273
+
274
+ event.preventDefault()
275
+ const content = event.clipboardData.getData("text/plain")
276
+ const start = input.selectionStart ?? 0
277
+ const end = input.selectionEnd ?? start
278
+ const candidate = this.truncate(
279
+ start !== end
280
+ ? this.acceptedValue.slice(0, start) + content + this.acceptedValue.slice(end)
281
+ : this.acceptedValue.slice(0, start) + content + this.acceptedValue.slice(start),
282
+ )
283
+ if (candidate !== "" && this.pattern && !this.pattern.test(candidate)) return
284
+
285
+ input.value = candidate
286
+ this.acceptedValue = candidate
287
+ const selectionStart = Math.min(candidate.length, Math.max(0, this.maximumLength - 1))
288
+ input.setSelectionRange(selectionStart, candidate.length)
289
+ this.captureAcceptedSelection()
290
+ this.capturePreviousSelection()
291
+ this.syncPresentation()
292
+ input.dispatchEvent(new window.InputEvent("input", {
293
+ bubbles: true,
294
+ composed: true,
295
+ data: content,
296
+ inputType: "insertFromPaste",
297
+ }))
298
+ }
299
+
300
+ syncPresentation() {
301
+ const input = this.input
302
+ if (!input) return
303
+
304
+ const value = input.value
305
+ const focused = document.activeElement === input && !input.disabled
306
+ const selectionStart = input.selectionStart ?? value.length
307
+ const selectionEnd = input.selectionEnd ?? selectionStart
308
+ const start = Math.min(selectionStart, selectionEnd)
309
+ const end = Math.max(selectionStart, selectionEnd)
310
+ const collapsed = start === end
311
+ const length = this.maximumLength
312
+ const activeIndex = collapsed && length > 0 ? Math.min(start, length - 1) : -1
313
+ const invalid = input.getAttribute("aria-invalid") === "true"
314
+
315
+ this.display?.toggleAttribute("data-disabled", input.disabled)
316
+ this.display?.toggleAttribute("data-invalid", invalid)
317
+
318
+ for (const slot of this.slots) {
319
+ const index = Number(slot.dataset.index)
320
+ const active = focused && Number.isInteger(index) && (
321
+ collapsed ? index === activeIndex : index >= start && index < end
322
+ )
323
+ const caretVisible = active && collapsed && index === start && start < length && value[index] === undefined
324
+ const character = this.part(slot, "input-otp-character")
325
+ const caret = this.part(slot, "input-otp-caret")
326
+
327
+ if (character) character.textContent = value[index] ?? ""
328
+ if (caret) caret.hidden = !caretVisible
329
+ slot.dataset.active = String(active)
330
+ slot.toggleAttribute("data-disabled", input.disabled)
331
+ slot.setAttribute("aria-invalid", String(invalid))
332
+ }
333
+
334
+ if (input.value === this.acceptedValue) this.captureAcceptedSelection()
335
+ }
336
+
337
+ clearActivePresentation() {
338
+ for (const slot of this.slots) {
339
+ slot.dataset.active = "false"
340
+ const caret = this.part(slot, "input-otp-caret")
341
+ if (caret) caret.hidden = true
342
+ }
343
+ }
344
+
345
+ restoreAcceptedValue() {
346
+ const input = this.input
347
+ if (!input) return
348
+
349
+ input.value = this.acceptedValue
350
+ input.setSelectionRange(
351
+ Math.min(this.acceptedSelectionStart, input.value.length),
352
+ Math.min(this.acceptedSelectionEnd, input.value.length),
353
+ this.acceptedSelectionDirection,
354
+ )
355
+ }
356
+
357
+ captureAcceptedSelection() {
358
+ const input = this.input
359
+ if (!input) return
360
+
361
+ this.acceptedSelectionStart = input.selectionStart ?? input.value.length
362
+ this.acceptedSelectionEnd = input.selectionEnd ?? this.acceptedSelectionStart
363
+ this.acceptedSelectionDirection = input.selectionDirection ?? "none"
364
+ }
365
+
366
+ capturePreviousSelection() {
367
+ const input = this.input
368
+ if (!input) return
369
+
370
+ this.previousSelectionStart = input.selectionStart
371
+ this.previousSelectionEnd = input.selectionEnd
372
+ this.previousSelectionDirection = input.selectionDirection
373
+ }
374
+
375
+ /** @param {number} maximum @returns {{ start: number, end: number, direction: "forward" | "backward" | "none" }} */
376
+ selectionWithin(maximum) {
377
+ const input = this.input
378
+ if (!input) return { start: 0, end: 0, direction: "none" }
379
+
380
+ return {
381
+ start: Math.min(input.selectionStart ?? maximum, maximum),
382
+ end: Math.min(input.selectionEnd ?? maximum, maximum),
383
+ direction: input.selectionDirection ?? "none",
384
+ }
385
+ }
386
+
387
+ /** @param {{ start: number, end: number, direction: "forward" | "backward" | "none" }} selection */
388
+ applySelection(selection) {
389
+ this.input?.setSelectionRange(selection.start, selection.end, selection.direction)
390
+ }
391
+
392
+ /** @param {string} value @returns {string} */
393
+ truncate(value) {
394
+ const maximum = this.input?.maxLength ?? -1
395
+ return maximum >= 0 ? value.slice(0, maximum) : value
396
+ }
397
+
398
+ /** @param {HTMLElement} slot @param {string} name @returns {HTMLElement | null} */
399
+ part(slot, name) {
400
+ const element = /** @type {HTMLElement | null} */ (
401
+ slot.querySelector(`[data-slot='${name}']`)
402
+ )
403
+ return element?.closest(`[data-controller~='${IDENTIFIER}']`) === this.element ? element : null
404
+ }
405
+
406
+ /** @returns {number} */
407
+ get maximumLength() {
408
+ const maximum = this.input?.maxLength ?? -1
409
+ if (maximum >= 0) return maximum
410
+
411
+ return this.slots.reduce((length, slot) => {
412
+ const index = Number(slot.dataset.index)
413
+ return Number.isInteger(index) ? Math.max(length, index + 1) : length
414
+ }, 0)
415
+ }
416
+ }
@@ -0,0 +1,286 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import { pointAnchor } from "@supermomonga/shadcn-view-components/floating_position"
4
+ import {
5
+ activateMenuItemFromKeyboard,
6
+ applyMenuTriggerState,
7
+ associateMenuElements,
8
+ directMenuItems,
9
+ isOwnedMenuElement,
10
+ MenuPopoverManager,
11
+ ownedMenuElements,
12
+ scopedMenuElement,
13
+ } from "@supermomonga/shadcn-view-components/menu_popover"
14
+
15
+ const SUB_SCOPE_SELECTOR = "[data-slot$='-sub']"
16
+ const SUB_TRIGGER_SELECTOR = "[data-slot$='-sub-trigger']"
17
+
18
+ // DropdownMenu / ContextMenu固有のARIA menu状態を管理する。
19
+ // MenubarとNavigationMenuはDOM構造・操作モデルが異なるため専用controllerを使う。
20
+ export default class MenuController extends Controller {
21
+ /** @type {HTMLElement | null} */
22
+ rootMenu = null
23
+
24
+ /** @type {HTMLElement | null} */
25
+ rootTrigger = null
26
+
27
+ /** @type {Map<HTMLElement, HTMLElement>} */
28
+ triggerToPopover = new Map()
29
+
30
+ /** @type {Map<HTMLElement, HTMLElement>} */
31
+ popoverToTrigger = new Map()
32
+
33
+ /** @type {MenuPopoverManager | null} */
34
+ popovers = null
35
+
36
+ /** @type {(event: MouseEvent) => void} */
37
+ onClick = (event) => {
38
+ if (!(event.target instanceof Element) || !isOwnedMenuElement(this.root, event.target)) return
39
+
40
+ const item = /** @type {HTMLElement | null} */ (event.target.closest("[role^='menuitem']"))
41
+ if (!item || this.triggerToPopover.has(item)) return
42
+ const menu = /** @type {HTMLElement | null} */ (item.closest("[role='menu']"))
43
+ if (menu && this.popovers?.isOpen(menu)) this.closeAll()
44
+ }
45
+
46
+ /** @type {(event: KeyboardEvent) => void} */
47
+ onKeydown = (event) => this.navigate(event)
48
+
49
+ /** @type {(event: FocusEvent) => void} */
50
+ onFocusOut = (event) => {
51
+ const next = event.relatedTarget
52
+ if (next instanceof Node && this.popovers?.containsOpenPopover(next)) return
53
+ this.closeAll()
54
+ }
55
+
56
+ /** @type {(event: PointerEvent) => void} */
57
+ onDocPointerDown = (event) => {
58
+ if (!this.popovers) return
59
+ this.popovers.beginPointerDown()
60
+ if (!(event.target instanceof Node)) return
61
+
62
+ for (const popover of this.popovers.openPopovers().reverse()) {
63
+ const trigger = this.popoverToTrigger.get(popover)
64
+ if (popover.contains(event.target)) continue
65
+ if (!trigger?.matches("[data-slot='context-menu-trigger']") && trigger?.contains(event.target)) {
66
+ this.popovers.recordOpenTriggerPointerDown(event, popover, trigger)
67
+ continue
68
+ }
69
+ this.popovers.hide(popover)
70
+ }
71
+ }
72
+
73
+ connect() {
74
+ this.configurePairs()
75
+ this.popovers = new MenuPopoverManager(this.root, (popover, state) => this.syncState(popover, state))
76
+ this.popovers.connect()
77
+ this.root.addEventListener("click", this.onClick, true)
78
+ this.root.addEventListener("keydown", this.onKeydown, true)
79
+ this.root.addEventListener("focusout", this.onFocusOut)
80
+ document.addEventListener("pointerdown", this.onDocPointerDown)
81
+ }
82
+
83
+ disconnect() {
84
+ this.root.removeEventListener("click", this.onClick, true)
85
+ this.root.removeEventListener("keydown", this.onKeydown, true)
86
+ this.root.removeEventListener("focusout", this.onFocusOut)
87
+ document.removeEventListener("pointerdown", this.onDocPointerDown)
88
+ this.popovers?.disconnect()
89
+ this.popovers = null
90
+ this.rootMenu = null
91
+ this.rootTrigger = null
92
+ this.triggerToPopover.clear()
93
+ this.popoverToTrigger.clear()
94
+ }
95
+
96
+ configurePairs() {
97
+ this.triggerToPopover.clear()
98
+ this.popoverToTrigger.clear()
99
+
100
+ for (const scope of ownedMenuElements(this.root, SUB_SCOPE_SELECTOR)) {
101
+ const trigger = scopedMenuElement(this.root, scope, SUB_SCOPE_SELECTOR, SUB_TRIGGER_SELECTOR)
102
+ const popover = scopedMenuElement(this.root, scope, SUB_SCOPE_SELECTOR, "[popover][role='menu']")
103
+ if (trigger && popover) this.addPair(trigger, popover, "shadcn-submenu")
104
+ }
105
+
106
+ this.rootMenu = ownedMenuElements(this.root, "[popover][role='menu']")
107
+ .find((popover) => !this.popoverToTrigger.has(popover)) ?? null
108
+ this.rootTrigger = ownedMenuElements(this.root, "[aria-haspopup='menu']")
109
+ .find((trigger) => !trigger.matches(SUB_TRIGGER_SELECTOR)) ?? null
110
+ if (this.rootTrigger && this.rootMenu) this.addPair(this.rootTrigger, this.rootMenu, "shadcn-menu")
111
+ }
112
+
113
+ /** @param {HTMLElement} trigger @param {HTMLElement} popover @param {string} prefix */
114
+ addPair(trigger, popover, prefix) {
115
+ associateMenuElements(trigger, popover, prefix)
116
+ this.triggerToPopover.set(trigger, popover)
117
+ this.popoverToTrigger.set(popover, trigger)
118
+ }
119
+
120
+ /** @param {Event} event */
121
+ toggle(event) {
122
+ if (!(event.currentTarget instanceof HTMLElement)) return
123
+
124
+ const popover = this.triggerToPopover.get(event.currentTarget)
125
+ if (!popover || !this.popovers) return
126
+ if (this.popovers.consumeOpenTriggerPointer(event, event.currentTarget)) {
127
+ this.closeMenu(popover)
128
+ return
129
+ }
130
+ if (this.popovers.isOpen(popover)) this.closeMenu(popover)
131
+ else this.showMenu(popover, event.currentTarget)
132
+ }
133
+
134
+ /** @param {MouseEvent} event */
135
+ showAt(event) {
136
+ if (!(event.currentTarget instanceof HTMLElement) || !this.rootMenu || !this.popovers) return
137
+
138
+ event.preventDefault()
139
+ this.popovers.hideAll([this.rootMenu])
140
+ this.popovers.show(
141
+ this.rootMenu,
142
+ pointAnchor(event.clientX, event.clientY, event.currentTarget),
143
+ { align: "start", alignOffset: 4, side: "right", sideOffset: 0 },
144
+ )
145
+ this.focusBoundaryItem(this.rootMenu, "first")
146
+ }
147
+
148
+ /** @param {Event} event */
149
+ toggleSub(event) {
150
+ if (!(event.currentTarget instanceof HTMLElement)) return
151
+
152
+ const popover = this.triggerToPopover.get(event.currentTarget)
153
+ if (!popover || !this.popovers) return
154
+ if (this.popovers.consumeOpenTriggerPointer(event, event.currentTarget)) {
155
+ this.closeMenu(popover)
156
+ return
157
+ }
158
+ if (this.popovers.isOpen(popover)) this.closeMenu(popover)
159
+ else this.showMenu(popover, event.currentTarget)
160
+ }
161
+
162
+ activate() {
163
+ this.closeAll()
164
+ }
165
+
166
+ /** @param {KeyboardEvent} event */
167
+ navigate(event) {
168
+ const target = event.target
169
+ if (!(target instanceof Element) || !isOwnedMenuElement(this.root, target)) return
170
+
171
+ if (event.key === "Tab" && (this.popovers?.openPopovers().length ?? 0) > 0) {
172
+ this.closeAll()
173
+ return
174
+ }
175
+
176
+ const menu = /** @type {HTMLElement | null} */ (target.closest("[role='menu']"))
177
+ if (!menu || !this.popovers?.isOpen(menu)) return
178
+
179
+ const items = directMenuItems(this.root, menu)
180
+ const active = menu.ownerDocument.activeElement
181
+ const current = items.findIndex((item) => (
182
+ item === target || item.contains(target) || item === active || (active instanceof Node && item.contains(active))
183
+ ))
184
+ if (activateMenuItemFromKeyboard(event, items[current])) return
185
+
186
+ const keys = ["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", "Home", "End", "Escape"]
187
+ if (!keys.includes(event.key)) return
188
+ event.preventDefault()
189
+ if (event.key === "Escape") {
190
+ this.escapeMenu(menu)
191
+ return
192
+ }
193
+ if (event.key === "ArrowRight" && current >= 0) {
194
+ const submenu = this.triggerToPopover.get(items[current])
195
+ if (submenu) this.showMenu(submenu, items[current])
196
+ return
197
+ }
198
+ if (event.key === "ArrowLeft") {
199
+ const parentTrigger = this.popoverToTrigger.get(menu)
200
+ if (menu !== this.rootMenu && parentTrigger) {
201
+ this.closeMenu(menu)
202
+ parentTrigger.focus()
203
+ }
204
+ return
205
+ }
206
+ if (items.length === 0) return
207
+
208
+ let next = current
209
+ if (event.key === "ArrowDown") next = (current + 1 + items.length) % items.length
210
+ if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length
211
+ if (event.key === "Home") next = 0
212
+ if (event.key === "End") next = items.length - 1
213
+ this.focusItem(menu, items[Math.max(0, next)])
214
+ }
215
+
216
+ /** @param {HTMLElement} menu */
217
+ escapeMenu(menu) {
218
+ const trigger = this.popoverToTrigger.get(menu)
219
+ if (menu === this.rootMenu) {
220
+ this.closeAll()
221
+ trigger?.focus()
222
+ return
223
+ }
224
+
225
+ this.closeMenu(menu)
226
+ trigger?.focus()
227
+ }
228
+
229
+ /** @param {HTMLElement} menu @param {HTMLElement} anchor */
230
+ showMenu(menu, anchor) {
231
+ if (!this.popovers) return
232
+
233
+ const isSubmenu = menu !== this.rootMenu
234
+ this.popovers.show(menu, anchor, isSubmenu
235
+ ? { align: "start", alignOffset: -3, side: "right", sideOffset: 0 }
236
+ : { align: "start", side: "bottom", sideOffset: 4 })
237
+ this.focusBoundaryItem(menu, "first")
238
+ }
239
+
240
+ /** @param {HTMLElement} menu */
241
+ closeMenu(menu) {
242
+ this.closeDescendants(menu)
243
+ this.popovers?.hide(menu)
244
+ }
245
+
246
+ closeAll() {
247
+ this.popovers?.hideAll()
248
+ }
249
+
250
+ /** @param {HTMLElement} parent */
251
+ closeDescendants(parent) {
252
+ for (const popover of this.popovers?.openPopovers() ?? []) {
253
+ if (popover !== parent && parent.contains(popover)) this.popovers?.hide(popover)
254
+ }
255
+ }
256
+
257
+ /** @param {HTMLElement} popover @param {"open" | "closed"} state */
258
+ syncState(popover, state) {
259
+ const trigger = this.popoverToTrigger.get(popover)
260
+ applyMenuTriggerState(trigger, state, { expanded: !trigger?.matches("[data-slot='context-menu-trigger']") })
261
+ if (state === "closed") this.closeDescendants(popover)
262
+ }
263
+
264
+ /** @param {HTMLElement} menu @param {"first" | "last"} boundary */
265
+ focusBoundaryItem(menu, boundary) {
266
+ const items = directMenuItems(this.root, menu)
267
+ this.focusItem(menu, boundary === "first" ? items[0] : items.at(-1))
268
+ }
269
+
270
+ /** @param {HTMLElement} menu @param {HTMLElement | undefined} item */
271
+ focusItem(menu, item) {
272
+ if (!item) return
273
+
274
+ for (const candidate of directMenuItems(this.root, menu)) {
275
+ const highlighted = candidate === item
276
+ candidate.dataset.highlighted = highlighted ? "true" : "false"
277
+ candidate.tabIndex = highlighted ? 0 : -1
278
+ }
279
+ item.focus()
280
+ }
281
+
282
+ /** @returns {HTMLElement} */
283
+ get root() {
284
+ return /** @type {HTMLElement} */ (this.element)
285
+ }
286
+ }