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,88 @@
1
+ const GENERATED_ID_ATTRIBUTE = "data-shadcn-generated-id"
2
+ const GENERATED_ROOT_ID_ATTRIBUTE = "data-shadcn-generated-root-id"
3
+
4
+ /** @param {string} id @param {Element} element */
5
+ function idBelongsToAnotherElement(id, element) {
6
+ return [...document.querySelectorAll("[id]")].some((candidate) => candidate !== element && candidate.id === id)
7
+ }
8
+
9
+ /** @param {string} base @param {Element} element */
10
+ function uniqueId(base, element) {
11
+ let candidate = base
12
+ let suffix = 2
13
+ while (idBelongsToAnotherElement(candidate, element)) candidate = `${base}-${suffix++}`
14
+ return candidate
15
+ }
16
+
17
+ function randomSuffix() {
18
+ if (typeof window.crypto?.randomUUID === "function") return window.crypto.randomUUID()
19
+
20
+ const values = new Uint32Array(4)
21
+ window.crypto.getRandomValues(values)
22
+ return [...values].map((value) => value.toString(16).padStart(8, "0")).join("")
23
+ }
24
+
25
+ /**
26
+ * SSRで生成したroot IDを返す。fragment cache由来の同一IDがdocument内で
27
+ * 重複した場合だけ、自動生成IDを現在のroot専用に差し替える。
28
+ * 利用者指定IDは重複していても変更しない。
29
+ *
30
+ * @param {HTMLElement} root
31
+ * @param {string} prefix
32
+ */
33
+ export function ensureRootId(root, prefix) {
34
+ const generated = root.getAttribute(GENERATED_ROOT_ID_ATTRIBUTE) === "true"
35
+ if (!root.id || (generated && idBelongsToAnotherElement(root.id, root))) {
36
+ root.id = uniqueId(`shadcn-${prefix}-${randomSuffix()}`, root)
37
+ root.setAttribute(GENERATED_ROOT_ID_ATTRIBUTE, "true")
38
+ }
39
+ return root.id
40
+ }
41
+
42
+ /**
43
+ * 利用者指定IDを保ち、IDが無い要素だけroot由来の候補で補う。
44
+ * 自動生成済みの要素はroot再採番時に追従させる。
45
+ *
46
+ * @param {HTMLElement} element
47
+ * @param {string} candidate
48
+ */
49
+ export function ensureId(element, candidate) {
50
+ const generated = element.getAttribute(GENERATED_ID_ATTRIBUTE) === "true"
51
+ if (element.id && !generated) return element.id
52
+
53
+ element.id = uniqueId(candidate, element)
54
+ element.setAttribute(GENERATED_ID_ATTRIBUTE, "true")
55
+ return element.id
56
+ }
57
+
58
+ /**
59
+ * 同名Stimulus controllerの入れ子を親controllerの探索結果から除外する。
60
+ *
61
+ * @param {HTMLElement} root
62
+ * @param {string} selector
63
+ * @param {string} identifier
64
+ * @returns {HTMLElement[]}
65
+ */
66
+ export function ownedElements(root, selector, identifier) {
67
+ return /** @type {HTMLElement[]} */ (
68
+ [...root.querySelectorAll(selector)].filter(
69
+ (element) => element.closest(`[data-controller~='${identifier}']`) === root,
70
+ )
71
+ )
72
+ }
73
+
74
+ /**
75
+ * 利用者指定属性を保ち、未指定または以前このhelperが補った属性だけを更新する。
76
+ * root IDがキャッシュ重複対策で変わった場合も、生成した参照だけが追従する。
77
+ *
78
+ * @param {Element} element
79
+ * @param {string} name
80
+ * @param {string} value
81
+ */
82
+ export function setDefaultAttribute(element, name, value) {
83
+ const marker = `data-shadcn-generated-${name}`
84
+ if (element.hasAttribute(name) && element.getAttribute(marker) !== "true") return
85
+
86
+ element.setAttribute(name, value)
87
+ element.setAttribute(marker, "true")
88
+ }
@@ -0,0 +1,241 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import {
4
+ ensureId,
5
+ ensureRootId,
6
+ setDefaultAttribute,
7
+ } from "@supermomonga/shadcn-view-components/aria_relationships"
8
+ import { hideAfterExit } from "@supermomonga/shadcn-view-components/hide_after_exit"
9
+
10
+ const IDENTIFIER = "shadcn--accordion"
11
+ const ITEM_SELECTOR = "details[data-slot='accordion-item']"
12
+ const TRIGGER_SELECTOR = ":scope > summary[data-slot='accordion-trigger']"
13
+ const CONTENT_SELECTOR = ":scope > [data-slot='accordion-content']"
14
+
15
+ // ネイティブ <details> の開閉を契約クラスのアニメーションと同期する。
16
+ // 契約の accordion-content は data-[state=open]:animate-accordion-down /
17
+ // data-[state=closed]:animate-accordion-up(tw-animate-css がキーフレームを提供、
18
+ // 高さは --radix-accordion-content-height を参照)を持つため:
19
+ //
20
+ // - summary クリックは既定の即時開閉を preventDefault で止める
21
+ // - 開く: 高さ変数を設定してから details を開き data-state=open を同期する
22
+ // (アニメーションが高さ0 → 測定高さで走る)
23
+ // - 閉じる: data-state=closed を先に切り替え、accordion-up の終了を待ってから
24
+ // details を閉じる(即時閉じるとコンテンツが一瞬で消えるため)
25
+ // - 排他(name 属性グループ)は開く際に他の開いている Item を閉じる
26
+ // - reduced-motion 設定時とJS無効時はアニメーション無しの即時開閉になる
27
+ /** @extends {Controller<HTMLElement>} */
28
+ export default class AccordionController extends Controller {
29
+ /** @type {boolean} */
30
+ active = false
31
+
32
+ /** @type {Map<HTMLDetailsElement, () => void>} */
33
+ pendingCloses = new Map()
34
+
35
+ /** @type {(event: MouseEvent) => void} */
36
+ onClick = (event) => this.handleClick(event)
37
+
38
+ /** @type {(event: Event) => void} */
39
+ onToggle = (event) => this.handleToggle(event)
40
+
41
+ connect() {
42
+ // Item にもcontrollerを付与するが、Accordion ルート内では親の一元管理に委ねる。
43
+ // これによりイベントの二重処理を避けつつ、standalone Itemも同じ振る舞いを持つ。
44
+ this.active = !this.root.matches(ITEM_SELECTOR) || this.managerRootFor(this.root) === null
45
+ if (!this.active) return
46
+
47
+ ensureRootId(this.root, "accordion")
48
+ this.element.addEventListener("click", this.onClick, true)
49
+ // toggle はrootのcapture listenerで受ける。非bubbling eventでもcapture phaseは
50
+ // ancestorを通るため、接続後に追加されたitemのプログラマティックな開閉にも追従できる。
51
+ this.element.addEventListener("toggle", this.onToggle, true)
52
+ for (const [index, item] of this.items().entries()) {
53
+ this.ensureRelationships(item, index)
54
+ this.syncState(item, item.open ? "open" : "closed")
55
+ }
56
+ }
57
+
58
+ disconnect() {
59
+ if (!this.active) return
60
+
61
+ this.element.removeEventListener("click", this.onClick, true)
62
+ this.element.removeEventListener("toggle", this.onToggle, true)
63
+ // Turbo cache等で退出アニメーション中に切断されても、listener/timerを残さず
64
+ // ユーザーが確定させた閉状態をDOMへ反映してから破棄する。
65
+ for (const [item, cancel] of this.pendingCloses) {
66
+ cancel()
67
+ item.open = false
68
+ item.style.removeProperty("--radix-accordion-content-height")
69
+ this.syncState(item, "closed")
70
+ }
71
+ this.pendingCloses.clear()
72
+ this.active = false
73
+ }
74
+
75
+ // キャプチャフェーズで一元処理する(data-action との二重発火防止 — 他コントローラと同じ)
76
+ /** @param {MouseEvent} event */
77
+ handleClick(event) {
78
+ const summary = event.target instanceof Element ? event.target.closest("summary[data-slot='accordion-trigger']") : null
79
+ if (!summary || !this.element.contains(summary)) return
80
+
81
+ const item = /** @type {HTMLDetailsElement | null} */ (
82
+ summary.closest("details[data-slot='accordion-item']")
83
+ )
84
+ if (!item || !this.items().includes(item)) return
85
+
86
+ event.preventDefault()
87
+ this.ensureRelationships(item, this.items().indexOf(item))
88
+ if (item.open) {
89
+ this.close(item)
90
+ } else {
91
+ for (const other of this.items()) {
92
+ if (other !== item && other.open && this.sameGroup(other, item)) this.close(other)
93
+ }
94
+ this.open(item)
95
+ }
96
+ }
97
+
98
+ /** @param {Event} event */
99
+ handleToggle(event) {
100
+ if (!(event.target instanceof Element)) return
101
+ if (!event.target.matches(ITEM_SELECTOR) || !this.element.contains(event.target)) return
102
+
103
+ const item = /** @type {HTMLDetailsElement} */ (event.target)
104
+ const index = this.items().indexOf(item)
105
+ if (index < 0) return
106
+
107
+ this.ensureRelationships(item, index)
108
+ if (item.open) this.cancelClose(item)
109
+ this.syncState(item, item.open ? "open" : "closed")
110
+ }
111
+
112
+ /** @param {HTMLDetailsElement} item */
113
+ open(item) {
114
+ this.cancelClose(item)
115
+ const content = this.contentOf(item)
116
+ if (content && !this.reducedMotion) {
117
+ item.style.setProperty("--radix-accordion-content-height", `${content.scrollHeight}px`)
118
+ }
119
+ item.open = true
120
+ this.syncState(item, "open")
121
+ }
122
+
123
+ /** @param {HTMLDetailsElement} item */
124
+ close(item) {
125
+ this.cancelClose(item)
126
+ const content = this.contentOf(item)
127
+ if (!content || this.reducedMotion) {
128
+ item.open = false
129
+ item.style.removeProperty("--radix-accordion-content-height")
130
+ this.syncState(item, "closed")
131
+ return
132
+ }
133
+
134
+ this.syncState(item, "closed")
135
+ const cancel = hideAfterExit(content, () => {
136
+ this.pendingCloses.delete(item)
137
+ item.open = false
138
+ item.style.removeProperty("--radix-accordion-content-height")
139
+ }, "accordion-up")
140
+ this.pendingCloses.set(item, cancel)
141
+ }
142
+
143
+ /** @param {HTMLDetailsElement} item */
144
+ cancelClose(item) {
145
+ const cancel = this.pendingCloses.get(item)
146
+ if (!cancel) return
147
+ cancel()
148
+ this.pendingCloses.delete(item)
149
+ }
150
+
151
+ // base-nova の契約クラスは data-open / data-closed(属性の存在)を参照する
152
+ // (data-open:animate-accordion-down 等)。Trigger には aria-expanded も同期する
153
+ // (アイコン切替は group-aria-expanded で行う)
154
+ /**
155
+ * @param {HTMLDetailsElement} item
156
+ * @param {"open" | "closed"} state
157
+ */
158
+ syncState(item, state) {
159
+ item.toggleAttribute("data-open", state === "open")
160
+ item.toggleAttribute("data-closed", state !== "open")
161
+ const trigger = item.querySelector(TRIGGER_SELECTOR)
162
+ if (trigger) trigger.setAttribute("aria-expanded", String(state === "open"))
163
+ const content = this.contentOf(item)
164
+ if (content) {
165
+ content.toggleAttribute("data-open", state === "open")
166
+ content.toggleAttribute("data-closed", state !== "open")
167
+ }
168
+ }
169
+
170
+ /** @returns {HTMLDetailsElement[]} */
171
+ items() {
172
+ if (this.root.matches(ITEM_SELECTOR)) return [/** @type {HTMLDetailsElement} */ (this.root)]
173
+
174
+ return /** @type {HTMLDetailsElement[]} */ (
175
+ Array.from(this.element.querySelectorAll(ITEM_SELECTOR))
176
+ .filter((item) => this.managerRootFor(item) === this.root)
177
+ )
178
+ }
179
+
180
+ /**
181
+ * @param {HTMLDetailsElement} item
182
+ * @returns {HTMLElement | null}
183
+ */
184
+ contentOf(item) {
185
+ return /** @type {HTMLElement | null} */ (
186
+ item.querySelector(CONTENT_SELECTOR)
187
+ )
188
+ }
189
+
190
+ /**
191
+ * @param {HTMLDetailsElement} item
192
+ * @param {number} index
193
+ */
194
+ ensureRelationships(item, index) {
195
+ ensureRootId(this.root, "accordion")
196
+ const itemId = ensureRootId(item, `accordion-item-${index + 1}`)
197
+ const trigger = /** @type {HTMLElement | null} */ (item.querySelector(TRIGGER_SELECTOR))
198
+ const content = this.contentOf(item)
199
+ if (!trigger || !content) return
200
+
201
+ const triggerId = ensureId(trigger, `${itemId}-trigger`)
202
+ const contentId = ensureId(content, `${itemId}-content`)
203
+ setDefaultAttribute(trigger, "aria-controls", contentId)
204
+ if (!content.hasAttribute("aria-label")) setDefaultAttribute(content, "aria-labelledby", triggerId)
205
+ setDefaultAttribute(content, "role", "region")
206
+ }
207
+
208
+ /**
209
+ * Itemを管理する最も近いAccordionルートを返す。Item自身のcontrollerは飛ばす。
210
+ * @param {Element} item
211
+ * @returns {HTMLElement | null}
212
+ */
213
+ managerRootFor(item) {
214
+ let candidate = /** @type {HTMLElement | null} */ (
215
+ item.parentElement?.closest(`[data-controller~='${IDENTIFIER}']`) || null
216
+ )
217
+ while (candidate?.matches(ITEM_SELECTOR)) {
218
+ candidate = /** @type {HTMLElement | null} */ (
219
+ candidate.parentElement?.closest(`[data-controller~='${IDENTIFIER}']`) || null
220
+ )
221
+ }
222
+ return candidate
223
+ }
224
+
225
+ /**
226
+ * @param {HTMLDetailsElement} a
227
+ * @param {HTMLDetailsElement} b
228
+ */
229
+ sameGroup(a, b) {
230
+ return a.getAttribute("name") !== null && a.getAttribute("name") === b.getAttribute("name")
231
+ }
232
+
233
+ get reducedMotion() {
234
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches
235
+ }
236
+
237
+ /** @returns {HTMLElement} */
238
+ get root() {
239
+ return /** @type {HTMLElement} */ (this.element)
240
+ }
241
+ }
@@ -0,0 +1,98 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ import {
4
+ ensureId,
5
+ ensureRootId,
6
+ ownedElements,
7
+ } from "@supermomonga/shadcn-view-components/aria_relationships"
8
+
9
+ const CONTROLLER = "shadcn--calendar"
10
+ const DAY_SELECTOR = "[data-slot='calendar-day-button']"
11
+
12
+ // サーバ描画済みの同一月grid内でroving tabindexを管理する。
13
+ // 月をまたぐ移動はURL/I18n/選択APIと一体で設計する必要があるため、このcontrollerは
14
+ // 現在表示されている42日分の範囲を越えてフォーカスを移動しない。
15
+ /** @extends {Controller<HTMLElement>} */
16
+ export default class CalendarController extends Controller {
17
+ connect() {
18
+ this.ensureRelationships()
19
+ this.normalizeTabStop()
20
+ }
21
+
22
+ /** @param {KeyboardEvent} event */
23
+ navigate(event) {
24
+ const target = event.target instanceof Element ? event.target.closest(DAY_SELECTOR) : null
25
+ if (!(target instanceof HTMLElement) || !this.days.includes(target)) return
26
+
27
+ const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]
28
+ if (!keys.includes(event.key)) return
29
+ event.preventDefault()
30
+
31
+ let destination
32
+ if (event.key === "Home" || event.key === "End") {
33
+ const row = target.closest("tr")
34
+ const rowDays = row ? this.days.filter((day) => row.contains(day)) : []
35
+ destination = event.key === "Home" ? rowDays[0] : rowDays.at(-1)
36
+ } else {
37
+ let offset = 0
38
+ if (event.key === "ArrowLeft") offset = -1
39
+ else if (event.key === "ArrowRight") offset = 1
40
+ else if (event.key === "ArrowUp") offset = -7
41
+ else if (event.key === "ArrowDown") offset = 7
42
+ const index = this.days.indexOf(target) + offset
43
+ destination = this.days[index]
44
+ }
45
+
46
+ if (destination) this.focusDay(destination)
47
+ }
48
+
49
+ ensureRelationships() {
50
+ const rootId = ensureRootId(this.element, "calendar")
51
+ const caption = this.caption
52
+ const grid = this.grid
53
+ if (!caption || !grid) return
54
+
55
+ const previousCaptionId = caption.id
56
+ ensureId(caption, `${rootId}-caption`)
57
+ ensureId(grid, `${rootId}-grid`)
58
+ if (!grid.hasAttribute("aria-labelledby") || grid.getAttribute("aria-labelledby") === previousCaptionId) {
59
+ grid.setAttribute("aria-labelledby", caption.id)
60
+ }
61
+ }
62
+
63
+ normalizeTabStop() {
64
+ const days = this.days
65
+ if (days.length === 0) return
66
+
67
+ const current = days.filter((day) => day.tabIndex === 0)
68
+ const selected = days.find((day) => day.closest("[role='gridcell']")?.getAttribute("aria-selected") === "true")
69
+ const today = days.find((day) => day.getAttribute("aria-current") === "date")
70
+ this.setTabStop(current.length === 1 ? current[0] : selected || today || days[0])
71
+ }
72
+
73
+ /** @param {HTMLElement} day */
74
+ focusDay(day) {
75
+ this.setTabStop(day)
76
+ day.focus()
77
+ }
78
+
79
+ /** @param {HTMLElement} active */
80
+ setTabStop(active) {
81
+ for (const day of this.days) day.tabIndex = day === active ? 0 : -1
82
+ }
83
+
84
+ /** @returns {HTMLElement[]} */
85
+ get days() {
86
+ return ownedElements(this.element, DAY_SELECTOR, CONTROLLER)
87
+ }
88
+
89
+ /** @returns {HTMLElement | null} */
90
+ get caption() {
91
+ return ownedElements(this.element, "[data-slot='calendar-caption']", CONTROLLER)[0] ?? null
92
+ }
93
+
94
+ /** @returns {HTMLElement | null} */
95
+ get grid() {
96
+ return ownedElements(this.element, "[data-slot='calendar-grid']", CONTROLLER)[0] ?? null
97
+ }
98
+ }