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,660 @@
1
+ /** @typedef {"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end"} Side */
2
+ /** @typedef {"top" | "right" | "bottom" | "left"} PhysicalSide */
3
+ /** @typedef {"start" | "center" | "end"} Align */
4
+ /** @typedef {"ltr" | "rtl"} Direction */
5
+ /** @typedef {{ getBoundingClientRect: () => DOMRect | DOMRectReadOnly, contextElement?: Element }} Anchor */
6
+ /** @typedef {{ width: number, height: number }} FloatingSize */
7
+ /** @typedef {{ side: PhysicalSide, align: Align }} Placement */
8
+ /** @typedef {{ side: PhysicalSide, align: Align, coordinates: {x: number, y: number}, overflows: number[] }} EvaluatedPlacement */
9
+
10
+ /**
11
+ * @typedef {object} PositionConfig
12
+ * @property {Side} [side]
13
+ * @property {Align} [align]
14
+ * @property {number} [sideOffset]
15
+ * @property {number} [alignOffset]
16
+ * @property {number} [collisionPadding]
17
+ * @property {Direction} [direction]
18
+ * @property {{ width: number, height: number }} [viewport]
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} FloatingPosition
23
+ * @property {number} x
24
+ * @property {number} y
25
+ * @property {Side} side
26
+ * @property {Align} align
27
+ * @property {number} anchorWidth
28
+ * @property {number} anchorHeight
29
+ * @property {number} availableWidth
30
+ * @property {number} availableHeight
31
+ * @property {string} transformOrigin
32
+ */
33
+
34
+ const SIDES = new Set(["top", "right", "bottom", "left", "inline-start", "inline-end"])
35
+ const ALIGNS = new Set(["start", "center", "end"])
36
+ /** @type {Record<PhysicalSide, PhysicalSide>} */
37
+ const OPPOSITE_SIDE = { bottom: "top", left: "right", right: "left", top: "bottom" }
38
+
39
+ /**
40
+ * DOMから独立した座標計算。希望sideの主軸が衝突すると反対側へflipし、
41
+ * align軸は反対alignを比較した後collision padding内へshiftする。
42
+ *
43
+ * @param {DOMRect | DOMRectReadOnly} anchorRect
44
+ * @param {FloatingSize} floatingRect
45
+ * @param {PositionConfig} [config]
46
+ * @returns {FloatingPosition}
47
+ */
48
+ export function computeFloatingPosition(anchorRect, floatingRect, config = {}) {
49
+ const direction = parseDirection(config.direction ?? "ltr")
50
+ const requestedSide = parseSide(config.side ?? "bottom")
51
+ const requestedAlign = parseAlign(config.align ?? "center")
52
+ const sideOffset = parseNumber(config.sideOffset ?? 0, "sideOffset")
53
+ const alignOffset = parseNumber(config.alignOffset ?? 0, "alignOffset")
54
+ const collisionPadding = parseNumber(config.collisionPadding ?? 5, "collisionPadding", 0)
55
+ const viewport = config.viewport ?? { height: window.innerHeight, width: window.innerWidth }
56
+ const viewportWidth = parseNumber(viewport.width, "viewport.width", 0)
57
+ const viewportHeight = parseNumber(viewport.height, "viewport.height", 0)
58
+ const preferredSide = physicalSide(requestedSide, direction)
59
+ const placements = placementCandidates(preferredSide, requestedAlign, direction)
60
+ const flipPadding = biasedCollisionPadding(requestedSide, collisionPadding)
61
+ const evaluated = placements.map((placement) => evaluatePlacement(
62
+ placement,
63
+ anchorRect,
64
+ floatingRect,
65
+ sideOffset,
66
+ alignOffset,
67
+ direction,
68
+ viewportWidth,
69
+ viewportHeight,
70
+ collisionPadding,
71
+ flipPadding,
72
+ ))
73
+ const initialAxis = sideAxis(preferredSide)
74
+ const initialAxisPlacements = evaluated.filter((placement) => sideAxis(placement.side) === initialAxis)
75
+ const candidates = initialAxisPlacements.every((placement) => placement.overflows[0] > 0)
76
+ ? evaluated
77
+ : initialAxisPlacements
78
+ const resolvedPlacement = selectPlacement(candidates, initialAxis)
79
+ const {
80
+ align: resolvedAlign,
81
+ coordinates,
82
+ side: resolvedPhysicalSide,
83
+ } = resolvedPlacement
84
+
85
+ // Base UIの既定shiftはalign軸だけをviewport内へ移動する。side軸はflipの結果を保つ。
86
+ const shiftedCoordinates = shiftAlignAxis(
87
+ coordinates,
88
+ resolvedPhysicalSide,
89
+ floatingRect,
90
+ viewportWidth,
91
+ viewportHeight,
92
+ collisionPadding,
93
+ )
94
+ const { x, y } = shiftedCoordinates
95
+ const side = outputSide(requestedSide, resolvedPhysicalSide, direction)
96
+ const available = availableSize(
97
+ resolvedPhysicalSide,
98
+ anchorRect,
99
+ viewportWidth,
100
+ viewportHeight,
101
+ collisionPadding,
102
+ sideOffset,
103
+ )
104
+
105
+ return {
106
+ align: resolvedAlign,
107
+ anchorHeight: anchorRect.height,
108
+ anchorWidth: anchorRect.width,
109
+ availableHeight: available.height,
110
+ availableWidth: available.width,
111
+ side,
112
+ transformOrigin: transformOrigin(
113
+ resolvedPhysicalSide,
114
+ resolvedAlign,
115
+ sideOffset,
116
+ direction,
117
+ anchorRect,
118
+ floatingRect,
119
+ coordinates,
120
+ x,
121
+ y,
122
+ ),
123
+ x,
124
+ y,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 浮動要素を配置し、scroll・resize・要素size・anchor移動へ追従する。
130
+ * 入力data-position-*は引数より優先し、data-side/data-alignは実配置だけを表す。
131
+ *
132
+ * @param {{
133
+ * anchor: Anchor,
134
+ * floating: HTMLElement,
135
+ * side?: Side,
136
+ * align?: Align,
137
+ * sideOffset?: number,
138
+ * alignOffset?: number,
139
+ * collisionPadding?: number,
140
+ * direction?: Direction,
141
+ * }} options
142
+ * @returns {{ update: () => void, destroy: () => void }}
143
+ */
144
+ export function startFloatingPosition(options) {
145
+ const { anchor, floating } = options
146
+ if (!anchor || typeof anchor.getBoundingClientRect !== "function") {
147
+ throw new TypeError("anchor must provide getBoundingClientRect()")
148
+ }
149
+ if (!(floating instanceof HTMLElement)) throw new TypeError("floating must be an HTMLElement")
150
+
151
+ const ownerDocument = floating.ownerDocument
152
+ const ownerWindow = ownerDocument.defaultView ?? window
153
+ const config = configFromElement(floating, options)
154
+ let destroyed = false
155
+ /** @type {number | undefined} */
156
+ let animationFrame
157
+
158
+ const update = () => {
159
+ if (destroyed) return
160
+ const anchorRect = anchor.getBoundingClientRect()
161
+ const floatingRect = floatingLayoutSize(floating)
162
+ const direction = config.direction ?? directionOf(anchor, floating, ownerWindow)
163
+ const result = computeFloatingPosition(anchorRect, floatingRect, {
164
+ ...config,
165
+ direction,
166
+ viewport: { height: ownerWindow.innerHeight, width: ownerWindow.innerWidth },
167
+ })
168
+ applyFloatingPosition(floating, result)
169
+ }
170
+ const scheduleUpdate = () => {
171
+ if (destroyed || animationFrame !== undefined) return
172
+ animationFrame = ownerWindow.requestAnimationFrame(() => {
173
+ animationFrame = undefined
174
+ update()
175
+ })
176
+ }
177
+
178
+ update()
179
+ ownerDocument.addEventListener("scroll", scheduleUpdate, true)
180
+ ownerWindow.addEventListener("resize", scheduleUpdate)
181
+ ownerWindow.visualViewport?.addEventListener("resize", scheduleUpdate)
182
+ ownerWindow.visualViewport?.addEventListener("scroll", scheduleUpdate)
183
+
184
+ /** @type {ResizeObserver | undefined} */
185
+ let resizeObserver
186
+ if (typeof ownerWindow.ResizeObserver === "function") {
187
+ resizeObserver = new ownerWindow.ResizeObserver(scheduleUpdate)
188
+ if (anchor instanceof Element) resizeObserver.observe(anchor)
189
+ else if (anchor.contextElement) resizeObserver.observe(anchor.contextElement)
190
+ resizeObserver.observe(floating)
191
+ }
192
+
193
+ const observedAnchor = anchor instanceof Element ? anchor : anchor.contextElement
194
+ const stopMoveObserver = observedAnchor
195
+ ? observeAnchorMove(observedAnchor, scheduleUpdate, ownerWindow)
196
+ : () => {}
197
+
198
+ const destroy = () => {
199
+ if (destroyed) return
200
+ destroyed = true
201
+ ownerDocument.removeEventListener("scroll", scheduleUpdate, true)
202
+ ownerWindow.removeEventListener("resize", scheduleUpdate)
203
+ ownerWindow.visualViewport?.removeEventListener("resize", scheduleUpdate)
204
+ ownerWindow.visualViewport?.removeEventListener("scroll", scheduleUpdate)
205
+ resizeObserver?.disconnect()
206
+ stopMoveObserver()
207
+ if (animationFrame !== undefined) ownerWindow.cancelAnimationFrame(animationFrame)
208
+ animationFrame = undefined
209
+ }
210
+
211
+ return { destroy, update }
212
+ }
213
+
214
+ /**
215
+ * 開くanimationのtransformで縮小・移動した描画boxではなく、最終layout寸法を返す。
216
+ * offset寸法が得られないDOM実装ではgetBoundingClientRect()へ戻す。
217
+ *
218
+ * @param {HTMLElement} floating
219
+ * @returns {FloatingSize}
220
+ */
221
+ function floatingLayoutSize(floating) {
222
+ const renderedRect = floating.getBoundingClientRect()
223
+ return {
224
+ height: floating.offsetHeight || renderedRect.height,
225
+ width: floating.offsetWidth || renderedRect.width,
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Context Menu等のclient座標を通常anchorと同じinterfaceで扱う。
231
+ *
232
+ * @param {number} x
233
+ * @param {number} y
234
+ * @param {Element} [contextElement]
235
+ * @returns {Anchor}
236
+ */
237
+ export function pointAnchor(x, y, contextElement) {
238
+ return {
239
+ contextElement,
240
+ getBoundingClientRect: () => DOMRect.fromRect({ height: 0, width: 0, x, y }),
241
+ }
242
+ }
243
+
244
+ /** @param {HTMLElement} floating @param {FloatingPosition} position */
245
+ function applyFloatingPosition(floating, position) {
246
+ const style = floating.style
247
+ style.position = "fixed"
248
+ style.margin = "0"
249
+ style.left = `${formatNumber(position.x)}px`
250
+ style.top = `${formatNumber(position.y)}px`
251
+ style.setProperty("--anchor-width", `${formatNumber(position.anchorWidth)}px`)
252
+ style.setProperty("--anchor-height", `${formatNumber(position.anchorHeight)}px`)
253
+ style.setProperty("--available-width", `${formatNumber(position.availableWidth)}px`)
254
+ style.setProperty("--available-height", `${formatNumber(position.availableHeight)}px`)
255
+ style.setProperty("--transform-origin", position.transformOrigin)
256
+ floating.dataset.side = position.side
257
+ floating.dataset.align = position.align
258
+ }
259
+
260
+ /**
261
+ * @param {HTMLElement} floating
262
+ * @param {{ side?: Side, align?: Align, sideOffset?: number, alignOffset?: number, collisionPadding?: number, direction?: Direction }} defaults
263
+ * @returns {PositionConfig}
264
+ */
265
+ function configFromElement(floating, defaults) {
266
+ return {
267
+ align: parseAlign(floating.dataset.positionAlign ?? defaults.align ?? "center"),
268
+ alignOffset: parseNumber(floating.dataset.positionAlignOffset ?? defaults.alignOffset ?? 0, "alignOffset"),
269
+ collisionPadding: parseNumber(
270
+ floating.dataset.positionCollisionPadding ?? defaults.collisionPadding ?? 5,
271
+ "collisionPadding",
272
+ 0,
273
+ ),
274
+ direction: defaults.direction,
275
+ side: parseSide(floating.dataset.positionSide ?? defaults.side ?? "bottom"),
276
+ sideOffset: parseNumber(floating.dataset.positionSideOffset ?? defaults.sideOffset ?? 0, "sideOffset"),
277
+ }
278
+ }
279
+
280
+ /**
281
+ * IntersectionObserverのrootをanchorの現在位置へ絞り、sizeが不変のlayout shiftも検出する。
282
+ * scroll/resizeは別購読なので、Observer非対応環境でも主要な追従経路は維持される。
283
+ *
284
+ * @param {Element} anchor
285
+ * @param {() => void} onMove
286
+ * @param {Window} ownerWindow
287
+ * @returns {() => void}
288
+ */
289
+ function observeAnchorMove(anchor, onMove, ownerWindow) {
290
+ const Observer = /** @type {typeof IntersectionObserver | undefined} */ (
291
+ Reflect.get(ownerWindow, "IntersectionObserver")
292
+ )
293
+ if (!Observer) return () => {}
294
+
295
+ /** @type {IntersectionObserver | undefined} */
296
+ let observer
297
+ let stopped = false
298
+ const observe = () => {
299
+ observer?.disconnect()
300
+ if (stopped) return
301
+ const rect = anchor.getBoundingClientRect()
302
+ const rootMargin = [
303
+ -Math.floor(rect.top),
304
+ -Math.floor(ownerWindow.innerWidth - rect.right),
305
+ -Math.floor(ownerWindow.innerHeight - rect.bottom),
306
+ -Math.floor(rect.left),
307
+ ].map((value) => `${value}px`).join(" ")
308
+ const observedRect = rect
309
+ const nextObserver = new Observer(() => {
310
+ const currentRect = anchor.getBoundingClientRect()
311
+ if (!rectChanged(observedRect, currentRect)) return
312
+ onMove()
313
+ observe()
314
+ }, { rootMargin, threshold: [0, 1] })
315
+ observer = nextObserver
316
+ nextObserver.observe(anchor)
317
+ }
318
+ observe()
319
+
320
+ return () => {
321
+ if (stopped) return
322
+ stopped = true
323
+ observer?.disconnect()
324
+ observer = undefined
325
+ }
326
+ }
327
+
328
+ /**
329
+ * @param {PhysicalSide} side
330
+ * @param {Align} align
331
+ * @param {DOMRect | DOMRectReadOnly} anchor
332
+ * @param {FloatingSize} floating
333
+ * @param {number} sideOffset
334
+ * @param {number} alignOffset
335
+ * @param {Direction} direction
336
+ */
337
+ function coordinatesFor(side, align, anchor, floating, sideOffset, alignOffset, direction) {
338
+ if (side === "top" || side === "bottom") {
339
+ return {
340
+ x: alignedCoordinate(anchor.left, anchor.width, floating.width, align, alignOffset, direction === "rtl" ? -1 : 1),
341
+ y: side === "top" ? anchor.top - floating.height - sideOffset : anchor.bottom + sideOffset,
342
+ }
343
+ }
344
+ return {
345
+ x: side === "left" ? anchor.left - floating.width - sideOffset : anchor.right + sideOffset,
346
+ y: alignedCoordinate(anchor.top, anchor.height, floating.height, align, alignOffset, 1),
347
+ }
348
+ }
349
+
350
+ /** @param {number} start @param {number} anchorSize @param {number} floatingSize @param {Align} align @param {number} offset @param {1 | -1} direction */
351
+ function alignedCoordinate(start, anchorSize, floatingSize, align, offset, direction) {
352
+ if (align === "center") return start + (anchorSize - floatingSize) / 2 + direction * offset
353
+ if (align === "start") return start + (direction === 1 ? 0 : anchorSize - floatingSize) + direction * offset
354
+ return start + (direction === 1 ? anchorSize - floatingSize : 0) - direction * offset
355
+ }
356
+
357
+ /**
358
+ * Floating UI flipと同じplacement順を構成する。align付きは希望/反対alignを
359
+ * 同じside軸で試してから、fallback axis end側へ移る。
360
+ *
361
+ * @param {PhysicalSide} preferredSide
362
+ * @param {Align} align
363
+ * @param {Direction} direction
364
+ * @returns {Placement[]}
365
+ */
366
+ function placementCandidates(preferredSide, align, direction) {
367
+ const oppositeSide = OPPOSITE_SIDE[preferredSide]
368
+ const fallbackEnd = fallbackEndSide(preferredSide, direction)
369
+ const fallbackOther = OPPOSITE_SIDE[fallbackEnd]
370
+ if (align === "center") {
371
+ return [preferredSide, oppositeSide, fallbackEnd, fallbackOther].map((side) => ({ align, side }))
372
+ }
373
+
374
+ const oppositeAlign = align === "start" ? "end" : "start"
375
+ return [
376
+ { align, side: preferredSide },
377
+ { align: oppositeAlign, side: preferredSide },
378
+ { align, side: oppositeSide },
379
+ { align: oppositeAlign, side: oppositeSide },
380
+ { align, side: fallbackEnd },
381
+ { align, side: fallbackOther },
382
+ { align: oppositeAlign, side: fallbackEnd },
383
+ { align: oppositeAlign, side: fallbackOther },
384
+ ]
385
+ }
386
+
387
+ /**
388
+ * @param {Placement} placement
389
+ * @param {DOMRect | DOMRectReadOnly} anchor
390
+ * @param {FloatingSize} floating
391
+ * @param {number} sideOffset
392
+ * @param {number} alignOffset
393
+ * @param {Direction} direction
394
+ * @param {number} viewportWidth
395
+ * @param {number} viewportHeight
396
+ * @param {number} shiftPadding
397
+ * @param {{top:number,right:number,bottom:number,left:number}} padding
398
+ * @returns {EvaluatedPlacement}
399
+ */
400
+ function evaluatePlacement(
401
+ placement,
402
+ anchor,
403
+ floating,
404
+ sideOffset,
405
+ alignOffset,
406
+ direction,
407
+ viewportWidth,
408
+ viewportHeight,
409
+ shiftPadding,
410
+ padding,
411
+ ) {
412
+ let coordinates = coordinatesFor(
413
+ placement.side,
414
+ placement.align,
415
+ anchor,
416
+ floating,
417
+ sideOffset,
418
+ alignOffset,
419
+ direction,
420
+ )
421
+ // Base UIはcenterだけshiftをflipより先に適用し、shift後のoverflowで候補を選ぶ。
422
+ if (placement.align === "center") {
423
+ coordinates = shiftAlignAxis(
424
+ coordinates,
425
+ placement.side,
426
+ floating,
427
+ viewportWidth,
428
+ viewportHeight,
429
+ shiftPadding,
430
+ )
431
+ }
432
+ const overflow = {
433
+ bottom: coordinates.y + floating.height - (viewportHeight - padding.bottom),
434
+ left: padding.left - coordinates.x,
435
+ right: coordinates.x + floating.width - (viewportWidth - padding.right),
436
+ top: padding.top - coordinates.y,
437
+ }
438
+ const [firstCrossSide, secondCrossSide] = alignmentSides(placement, anchor, floating, direction)
439
+ return {
440
+ ...placement,
441
+ coordinates,
442
+ overflows: [overflow[placement.side], overflow[firstCrossSide], overflow[secondCrossSide]],
443
+ }
444
+ }
445
+
446
+ /**
447
+ * @param {{x:number,y:number}} coordinates
448
+ * @param {PhysicalSide} side
449
+ * @param {FloatingSize} floating
450
+ * @param {number} viewportWidth
451
+ * @param {number} viewportHeight
452
+ * @param {number} padding
453
+ */
454
+ function shiftAlignAxis(coordinates, side, floating, viewportWidth, viewportHeight, padding) {
455
+ const maxX = Math.max(padding, viewportWidth - padding - floating.width)
456
+ const maxY = Math.max(padding, viewportHeight - padding - floating.height)
457
+ return isVerticalSide(side)
458
+ ? { x: clamp(coordinates.x, padding, maxX), y: coordinates.y }
459
+ : { x: coordinates.x, y: clamp(coordinates.y, padding, maxY) }
460
+ }
461
+
462
+ /**
463
+ * @param {EvaluatedPlacement[]} candidates
464
+ * @param {"x" | "y"} initialAxis
465
+ * @returns {EvaluatedPlacement}
466
+ */
467
+ function selectPlacement(candidates, initialAxis) {
468
+ const fullyVisible = candidates.find((candidate) => candidate.overflows.every((overflow) => overflow <= 0))
469
+ if (fullyVisible) return fullyVisible
470
+
471
+ const mainAxisFit = candidates
472
+ .filter((candidate) => candidate.overflows[0] <= 0)
473
+ .sort((first, second) => first.overflows[1] - second.overflows[1])[0]
474
+ if (mainAxisFit) return mainAxisFit
475
+
476
+ const preferred = candidates.filter((candidate) => {
477
+ const currentAxis = sideAxis(candidate.side)
478
+ return currentAxis === initialAxis || currentAxis === "y"
479
+ })
480
+ const bestFitCandidates = preferred.length > 0 ? preferred : candidates
481
+ return bestFitCandidates
482
+ .map((candidate) => ({
483
+ candidate,
484
+ score: candidate.overflows
485
+ .filter((overflow) => overflow > 0)
486
+ .reduce((total, overflow) => total + overflow, 0),
487
+ }))
488
+ .sort((first, second) => first.score - second.score)[0].candidate
489
+ }
490
+
491
+ /**
492
+ * @param {Placement} placement
493
+ * @param {DOMRect | DOMRectReadOnly} anchor
494
+ * @param {FloatingSize} floating
495
+ * @param {Direction} direction
496
+ * @returns {[PhysicalSide, PhysicalSide]}
497
+ */
498
+ function alignmentSides(placement, anchor, floating, direction) {
499
+ /** @type {PhysicalSide} */
500
+ let first
501
+ if (isVerticalSide(placement.side)) {
502
+ first = placement.align === (direction === "rtl" ? "end" : "start") ? "right" : "left"
503
+ if (anchor.width > floating.width) first = OPPOSITE_SIDE[first]
504
+ } else {
505
+ first = placement.align === "start" ? "bottom" : "top"
506
+ if (anchor.height > floating.height) first = OPPOSITE_SIDE[first]
507
+ }
508
+ return [first, OPPOSITE_SIDE[first]]
509
+ }
510
+
511
+ /** @param {Side} requestedSide @param {number} padding */
512
+ function biasedCollisionPadding(requestedSide, padding) {
513
+ const result = { bottom: padding, left: padding, right: padding, top: padding }
514
+ if (requestedSide === "top") result.bottom += 1
515
+ if (requestedSide === "right") result.left += 1
516
+ if (requestedSide === "bottom") result.top += 1
517
+ if (requestedSide === "left") result.right += 1
518
+ return result
519
+ }
520
+
521
+ /** @param {string} side @param {DOMRect | DOMRectReadOnly} anchor @param {number} width @param {number} height @param {number} padding @param {number} offset */
522
+ function availableSize(side, anchor, width, height, padding, offset) {
523
+ if (side === "top") return { height: Math.max(0, anchor.top - padding - offset), width: Math.max(0, width - padding * 2) }
524
+ if (side === "bottom") return { height: Math.max(0, height - padding - anchor.bottom - offset), width: Math.max(0, width - padding * 2) }
525
+ if (side === "left") return { height: Math.max(0, height - padding * 2), width: Math.max(0, anchor.left - padding - offset) }
526
+ return { height: Math.max(0, height - padding * 2), width: Math.max(0, width - padding - anchor.right - offset) }
527
+ }
528
+
529
+ /**
530
+ * Base UIのarrowless transform-origin。start/endがcross軸でshiftされていなければ
531
+ * aligned edge、centerまたはviewport内へshiftされた場合はanchor中心を使う。
532
+ * side軸はanchorまでのoffsetを要素外の原点として含める。
533
+ *
534
+ * @param {PhysicalSide} side
535
+ * @param {Align} align
536
+ * @param {number} sideOffset
537
+ * @param {Direction} direction
538
+ * @param {DOMRect | DOMRectReadOnly} anchor
539
+ * @param {FloatingSize} floating
540
+ * @param {{x:number,y:number}} unshifted
541
+ * @param {number} x
542
+ * @param {number} y
543
+ */
544
+ function transformOrigin(side, align, sideOffset, direction, anchor, floating, unshifted, x, y) {
545
+ const verticalSide = isVerticalSide(side)
546
+ const crossShift = verticalSide ? x - unshifted.x : y - unshifted.y
547
+ let crossOrigin
548
+ if (align !== "center" && Math.abs(crossShift) <= 1) {
549
+ crossOrigin = alignedOrigin(align, verticalSide ? direction : "ltr")
550
+ } else if (verticalSide) {
551
+ crossOrigin = `${formatNumber(clamp(anchor.left + anchor.width / 2 - x, 0, floating.width))}px`
552
+ } else {
553
+ crossOrigin = `${formatNumber(clamp(anchor.top + anchor.height / 2 - y, 0, floating.height))}px`
554
+ }
555
+
556
+ const sideOrigin = sideAxisOrigin(side, sideOffset)
557
+ return verticalSide ? `${crossOrigin} ${sideOrigin}` : `${sideOrigin} ${crossOrigin}`
558
+ }
559
+
560
+ /** @param {Align} align @param {Direction} direction */
561
+ function alignedOrigin(align, direction) {
562
+ if (align === "center") return "50%"
563
+ if (align === "start") return direction === "rtl" ? "100%" : "0%"
564
+ return direction === "rtl" ? "0%" : "100%"
565
+ }
566
+
567
+ /** @param {PhysicalSide} side @param {number} sideOffset */
568
+ function sideAxisOrigin(side, sideOffset) {
569
+ if (side === "top" || side === "left") {
570
+ const operator = sideOffset < 0 ? "-" : "+"
571
+ return `calc(100% ${operator} ${formatNumber(Math.abs(sideOffset))}px)`
572
+ }
573
+ return `${formatNumber(-sideOffset)}px`
574
+ }
575
+
576
+ /** @param {Side} side @param {Direction} direction @returns {PhysicalSide} */
577
+ function physicalSide(side, direction) {
578
+ if (side === "inline-start") return direction === "rtl" ? "right" : "left"
579
+ if (side === "inline-end") return direction === "rtl" ? "left" : "right"
580
+ return side
581
+ }
582
+
583
+ /** @param {Side} requested @param {PhysicalSide} physical @param {Direction} direction @returns {Side} */
584
+ function outputSide(requested, physical, direction) {
585
+ if (requested !== "inline-start" && requested !== "inline-end") return physical
586
+ if (physical === physicalSide("inline-start", direction)) return "inline-start"
587
+ if (physical === physicalSide("inline-end", direction)) return "inline-end"
588
+ return physical
589
+ }
590
+
591
+ /**
592
+ * 希望軸と反対側のどちらにも収まらない場合に最初に試す直交辺。
593
+ * Base UIの既定fallback axis side="end"と同じ論理方向を物理辺へ解決する。
594
+ *
595
+ * @param {PhysicalSide} side
596
+ * @param {Direction} direction
597
+ * @returns {PhysicalSide}
598
+ */
599
+ function fallbackEndSide(side, direction) {
600
+ if (isVerticalSide(side)) return direction === "rtl" ? "left" : "right"
601
+ return "bottom"
602
+ }
603
+
604
+ /** @param {PhysicalSide} side @returns {"x" | "y"} */
605
+ function sideAxis(side) {
606
+ return isVerticalSide(side) ? "y" : "x"
607
+ }
608
+
609
+ /** @param {Anchor} anchor @param {HTMLElement} floating @param {Window} ownerWindow @returns {Direction} */
610
+ function directionOf(anchor, floating, ownerWindow) {
611
+ const context = anchor instanceof Element ? anchor : (anchor.contextElement ?? floating)
612
+ const direction = ownerWindow.getComputedStyle(context).direction || context.closest("[dir]")?.getAttribute("dir") || "ltr"
613
+ return direction === "rtl" ? "rtl" : "ltr"
614
+ }
615
+
616
+ /** @param {unknown} value @returns {Side} */
617
+ function parseSide(value) {
618
+ if (typeof value !== "string" || !SIDES.has(value)) throw new TypeError(`invalid floating side: ${String(value)}`)
619
+ return /** @type {Side} */ (value)
620
+ }
621
+
622
+ /** @param {unknown} value @returns {Align} */
623
+ function parseAlign(value) {
624
+ if (typeof value !== "string" || !ALIGNS.has(value)) throw new TypeError(`invalid floating align: ${String(value)}`)
625
+ return /** @type {Align} */ (value)
626
+ }
627
+
628
+ /** @param {unknown} value @returns {Direction} */
629
+ function parseDirection(value) {
630
+ if (value !== "ltr" && value !== "rtl") throw new TypeError(`invalid floating direction: ${String(value)}`)
631
+ return value
632
+ }
633
+
634
+ /** @param {unknown} value @param {string} name @param {number} [minimum] */
635
+ function parseNumber(value, name, minimum = -Infinity) {
636
+ const number = typeof value === "number" ? value : Number(value)
637
+ if (!Number.isFinite(number) || number < minimum) throw new TypeError(`invalid floating ${name}: ${String(value)}`)
638
+ return number
639
+ }
640
+
641
+ /** @param {number} value @param {number} minimum @param {number} maximum */
642
+ function clamp(value, minimum, maximum) {
643
+ return Math.min(Math.max(value, minimum), maximum)
644
+ }
645
+
646
+ /** @param {string} side */
647
+ function isVerticalSide(side) {
648
+ return side === "top" || side === "bottom"
649
+ }
650
+
651
+ /** @param {number} value */
652
+ function formatNumber(value) {
653
+ return String(Math.round(value * 1000) / 1000)
654
+ }
655
+
656
+ /** @param {DOMRect | DOMRectReadOnly} before @param {DOMRect | DOMRectReadOnly} after */
657
+ function rectChanged(before, after) {
658
+ return before.left !== after.left || before.top !== after.top ||
659
+ before.width !== after.width || before.height !== after.height
660
+ }