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,552 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Shadcn
5
+ module ComboboxValueNormalization
6
+ extend T::Sig
7
+
8
+ private
9
+
10
+ sig { params(value: T.untyped, attribute: String).returns(String) }
11
+ def normalize_form_value(value, attribute:)
12
+ valid = value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer) || value.is_a?(Float)
13
+ Kernel.raise ArgumentError, "#{attribute} must be a String, Symbol, Integer, or Float" unless valid
14
+
15
+ value.to_s
16
+ end
17
+ end
18
+ private_constant :ComboboxValueNormalization
19
+
20
+ # 検索表示と確定したフォーム値を分離するコンボボックス。
21
+ # upstream と同じ input-group の見た目を、Input + Popover API リストで構成する。
22
+ # JS無効時フォールバック: JS必須(項目はSSR済みで読める。絞り込みはJS依存)
23
+ class Combobox < BaseComponent
24
+ CONTROLLER = "shadcn--combobox"
25
+ FormValue = T.type_alias { T.any(String, Symbol, Integer, Float) }
26
+ DefaultValue = T.type_alias { T.nilable(T.any(FormValue, T::Array[T.nilable(FormValue)])) }
27
+ FORM_OWNED_ATTRIBUTES = %i[name form required disabled value multiple].freeze
28
+ VISUALLY_HIDDEN_CONTROL_STYLE = "clip-path: inset(50%); overflow: hidden; white-space: nowrap; border: 0; " \
29
+ "padding: 0; width: 1px; height: 1px; margin: -1px; position: absolute"
30
+
31
+ include ComboboxValueNormalization
32
+
33
+ private_constant :FormValue, :DefaultValue, :FORM_OWNED_ATTRIBUTES, :VISUALLY_HIDDEN_CONTROL_STYLE
34
+
35
+ # ネイティブフォームと同じ公開keywordを列挙し、一つのHashへ隠さない。
36
+ sig do
37
+ params(
38
+ name: T.nilable(T.any(String, Symbol)),
39
+ default_value: DefaultValue,
40
+ multiple: T::Boolean,
41
+ disabled: T::Boolean,
42
+ required: T::Boolean,
43
+ form: T.nilable(T.any(String, Symbol)),
44
+ args: T::Hash[Symbol, T.untyped]
45
+ ).void.checked(:never)
46
+ end
47
+ def initialize(name: nil, default_value: nil, multiple: false, disabled: false, required: false, form: nil, **args) # rubocop:disable Metrics/ParameterLists
48
+ assign_accessibility_root_id(args, prefix: "combobox")
49
+ @name = T.let(name&.to_s, T.nilable(String))
50
+ @multiple = T.let(multiple, T::Boolean)
51
+ @disabled = T.let(disabled, T::Boolean)
52
+ @required = T.let(required, T::Boolean)
53
+ @form = T.let(form&.to_s, T.nilable(String))
54
+ @default_values = T.let(normalize_default_values(default_value), T::Array[String])
55
+ @root_id = T.let(args[:id].to_s, String)
56
+ super(**args)
57
+ end
58
+
59
+ # upstream の Combobox ルートは描画物を持たないprimitive aliasで、生成契約も
60
+ # その空の契約面を記録する。本gemでは入力とリストを束ねるラッパーとして描く。
61
+ sig { override.returns(String) }
62
+ def call
63
+ content_tag(:div, **root_attributes) { safe_join([content, form_control, empty_multiple_control]) }
64
+ end
65
+
66
+ private
67
+
68
+ sig { returns(T::Hash[Symbol, T.untyped]) }
69
+ def root_attributes
70
+ attributes = @html_args.merge(class: @user_class, data: root_data_attributes)
71
+ add_disabled_root_attributes(attributes) if @disabled
72
+ attributes
73
+ end
74
+
75
+ sig { returns(T::Hash[Symbol, T.untyped]) }
76
+ def root_data_attributes
77
+ data = T.cast(@html_args[:data], T.nilable(T::Hash[Symbol, T.untyped])) || {}
78
+ { controller: CONTROLLER }.merge(data)
79
+ end
80
+
81
+ sig { params(attributes: T::Hash[Symbol, T.untyped]).void }
82
+ def add_disabled_root_attributes(attributes)
83
+ attributes[:inert] = true unless attributes.key?(:inert)
84
+ aria = T.cast(attributes[:aria], T.nilable(T::Hash[Symbol, T.untyped])) || {}
85
+ attributes[:aria] = { disabled: "true" }.merge(aria)
86
+ end
87
+
88
+ sig { params(default_value: DefaultValue).returns(T::Array[String]).checked(:never) }
89
+ def normalize_default_values(default_value)
90
+ values = @multiple ? multiple_default_values(default_value) : single_default_values(default_value)
91
+ values.filter_map do |value|
92
+ next if value.nil?
93
+
94
+ normalized = normalize_form_value(value, attribute: "default_value")
95
+ normalized unless normalized.strip.empty?
96
+ end.uniq
97
+ end
98
+
99
+ sig { params(default_value: DefaultValue).returns(T::Array[T.nilable(FormValue)]).checked(:never) }
100
+ def multiple_default_values(default_value)
101
+ return [] if default_value.nil?
102
+
103
+ raise ArgumentError, "default_value must be an Array when multiple is true" unless default_value.is_a?(Array)
104
+
105
+ default_value
106
+ end
107
+
108
+ sig { params(default_value: DefaultValue).returns(T::Array[T.nilable(FormValue)]).checked(:never) }
109
+ def single_default_values(default_value)
110
+ raise ArgumentError, "default_value must be a scalar when multiple is false" if default_value.is_a?(Array)
111
+
112
+ default_value.nil? ? [] : [default_value]
113
+ end
114
+
115
+ sig { returns(T::Hash[Symbol, T.untyped]) }
116
+ def form_owned_attributes
117
+ attributes = T.let({}, T::Hash[Symbol, T.untyped])
118
+ attributes[:name] = @name if @name
119
+ attributes[:form] = @form if @form
120
+ attributes
121
+ end
122
+
123
+ sig { returns(String) }
124
+ def empty_multiple_control
125
+ return "" unless @multiple
126
+
127
+ attributes = form_owned_attributes.merge(
128
+ type: "hidden",
129
+ value: "",
130
+ disabled: @disabled || @default_values.any?,
131
+ data: { slot: "combobox-empty-form-control" }
132
+ )
133
+ void_tag("input", **attributes)
134
+ end
135
+
136
+ sig { returns(String) }
137
+ def form_control
138
+ attributes = form_owned_attributes.merge(
139
+ multiple: @multiple,
140
+ disabled: @disabled,
141
+ required: @required,
142
+ tabindex: "-1",
143
+ style: VISUALLY_HIDDEN_CONTROL_STYLE,
144
+ aria: { hidden: "true" },
145
+ data: { slot: "combobox-form-control", shadcn_generated_id: "true" }
146
+ )
147
+ attributes[:id] = "#{@root_id}-form-control"
148
+
149
+ values = !@multiple && @default_values.empty? ? [""] : @default_values
150
+ content_tag(:select, **attributes) do
151
+ safe_join(values.map { |value| content_tag(:option, value:, selected: true) { value } })
152
+ end
153
+ end
154
+
155
+ class Input < BaseComponent
156
+ # 契約スロット構成: input-group-button(トリガーアイコン)のみ。
157
+ # 検索inputは data-slot を持たない(role=combobox で識別する)
158
+ sig { override.returns(String) }
159
+ def default_tag
160
+ "div"
161
+ end
162
+
163
+ sig { params(args: T::Hash[Symbol, T.untyped]).void.checked(:never) }
164
+ def initialize(**args)
165
+ attribute = FORM_OWNED_ATTRIBUTES.find { |name| args.key?(name) }
166
+ raise ArgumentError, "#{attribute} belongs to Shadcn::Combobox, not its query input" if attribute
167
+
168
+ super
169
+ end
170
+
171
+ # upstream は <InputGroup className="w-auto"> でラップするため、
172
+ # ラッパのクラスは input-group の契約クラスを土台に組む。
173
+ # class は InputGroup ラッパー、その他のHTML属性は実際の検索inputへ渡す。
174
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
175
+ def html_attributes
176
+ {
177
+ class: ShadcnViewComponents::Classes.resolve(:input_group, extra: "w-auto #{@user_class}".strip)
178
+ }
179
+ end
180
+
181
+ sig { override.returns(String) }
182
+ def call
183
+ content_tag(tag, **html_attributes) do
184
+ safe_join([search_input, trigger_addon])
185
+ end
186
+ end
187
+
188
+ private
189
+
190
+ sig { returns(String) }
191
+ def search_input
192
+ # キーボード操作はコントローラのキャプチャリスナーで一元処理する(二重発火防止)。
193
+ # クラスは upstream と同じ構成: Input の契約クラス + input-group-control の上書き
194
+ base = ShadcnViewComponents::Classes.resolve(:input)
195
+ overlay = input_group_combination("Input", {})
196
+ attributes = @html_args.except(:tag, :class)
197
+ attributes[:type] = "search" unless attributes.key?(:type)
198
+ attributes[:class] = ShadcnViewComponents::Classes::MERGER.merge("#{base} #{overlay}")
199
+ attributes[:role] = "combobox"
200
+ merge_nested(
201
+ attributes,
202
+ :aria,
203
+ { expanded: "false", haspopup: "listbox", autocomplete: "list", label: "候補を検索" }
204
+ )
205
+ merge_nested(attributes, :data, { action: "input->#{Combobox::CONTROLLER}#filter" })
206
+ void_tag("input", **attributes)
207
+ end
208
+
209
+ # upstream の <InputGroupAddon align="inline-end">(トリガーを右端に置く)
210
+ sig { returns(String) }
211
+ def trigger_addon
212
+ inline_end = { align: :"inline-end" }
213
+ content_tag(:div, class: input_group_combination("Addon", inline_end), data: { slot: "input-group-addon" }) do
214
+ trigger_button
215
+ end
216
+ end
217
+
218
+ # 契約の input-group-button スロット。クラスは upstream と同じ構成で
219
+ # Button(ghost) + input-group-button(icon-xs) + combobox側の上書きを重ねる
220
+ sig { returns(String) }
221
+ def trigger_button
222
+ content_tag(
223
+ :button,
224
+ type: "button",
225
+ class: trigger_class,
226
+ data: { slot: "input-group-button", action: "#{Combobox::CONTROLLER}#toggleList" },
227
+ aria: { label: "選択肢を開く", haspopup: "listbox", expanded: "false" }
228
+ ) { chevron_icon }
229
+ end
230
+
231
+ sig { returns(String) }
232
+ def trigger_class
233
+ # NOTE: Hashの値型は不変のため、Classes.resolve の options と同じ型で T.let する
234
+ button_options = T.let({ variant: :ghost }, T::Hash[Symbol, T.nilable(T.any(Symbol, String))])
235
+ button_base = ShadcnViewComponents::Classes.resolve(:button, extra: nil, **button_options)
236
+ overlay = input_group_combination("Button", { size: :"icon-xs" })
237
+ tail = T.cast(contract_slot("input-group-button").dig(:static_attributes, :class), T.nilable(String)).to_s
238
+ ShadcnViewComponents::Classes::MERGER.merge([button_base, overlay, tail].join(" "))
239
+ end
240
+
241
+ # input-group 契約の各スロット組み合わせを引く
242
+ sig { params(export: String, options: T::Hash[Symbol, T.untyped]).returns(String) }
243
+ def input_group_combination(export, options = {})
244
+ contract = ShadcnViewComponents::Contracts::InputGroup.const_get(export)
245
+ defaults = T.cast(contract.const_get(:DEFAULTS), T::Hash[Symbol, Symbol])
246
+ T.cast(T.unsafe(contract).combination(defaults.merge(options)), String)
247
+ end
248
+
249
+ sig { returns(String) }
250
+ def chevron_icon
251
+ content_tag(
252
+ :svg,
253
+ xmlns: "http://www.w3.org/2000/svg",
254
+ viewBox: "0 0 24 24",
255
+ fill: "none",
256
+ stroke: "currentColor",
257
+ "stroke-width": "2",
258
+ "stroke-linecap": "round",
259
+ "stroke-linejoin": "round",
260
+ width: "16",
261
+ height: "16",
262
+ class: "size-4",
263
+ aria: { hidden: "true" }
264
+ ) do
265
+ raw(%(<path d="m6 9 6 6 6-6"/>))
266
+ end
267
+ end
268
+ end
269
+
270
+ class Trigger < BaseComponent
271
+ # 開閉アイコン(chevron)。button として描く
272
+ sig { override.returns(String) }
273
+ def default_tag
274
+ "button"
275
+ end
276
+
277
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
278
+ def html_attributes
279
+ attributes = super
280
+ attributes[:type] = "button" unless attributes.key?(:type)
281
+ merge_nested(attributes, :aria, { label: "選択肢を開く", haspopup: "listbox", expanded: "false" })
282
+ merge_nested(attributes, :data, { action: "#{Combobox::CONTROLLER}#toggleList" })
283
+ attributes
284
+ end
285
+
286
+ sig { override.returns(String) }
287
+ def call
288
+ content_tag(tag, **html_attributes) { chevron_icon }
289
+ end
290
+
291
+ private
292
+
293
+ sig { returns(String) }
294
+ def chevron_icon
295
+ content_tag(
296
+ :svg,
297
+ xmlns: "http://www.w3.org/2000/svg",
298
+ viewBox: "0 0 24 24",
299
+ fill: "none",
300
+ stroke: "currentColor",
301
+ "stroke-width": "2",
302
+ "stroke-linecap": "round",
303
+ "stroke-linejoin": "round",
304
+ width: "16",
305
+ height: "16",
306
+ class: "pointer-events-none size-4 text-muted-foreground",
307
+ aria: { hidden: "true" }
308
+ ) do
309
+ raw(%(<path d="m6 9 6 6 6-6"/>))
310
+ end
311
+ end
312
+ end
313
+
314
+ class Content < BaseComponent
315
+ # Popover API で開くリストボックス
316
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
317
+ def html_attributes
318
+ attributes = super
319
+ attributes[:role] = "listbox"
320
+ attributes[:popover] = "manual"
321
+ data = T.cast(attributes[:data], T.nilable(T::Hash[Symbol, T.untyped])) || {}
322
+ attributes[:data] = { state: "closed" }.merge(data)
323
+ attributes
324
+ end
325
+ end
326
+
327
+ class List < BaseComponent
328
+ # div(絞り込み対象の親)
329
+ end
330
+
331
+ class Item < BaseComponent
332
+ include ComboboxValueNormalization
333
+
334
+ # base-nova の combobox-item に indicator スロットは無い(チェックは装飾要素)
335
+ sig do
336
+ params(
337
+ value: FormValue,
338
+ disabled: T::Boolean,
339
+ args: T::Hash[Symbol, T.untyped]
340
+ ).void.checked(:never)
341
+ end
342
+ def initialize(value:, disabled: false, **args)
343
+ @value = T.let(normalize_form_value(value, attribute: "value"), String)
344
+ raise ArgumentError, "value must not be blank" if @value.strip.empty?
345
+
346
+ @disabled = T.let(disabled, T::Boolean)
347
+ super(**args)
348
+ end
349
+
350
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
351
+ def html_attributes
352
+ attributes = super
353
+ attributes[:role] = "option"
354
+ attributes[:tabindex] = "-1"
355
+ merge_nested(
356
+ attributes,
357
+ :aria,
358
+ { selected: "false" }.merge(@disabled ? { disabled: "true" } : {})
359
+ )
360
+ # クリックでの選択確定。キー操作はコントローラのキャプチャリスナーで
361
+ # 一元処理するため data-action にしない(二重発火防止)
362
+ merge_nested(
363
+ attributes,
364
+ :data,
365
+ {
366
+ value: @value,
367
+ selected: "false",
368
+ action: "click->#{Combobox::CONTROLLER}#select"
369
+ }.merge(@disabled ? { disabled: "" } : {})
370
+ )
371
+ attributes
372
+ end
373
+
374
+ sig { override.returns(String) }
375
+ def call
376
+ content_tag(tag, **html_attributes) do
377
+ safe_join([content, indicator])
378
+ end
379
+ end
380
+
381
+ private
382
+
383
+ sig { returns(String) }
384
+ def indicator
385
+ # 未選択時は HTML の hidden 属性で隠す(upstream の ItemIndicator と同じ挙動)。
386
+ # data-indicator は JS からの選択状態切替用(data-slot ではない)
387
+ content_tag(:span, hidden: true, data: { indicator: "true" }) do
388
+ check_icon
389
+ end
390
+ end
391
+
392
+ sig { returns(String) }
393
+ def check_icon
394
+ content_tag(
395
+ :svg,
396
+ xmlns: "http://www.w3.org/2000/svg",
397
+ viewBox: "0 0 24 24",
398
+ fill: "none",
399
+ stroke: "currentColor",
400
+ "stroke-width": "2",
401
+ "stroke-linecap": "round",
402
+ "stroke-linejoin": "round",
403
+ width: "14",
404
+ height: "14",
405
+ class: "size-4"
406
+ ) do
407
+ raw(%(<path d="M20 6 9 17l-5-5"/>))
408
+ end
409
+ end
410
+ end
411
+
412
+ class Group < BaseComponent
413
+ # div
414
+ end
415
+
416
+ class Label < BaseComponent
417
+ # div
418
+ end
419
+
420
+ class Empty < BaseComponent
421
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
422
+ def html_attributes
423
+ super.tap { |attributes| attributes[:hidden] = true }
424
+ end
425
+ end
426
+
427
+ class Separator < BaseComponent
428
+ # div
429
+ end
430
+
431
+ class Collection < BaseComponent
432
+ # 利用者が項目群を括るための汎用div
433
+ end
434
+
435
+ class Value < BaseComponent
436
+ # 選択値の表示用span
437
+ end
438
+
439
+ class Chips < BaseComponent
440
+ sig { override.returns(String) }
441
+ def call
442
+ content_tag(tag, **html_attributes) do
443
+ safe_join([content, chip_template])
444
+ end
445
+ end
446
+
447
+ private
448
+
449
+ sig { returns(String) }
450
+ def chip_template
451
+ content_tag(:template, data: { slot: "combobox-chip-template" }) do
452
+ render(Chip.new(value: "__combobox_chip_template__")) { "__combobox_chip_label__" }
453
+ end
454
+ end
455
+ end
456
+
457
+ class Chip < BaseComponent
458
+ include ComboboxValueNormalization
459
+
460
+ # チップ + 取り除きボタン(契約スロット: combobox-chip / combobox-chip-remove)
461
+ sig do
462
+ params(
463
+ value: FormValue,
464
+ args: T::Hash[Symbol, T.untyped]
465
+ ).void.checked(:never)
466
+ end
467
+ def initialize(value:, **args)
468
+ @value = T.let(normalize_form_value(value, attribute: "value"), String)
469
+ raise ArgumentError, "value must not be blank" if @value.strip.empty?
470
+
471
+ super(**args)
472
+ end
473
+
474
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
475
+ def html_attributes
476
+ super.tap { |attributes| merge_nested(attributes, :data, { value: @value }) }
477
+ end
478
+
479
+ sig { override.returns(String) }
480
+ def call
481
+ content_tag(tag, **html_attributes) do
482
+ safe_join([content_tag(:span, data: { slot: "combobox-chip-label" }) { content }, remove_button])
483
+ end
484
+ end
485
+
486
+ private
487
+
488
+ sig { returns(String) }
489
+ def remove_button
490
+ # upstream は <Button variant="ghost" size="icon-xs"> に
491
+ # combobox 側の上書き(-ml-1 opacity-50 …)を重ねる
492
+ options = T.let({ variant: :ghost, size: :"icon-xs" }, T::Hash[Symbol, T.nilable(T.any(Symbol, String))])
493
+ base = ShadcnViewComponents::Classes.resolve(:button, extra: nil, **options)
494
+ content_tag(
495
+ :button,
496
+ type: "button",
497
+ class: ShadcnViewComponents::Classes::MERGER.merge("#{base} -ml-1 opacity-50 hover:opacity-100"),
498
+ data: { slot: "combobox-chip-remove", action: "#{Combobox::CONTROLLER}#removeChip" },
499
+ aria: { label: "削除" }
500
+ ) { remove_icon }
501
+ end
502
+
503
+ sig { returns(String) }
504
+ def remove_icon
505
+ content_tag(
506
+ :svg,
507
+ xmlns: "http://www.w3.org/2000/svg",
508
+ viewBox: "0 0 24 24",
509
+ fill: "none",
510
+ stroke: "currentColor",
511
+ "stroke-width": "2",
512
+ "stroke-linecap": "round",
513
+ "stroke-linejoin": "round",
514
+ width: "12",
515
+ height: "12",
516
+ class: "size-3",
517
+ aria: { hidden: "true" }
518
+ ) { raw(%(<path d="M18 6 6 18"/><path d="m6 6 12 12"/>)) }
519
+ end
520
+ end
521
+
522
+ class ChipsInput < BaseComponent
523
+ # チップ列の末尾にある検索input
524
+ sig { override.returns(String) }
525
+ def default_tag
526
+ "input"
527
+ end
528
+
529
+ sig { params(args: T::Hash[Symbol, T.untyped]).void.checked(:never) }
530
+ def initialize(**args)
531
+ attribute = FORM_OWNED_ATTRIBUTES.find { |name| args.key?(name) }
532
+ raise ArgumentError, "#{attribute} belongs to Shadcn::Combobox, not its query input" if attribute
533
+
534
+ super
535
+ end
536
+
537
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
538
+ def html_attributes
539
+ attributes = super
540
+ attributes[:type] = "text" unless attributes.key?(:type)
541
+ attributes[:role] = "combobox"
542
+ merge_nested(
543
+ attributes,
544
+ :aria,
545
+ { expanded: "false", haspopup: "listbox", autocomplete: "list", label: "候補を検索" }
546
+ )
547
+ merge_nested(attributes, :data, { action: "input->#{Combobox::CONTROLLER}#filter" })
548
+ attributes
549
+ end
550
+ end
551
+ end
552
+ end
@@ -0,0 +1,149 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Shadcn
5
+ # コマンドパレット(10-roadmap Phase 3「command = ARIA combobox/listbox + フィルタlib」)。
6
+ # SSR済みリストを入力でフィルタする(command_controller)。情報はJSで構築しない
7
+ # JS無効時フォールバック: Readable(全項目がSSR済み)
8
+ class Command < BaseComponent
9
+ CONTROLLER = "shadcn--command"
10
+
11
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
12
+ def contract_data_attributes
13
+ super.merge(controller: CONTROLLER)
14
+ end
15
+
16
+ # upstream は Dialog + Command の合成。本gemでも同じ構成で再利用する
17
+ class Dialog < BaseComponent
18
+ sig { override.returns(String) }
19
+ def call
20
+ # 契約ルートは data-slot を持たない div(className="overflow-hidden p-0")。
21
+ # dialog/command の開閉構造はその内側に置く
22
+ content_tag(:div, class: self.class.classes(extra: @user_class)) do
23
+ render(::Shadcn::Dialog.new) do
24
+ render(::Shadcn::Dialog::Content.new) do
25
+ render(::Shadcn::Command.new) { content }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ class Input < BaseComponent
33
+ # 契約スロット構成: command-input-wrapper(div) > command-input(input)。
34
+ # upstream はさらに内側を <InputGroup>(別アイテム)でラップし、検索アイコンは
35
+ # <InputGroupAddon align="inline-start"> に置く(そのため input が先・icon が後)
36
+ sig { override.returns(String) }
37
+ def default_tag
38
+ "div"
39
+ end
40
+
41
+ sig do
42
+ params(placeholder: T.nilable(String), args: T::Hash[Symbol, T.untyped]).void.checked(:never)
43
+ end
44
+ def initialize(placeholder: nil, **args)
45
+ @placeholder = T.let(placeholder, T.nilable(String))
46
+ super(**args)
47
+ end
48
+
49
+ sig { override.returns(String) }
50
+ def call
51
+ content_tag(tag, **html_attributes) do
52
+ render(Shadcn::InputGroup.new(**group_args)) do
53
+ safe_join([input_element, addon_element])
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # upstream CommandInput の InputGroup への上書き(className)
61
+ sig { returns(T::Hash[Symbol, T.untyped]) }
62
+ def group_args
63
+ { class: "h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! " \
64
+ "*:data-[slot=input-group-addon]:pl-2!" }
65
+ end
66
+
67
+ sig { returns(String) }
68
+ def input_element
69
+ # キーボード操作はコントローラのキャプチャリスナーで一元処理する(data-action に
70
+ # keydown を置くと二重発火する — 片方のみにバインドする)
71
+ void_tag(
72
+ "input",
73
+ type: "text",
74
+ placeholder: @placeholder,
75
+ autocomplete: "off",
76
+ class: self.class.classes,
77
+ data: { slot: "command-input", action: "input->#{Command::CONTROLLER}#filter" },
78
+ aria: { label: "コマンド検索" }
79
+ )
80
+ end
81
+
82
+ sig { returns(String) }
83
+ def addon_element
84
+ render(Shadcn::InputGroup::Addon.new(align: "inline-start")) do
85
+ search_icon
86
+ end
87
+ end
88
+
89
+ sig { returns(String) }
90
+ def search_icon
91
+ content_tag(
92
+ :svg,
93
+ xmlns: "http://www.w3.org/2000/svg",
94
+ viewBox: "0 0 24 24",
95
+ fill: "none",
96
+ stroke: "currentColor",
97
+ "stroke-width": "2",
98
+ "stroke-linecap": "round",
99
+ "stroke-linejoin": "round",
100
+ class: "size-4 shrink-0 opacity-50",
101
+ aria: { hidden: "true" }
102
+ ) do
103
+ raw(%(<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>))
104
+ end
105
+ end
106
+ end
107
+
108
+ class List < BaseComponent
109
+ # div(フィルタ対象の親)
110
+ end
111
+
112
+ class Empty < BaseComponent
113
+ # フィルタ結果が空のときだけ表示(コントローラが切替)
114
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
115
+ def html_attributes
116
+ super.tap { |attributes| attributes[:hidden] = true }
117
+ end
118
+ end
119
+
120
+ class Group < BaseComponent
121
+ # div(見出しは利用者が content に含める)
122
+ end
123
+
124
+ class Item < BaseComponent
125
+ sig { params(value: T.nilable(String), args: T::Hash[Symbol, T.untyped]).void.checked(:never) }
126
+ def initialize(value: nil, **args)
127
+ @value = value
128
+ super(**args)
129
+ end
130
+
131
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
132
+ def html_attributes
133
+ attributes = super
134
+ attributes[:role] = "option"
135
+ attributes[:tabindex] = "-1"
136
+ merge_nested(attributes, :data, { value: @value, selected: "false" }.compact)
137
+ attributes
138
+ end
139
+ end
140
+
141
+ class Separator < BaseComponent
142
+ # div
143
+ end
144
+
145
+ class Shortcut < BaseComponent
146
+ # span
147
+ end
148
+ end
149
+ end