nitro_kit 0.9.0 → 2.0.0.alpha.2

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 (219) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/plugins/marketplace.json +20 -0
  3. data/CHANGELOG.md +172 -0
  4. data/LICENSE +28 -0
  5. data/README.md +22 -15
  6. data/STYLE_GUIDE.md +425 -0
  7. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
  8. data/app/assets/stylesheets/nitro_kit.css +7239 -0
  9. data/app/components/nitro_kit/accordion.rb +125 -65
  10. data/app/components/nitro_kit/alert.rb +80 -43
  11. data/app/components/nitro_kit/app_navigation.rb +307 -0
  12. data/app/components/nitro_kit/app_shell.rb +227 -0
  13. data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
  14. data/app/components/nitro_kit/appearance_picker.rb +156 -0
  15. data/app/components/nitro_kit/auth_shell.rb +34 -0
  16. data/app/components/nitro_kit/avatar.rb +85 -29
  17. data/app/components/nitro_kit/avatar_stack.rb +155 -9
  18. data/app/components/nitro_kit/badge.rb +43 -81
  19. data/app/components/nitro_kit/button.rb +180 -103
  20. data/app/components/nitro_kit/button_group.rb +63 -11
  21. data/app/components/nitro_kit/button_to.rb +69 -0
  22. data/app/components/nitro_kit/card.rb +42 -45
  23. data/app/components/nitro_kit/checkbox.rb +115 -62
  24. data/app/components/nitro_kit/checkbox_group.rb +131 -22
  25. data/app/components/nitro_kit/choice.rb +55 -0
  26. data/app/components/nitro_kit/combobox.rb +292 -105
  27. data/app/components/nitro_kit/command_palette.rb +412 -0
  28. data/app/components/nitro_kit/component.rb +322 -46
  29. data/app/components/nitro_kit/container.rb +32 -0
  30. data/app/components/nitro_kit/control_group.rb +45 -0
  31. data/app/components/nitro_kit/danger_zone.rb +78 -0
  32. data/app/components/nitro_kit/data_section.rb +106 -0
  33. data/app/components/nitro_kit/details_table.rb +229 -0
  34. data/app/components/nitro_kit/dialog.rb +257 -83
  35. data/app/components/nitro_kit/dropdown.rb +335 -122
  36. data/app/components/nitro_kit/dropzone.rb +326 -0
  37. data/app/components/nitro_kit/empty_state.rb +99 -0
  38. data/app/components/nitro_kit/field.rb +461 -236
  39. data/app/components/nitro_kit/field_group.rb +15 -8
  40. data/app/components/nitro_kit/fieldset.rb +37 -39
  41. data/app/components/nitro_kit/flex.rb +49 -0
  42. data/app/components/nitro_kit/form_builder.rb +313 -72
  43. data/app/components/nitro_kit/form_section.rb +79 -0
  44. data/app/components/nitro_kit/grid.rb +34 -0
  45. data/app/components/nitro_kit/icon.rb +54 -25
  46. data/app/components/nitro_kit/input.rb +97 -10
  47. data/app/components/nitro_kit/label.rb +22 -6
  48. data/app/components/nitro_kit/layout_options.rb +7 -0
  49. data/app/components/nitro_kit/page_header.rb +76 -0
  50. data/app/components/nitro_kit/pagination.rb +355 -69
  51. data/app/components/nitro_kit/pagination_bar.rb +90 -0
  52. data/app/components/nitro_kit/progressive_image.rb +181 -0
  53. data/app/components/nitro_kit/radio_button.rb +77 -55
  54. data/app/components/nitro_kit/radio_button_group.rb +107 -36
  55. data/app/components/nitro_kit/responsive_value.rb +94 -0
  56. data/app/components/nitro_kit/rich_text_area.rb +31 -0
  57. data/app/components/nitro_kit/select.rb +129 -64
  58. data/app/components/nitro_kit/settings_layout.rb +152 -0
  59. data/app/components/nitro_kit/sheet.rb +202 -0
  60. data/app/components/nitro_kit/stat_grid.rb +75 -0
  61. data/app/components/nitro_kit/switch.rb +108 -51
  62. data/app/components/nitro_kit/table.rb +237 -45
  63. data/app/components/nitro_kit/tabs.rb +168 -63
  64. data/app/components/nitro_kit/textarea.rb +81 -10
  65. data/app/components/nitro_kit/toast.rb +211 -80
  66. data/app/components/nitro_kit/toolbar.rb +75 -0
  67. data/app/components/nitro_kit/tooltip.rb +235 -34
  68. data/app/components/nitro_kit/typeset.rb +28 -0
  69. data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
  70. data/app/javascript/controllers/nk/appearance_controller.js +73 -0
  71. data/app/javascript/controllers/nk/avatar_controller.js +15 -0
  72. data/app/javascript/controllers/nk/button_controller.js +56 -0
  73. data/app/javascript/controllers/nk/checkable_controller.js +41 -0
  74. data/app/javascript/controllers/nk/combobox_controller.js +314 -82
  75. data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
  76. data/app/javascript/controllers/nk/dialog_controller.js +14 -10
  77. data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
  78. data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
  79. data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
  80. data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
  81. data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
  82. data/app/javascript/controllers/nk/overlay_position.js +37 -0
  83. data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
  84. data/app/javascript/controllers/nk/tabs_controller.js +135 -23
  85. data/app/javascript/controllers/nk/toast_controller.js +112 -45
  86. data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
  87. data/config/importmap.rb +2 -0
  88. data/config/locales/en.yml +98 -0
  89. data/docs/agent_guide.md +139 -0
  90. data/docs/agent_native_spec.md +422 -0
  91. data/docs/component_contracts.md +263 -0
  92. data/docs/customization.md +595 -0
  93. data/docs/hotwire.md +94 -0
  94. data/docs/initialization_prompt.md +36 -0
  95. data/docs/migration_1_to_2.md +318 -0
  96. data/docs/new_app_strategy.md +22 -0
  97. data/docs/patterns/application_foundation.md +136 -0
  98. data/docs/patterns/crud_resource.md +144 -0
  99. data/docs/patterns/destructive_action.md +105 -0
  100. data/docs/patterns/flash_and_toast.md +57 -0
  101. data/docs/patterns/inline_edit.md +59 -0
  102. data/docs/patterns/queryable_collection.md +187 -0
  103. data/docs/patterns/resource_form.md +126 -0
  104. data/docs/rails_conventions.md +95 -0
  105. data/docs/rails_integration.md +514 -0
  106. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  107. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  108. data/lib/nitro_kit/engine.rb +20 -0
  109. data/lib/nitro_kit/installation.rb +671 -0
  110. data/lib/nitro_kit/migration_inventory.rb +345 -0
  111. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  112. data/lib/nitro_kit/version.rb +1 -1
  113. data/lib/nitro_kit.rb +5 -42
  114. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  115. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  116. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  117. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  123. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  124. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  125. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  126. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  127. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  128. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  129. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  130. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  131. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  132. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  133. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  134. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  135. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  136. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  138. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  139. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  140. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  141. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  142. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  143. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  144. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  145. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  146. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  147. data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
  148. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  149. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  150. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  151. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  152. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  153. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  154. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  155. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  156. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  157. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  158. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  159. data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
  160. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  161. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  162. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  163. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  164. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  166. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  167. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  168. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  169. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  171. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  172. data/src/stylesheets/nitro_kit/components/table.css +116 -0
  173. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  174. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  175. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  176. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  177. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  178. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  179. data/src/stylesheets/nitro_kit/layers.css +7 -0
  180. data/src/stylesheets/nitro_kit/reset.css +192 -0
  181. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  182. metadata +182 -51
  183. data/Rakefile +0 -8
  184. data/app/assets/tailwind/application.css +0 -49
  185. data/app/components/nitro_kit/datepicker.rb +0 -9
  186. data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
  187. data/app/helpers/nitro_kit/alert_helper.rb +0 -11
  188. data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
  189. data/app/helpers/nitro_kit/badge_helper.rb +0 -13
  190. data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
  191. data/app/helpers/nitro_kit/button_helper.rb +0 -49
  192. data/app/helpers/nitro_kit/card_helper.rb +0 -9
  193. data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
  194. data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
  195. data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
  196. data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
  197. data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
  198. data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
  199. data/app/helpers/nitro_kit/field_helper.rb +0 -9
  200. data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
  201. data/app/helpers/nitro_kit/form_helper.rb +0 -13
  202. data/app/helpers/nitro_kit/icon_helper.rb +0 -9
  203. data/app/helpers/nitro_kit/input_helper.rb +0 -37
  204. data/app/helpers/nitro_kit/label_helper.rb +0 -19
  205. data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
  206. data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
  207. data/app/helpers/nitro_kit/select_helper.rb +0 -24
  208. data/app/helpers/nitro_kit/switch_helper.rb +0 -9
  209. data/app/helpers/nitro_kit/table_helper.rb +0 -9
  210. data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
  211. data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
  212. data/app/helpers/nitro_kit/toast_helper.rb +0 -25
  213. data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
  214. data/app/javascript/controllers/nk/accordion_controller.js +0 -18
  215. data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
  216. data/app/javascript/controllers/nk/switch_controller.js +0 -32
  217. data/lib/generators/nitro_kit/component_generator.rb +0 -84
  218. data/lib/nitro_kit/schema_builder.rb +0 -129
  219. data/lib/nitro_kit/variants.rb +0 -21
@@ -2,6 +2,16 @@
2
2
 
3
3
  module NitroKit
4
4
  class Field < Component
5
+ UNSET = Object.new.freeze
6
+ private_constant :UNSET
7
+ INPUT_TYPES = %i[
8
+ button color date datetime datetime_local email file hidden month number password range
9
+ search string tel text time url week
10
+ ].freeze
11
+ TYPES = (
12
+ INPUT_TYPES + %i[select combobox textarea rich_text checkbox radio radio_button radio_group switch]
13
+ ).freeze
14
+
5
15
  def initialize(
6
16
  form = nil,
7
17
  field_name = nil,
@@ -9,141 +19,193 @@ module NitroKit
9
19
  label: nil,
10
20
  description: nil,
11
21
  errors: nil,
12
- wrapper: {},
13
22
  options: nil,
23
+ option_tags: nil,
14
24
  include_blank: nil,
15
25
  prompt: nil,
16
- **attrs
26
+ id: nil,
27
+ name: nil,
28
+ value: UNSET,
29
+ placeholder: nil,
30
+ disabled: false,
31
+ readonly: false,
32
+ required: false,
33
+ autocomplete: nil,
34
+ checked: nil,
35
+ indeterminate: false,
36
+ multiple: false,
37
+ accept: nil,
38
+ min: nil,
39
+ max: nil,
40
+ step: nil,
41
+ minlength: nil,
42
+ maxlength: nil,
43
+ rows: nil,
44
+ cols: nil,
45
+ wrap: nil,
46
+ pattern: nil,
47
+ inputmode: nil,
48
+ checked_value: "1",
49
+ unchecked_value: "0",
50
+ include_hidden: true,
51
+ html: {},
52
+ aria: {},
53
+ data: {},
54
+ control_html: {},
55
+ control_aria: {},
56
+ control_data: {},
57
+ rich_text_content: nil,
58
+ desperately_need_a_class: nil
17
59
  )
18
60
  @form = form
19
- @field_name = field_name.to_s
20
- @as = as.is_a?(String) ? as.to_sym : as
21
-
22
- @name = attrs[:name] || form&.field_name(field_name)
23
- @id = attrs[:id] || form&.field_id(field_name)
24
-
25
- # select, radio group
61
+ @field_name = field_name&.to_s
62
+ @as = validate_choice!(:as, as.to_s.tr("-", "_").to_sym, TYPES)
63
+ @multiple = validate_boolean!(:multiple, multiple)
64
+ @id = id || form&.field_id(field_name) || default_field_id
65
+ @name = name || form&.field_name(field_name, multiple: @multiple) || default_field_name
66
+ @explicit_value = value
67
+ @field_label = label.nil? ? derived_label : label
68
+ @field_description = description
69
+ @field_error_messages = Array(errors).compact
26
70
  @options = options
71
+ @option_tags = option_tags
27
72
  @include_blank = include_blank
28
73
  @prompt = prompt
29
-
30
- @field_attrs = attrs
31
- @field_label = label.nil? ? field_name.to_s.humanize : label
32
- @field_description = description
33
- @field_error_messages = errors
34
-
35
- @wrapper = wrapper
74
+ @placeholder = placeholder
75
+ @disabled = validate_boolean!(:disabled, disabled)
76
+ @readonly = validate_boolean!(:readonly, readonly)
77
+ @required = validate_boolean!(:required, required)
78
+ @autocomplete = autocomplete
79
+ @checked = validate_boolean!(:checked, checked, allow_nil: true)
80
+ @indeterminate = validate_boolean!(:indeterminate, indeterminate)
81
+ @accept = accept
82
+ @min = min
83
+ @max = max
84
+ @step = step
85
+ @minlength = minlength
86
+ @maxlength = maxlength
87
+ @rows = rows
88
+ @cols = cols
89
+ @wrap = wrap
90
+ @pattern = pattern
91
+ @inputmode = inputmode
92
+ @checked_value = checked_value
93
+ @unchecked_value = unchecked_value
94
+ @include_hidden = validate_boolean!(:include_hidden, include_hidden)
95
+ @control_html = control_html
96
+ @control_aria = control_aria
97
+ @control_data = control_data
98
+ @rich_text_content = rich_text_content
99
+
100
+ if @options.is_a?(ActiveSupport::SafeBuffer)
101
+ raise ArgumentError, "pass captured option markup through option_tags:"
102
+ end
103
+ if @as != :textarea && !(@rows.nil? && @cols.nil? && @wrap.nil?)
104
+ raise ArgumentError, "rows:, cols:, and wrap: require as: :textarea"
105
+ end
106
+ if @as == :radio_group && @field_label == false
107
+ raise ArgumentError, "as: :radio_group requires a legend; pass label: with the group name"
108
+ end
36
109
 
37
110
  super(
38
- wrapper,
39
- data: { as: @as.to_s },
40
- class: base_class
111
+ component: :field,
112
+ attributes: {
113
+ data: {
114
+ field_type: @as.to_s.tr("_", "-"),
115
+ state: @field_error_messages.any? ? "invalid" : nil,
116
+ required: @required ? "true" : nil,
117
+ disabled: @disabled ? "true" : nil
118
+ }.compact
119
+ },
120
+ html:,
121
+ aria:,
122
+ data:,
123
+ desperately_need_a_class:
41
124
  )
42
125
  end
43
126
 
44
- attr_reader(
45
- :as,
46
- :form,
47
- :name,
48
- :id,
49
- :field_attrs,
50
- :field_label,
51
- :field_description,
52
- :field_error_messages
53
- )
127
+ attr_reader :as, :form, :name, :id, :field_label, :field_description, :field_error_messages
54
128
 
55
- def view_template
56
- div(**attrs) do
57
- if block_given?
58
- yield
59
- else
60
- default_field
61
- end
129
+ def view_template(&block)
130
+ div(**root_attributes) do
131
+ block ? yield : default_field
62
132
  end
63
133
  end
64
134
 
65
- alias :html_label :label
66
-
67
- def label(text = nil, **attrs)
68
- builder do
69
- text ||= field_label
70
-
71
- return unless text
72
-
73
- render(Label.new(**mattr(attrs, for: id, data: { slot: "label" }))) do
74
- text
75
- end
76
- end
135
+ def label(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
136
+ text = field_label if text.nil?
137
+ return if text == false
138
+ return unless !text.nil? || block
139
+
140
+ render_in_slot(
141
+ Label.new(
142
+ text,
143
+ for: label_target_id,
144
+ id: label_element_id,
145
+ html:,
146
+ aria:,
147
+ data:,
148
+ desperately_need_a_class:
149
+ ),
150
+ :label,
151
+ &block
152
+ )
77
153
  end
78
154
 
79
- def description(text = nil, **attrs, &block)
80
- builder do
81
- text ||= field_description
82
-
83
- return unless text || block_given?
84
-
85
- div(**mattr(attrs, data: { slot: "description" }, class: description_class)) do
86
- text_or_block(text, &block)
87
- end
88
- end
155
+ def description(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
156
+ text = field_description if text.nil?
157
+ return unless !text.nil? || block
158
+
159
+ div(
160
+ **slot_attributes(
161
+ :description,
162
+ attributes: { id: description_id }.compact,
163
+ html:,
164
+ aria:,
165
+ data:,
166
+ desperately_need_a_class:
167
+ )
168
+ ) { text_or_block(text, &block) }
89
169
  end
90
170
 
91
- def errors(error_messages = nil, **attrs)
92
- builder do
93
- error_messages ||= field_error_messages
94
-
95
- return unless error_messages&.any?
96
-
97
- ul(**mattr(attrs, data: { slot: "error" }, class: error_class)) do |msg|
98
- error_messages.each do |msg|
99
- li { msg }
100
- end
101
- end
171
+ def errors(error_messages = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
172
+ messages = error_messages.nil? ? field_error_messages : Array(error_messages).compact
173
+ return if messages.empty?
174
+
175
+ ul(
176
+ **slot_attributes(
177
+ :error,
178
+ attributes: { id: errors_id, aria: { live: "assertive" } }.compact,
179
+ html:,
180
+ aria:,
181
+ data:,
182
+ desperately_need_a_class:
183
+ )
184
+ ) do
185
+ messages.each { |message| li { plain(message.to_s) } }
102
186
  end
103
187
  end
104
188
 
105
- def control(**attrs)
106
- builder do
107
- case as
108
- when :string
109
- input(**attrs)
110
- when
111
- :button,
112
- :color,
113
- :date,
114
- :datetime,
115
- :datetime_local,
116
- :email,
117
- :file,
118
- :hidden,
119
- :month,
120
- :number,
121
- :password,
122
- :range,
123
- :search,
124
- :tel,
125
- :text,
126
- :time,
127
- :url,
128
- :week
129
- input(type: as, **attrs)
130
- when :select
131
- select(**attrs)
132
- when :textarea
133
- textarea(**attrs)
134
- when :checkbox
135
- checkbox(**attrs)
136
- when :combobox
137
- combobox(**attrs)
138
- when :radio, :radio_button, :radio_group
139
- radio_group(**attrs)
140
- when :switch
141
- switch(**attrs)
142
- when Class
143
- component(**attrs)
144
- else
145
- raise ArgumentError, "Invalid field type `#{as}'"
146
- end
189
+ def control(html: {}, aria: {}, data: {})
190
+ case as
191
+ when *INPUT_TYPES
192
+ input_control(html:, aria:, data:)
193
+ when :select
194
+ select_control(html:, aria:, data:)
195
+ when :combobox
196
+ combobox_control(html:, aria:, data:)
197
+ when :textarea
198
+ textarea_control(html:, aria:, data:)
199
+ when :rich_text
200
+ rich_text_control(html:, aria:, data:)
201
+ when :checkbox
202
+ checkbox_control(html:, aria:, data:)
203
+ when :switch
204
+ switch_control(html:, aria:, data:)
205
+ when :radio, :radio_button
206
+ radio_button_control(html:, aria:, data:)
207
+ when :radio_group
208
+ radio_group_control(html:, aria:, data:)
147
209
  end
148
210
  end
149
211
 
@@ -151,186 +213,349 @@ module NitroKit
151
213
 
152
214
  def default_field
153
215
  case as
154
- when :checkbox
216
+ when :checkbox, :switch
155
217
  control
156
- label
157
218
  description
158
- errors
219
+ when :radio_group
220
+ control
159
221
  else
160
222
  label
161
223
  description
162
224
  control
163
- errors
164
225
  end
226
+ errors
165
227
  end
166
228
 
167
- def control_attrs(**attrs)
168
- mattr(
169
- attrs,
170
- name:,
171
- id:,
172
- value: value_before_typecast,
173
- data: { slot: "control" }
229
+ def input_control(html:, aria:, data:)
230
+ type = resolved_input_type
231
+ render_in_slot(
232
+ Input.new(
233
+ type:,
234
+ id:,
235
+ name:,
236
+ value: type == :file ? nil : normalized_value(type),
237
+ placeholder: @placeholder,
238
+ disabled: @disabled,
239
+ readonly: @readonly,
240
+ required: @required,
241
+ autocomplete: @autocomplete,
242
+ multiple: @multiple,
243
+ accept: @accept,
244
+ min: @min,
245
+ max: @max,
246
+ step: @step,
247
+ minlength: @minlength,
248
+ maxlength: @maxlength,
249
+ pattern: @pattern,
250
+ inputmode: @inputmode,
251
+ html: @control_html.merge(html),
252
+ aria: control_aria(aria),
253
+ data: @control_data.merge(data)
254
+ ),
255
+ :control
174
256
  )
175
257
  end
176
258
 
177
- alias :html_input :input
259
+ def select_control(html:, aria:, data:)
260
+ render_in_slot(
261
+ Select.new(
262
+ options: @options || [],
263
+ option_tags: @option_tags,
264
+ id:,
265
+ name:,
266
+ value: current_value,
267
+ include_blank: @include_blank,
268
+ prompt: @prompt,
269
+ disabled: @disabled,
270
+ required: @required,
271
+ multiple: @multiple,
272
+ autocomplete: @autocomplete,
273
+ control_html: @control_html.merge(html),
274
+ control_aria: control_aria(aria),
275
+ control_data: @control_data.merge(data)
276
+ ),
277
+ :control
278
+ )
279
+ end
178
280
 
179
- def input(**attrs)
180
- render(
181
- Input.new(
182
- **control_attrs(
183
- **field_attrs,
184
- **attrs
185
- )
186
- )
281
+ def combobox_control(html:, aria:, data:)
282
+ render_in_slot(
283
+ Combobox.new(
284
+ id:,
285
+ name:,
286
+ label: false,
287
+ options: normalized_options,
288
+ value: current_value.nil? ? nil : current_value.to_s.presence,
289
+ placeholder: @placeholder,
290
+ include_blank: @include_blank.nil? ? true : @include_blank,
291
+ required: @required,
292
+ disabled: @disabled,
293
+ autocomplete: @autocomplete || "off",
294
+ html: @control_html.merge(html),
295
+ data: @control_data.merge(data),
296
+ control_aria: combobox_control_aria(aria)
297
+ ),
298
+ :control
187
299
  )
188
300
  end
189
301
 
190
- alias :html_select :select
302
+ def combobox_control_aria(extra)
303
+ attributes = control_aria(extra)
304
+ return attributes if attributes.keys.any? { |key| %w[label labelledby].include?(key.to_s.delete("_-")) }
305
+ return attributes.merge(labelledby: label_element_id) if field_label && label_element_id
191
306
 
192
- def select(options: nil, include_blank: nil, prompt: nil, **attrs)
193
- collection = options || @options || []
194
- blank_option = include_blank || @include_blank
195
- prompt_text = prompt || @prompt
307
+ accessible_name = derived_label || name.to_s.humanize.presence
308
+ raise ArgumentError, "an unlabeled combobox requires a field name or control ARIA label" unless accessible_name
196
309
 
197
- render(
198
- Select.new(
199
- collection,
200
- value: value,
201
- include_empty: blank_option,
202
- prompt: prompt_text,
203
- **control_attrs(
204
- **field_attrs,
205
- **attrs
206
- )
207
- )
208
- )
310
+ attributes.merge(label: accessible_name)
209
311
  end
210
312
 
211
- alias :html_textarea :textarea
212
-
213
- def textarea(**attrs)
214
- render(
313
+ def textarea_control(html:, aria:, data:)
314
+ render_in_slot(
215
315
  Textarea.new(
216
- **control_attrs(
217
- **field_attrs,
218
- **attrs
219
- )
220
- )
316
+ id:,
317
+ name:,
318
+ value: current_value,
319
+ placeholder: @placeholder,
320
+ disabled: @disabled,
321
+ readonly: @readonly,
322
+ required: @required,
323
+ autocomplete: @autocomplete,
324
+ rows: @rows,
325
+ cols: @cols,
326
+ minlength: @minlength,
327
+ maxlength: @maxlength,
328
+ wrap: @wrap,
329
+ html: @control_html.merge(html),
330
+ aria: control_aria(aria),
331
+ data: @control_data.merge(data)
332
+ ),
333
+ :control
221
334
  )
222
335
  end
223
336
 
224
- def checkbox(**attrs)
225
- control_attrs(**field_attrs, **attrs).tap do |attrs|
226
- input(type: "hidden", **attrs, id: nil, value: "0")
227
- render(Checkbox.new(checked: checked?, **attrs, value: "1"))
228
- end
337
+ def rich_text_control(html:, aria:, data:)
338
+ render_in_slot(
339
+ RichTextArea.new(
340
+ @rich_text_content,
341
+ html: @control_html.merge(html),
342
+ aria: control_aria(aria),
343
+ data: @control_data.merge(data)
344
+ ),
345
+ :control
346
+ )
229
347
  end
230
348
 
231
- def combobox(**attrs)
232
- render(
233
- Combobox.new(
234
- options: @options,
235
- **control_attrs(
236
- **field_attrs,
237
- **attrs
238
- )
239
- )
349
+ def checkbox_control(html:, aria:, data:)
350
+ render_in_slot(
351
+ Checkbox.new(
352
+ label: field_label == false ? nil : field_label,
353
+ id:,
354
+ name:,
355
+ value: @checked_value,
356
+ unchecked_value: @unchecked_value,
357
+ include_hidden: @include_hidden,
358
+ checked: checked?,
359
+ indeterminate: @indeterminate,
360
+ disabled: @disabled,
361
+ required: @required,
362
+ control_html: @control_html.merge(html),
363
+ control_aria: control_aria(aria),
364
+ control_data: @control_data.merge(data)
365
+ ),
366
+ :control
240
367
  )
241
368
  end
242
369
 
243
- def radio_group(options: nil, **attrs)
244
- render(
245
- RadioButtonGroup.new(
246
- options || @options || [],
247
- **control_attrs(
248
- **field_attrs,
249
- **attrs
250
- )
251
- )
370
+ def switch_control(html:, aria:, data:)
371
+ render_in_slot(
372
+ Switch.new(
373
+ label: field_label == false ? nil : field_label,
374
+ description: nil,
375
+ id:,
376
+ name:,
377
+ value: @checked_value,
378
+ unchecked_value: @unchecked_value,
379
+ include_hidden: @include_hidden,
380
+ checked: checked?,
381
+ disabled: @disabled,
382
+ required: @required,
383
+ control_html: @control_html.merge(html),
384
+ control_aria: switch_aria(aria),
385
+ control_data: @control_data.merge(data)
386
+ ),
387
+ :control
252
388
  )
253
389
  end
254
390
 
255
- def switch(**attrs)
256
- # TODO: support use in forms
257
- render(
258
- Switch.new(
259
- **control_attrs(
260
- **field_attrs,
261
- **attrs
262
- )
263
- )
391
+ def radio_button_control(html:, aria:, data:)
392
+ choice = normalized_options.first || Choice.new(label: @checked_value, value: @checked_value)
393
+
394
+ render_in_slot(
395
+ RadioButton.new(
396
+ label: field_label == false ? nil : field_label,
397
+ id:,
398
+ name:,
399
+ value: choice.value,
400
+ checked: @checked.nil? ? current_value.to_s == choice.value.to_s : @checked,
401
+ disabled: @disabled || choice.disabled,
402
+ required: @required,
403
+ control_html: @control_html.merge(html),
404
+ control_aria: control_aria(aria),
405
+ control_data: @control_data.merge(data)
406
+ ),
407
+ :control
264
408
  )
265
409
  end
266
410
 
267
- def component(**attrs)
268
- render(
269
- as.new(
270
- **control_attrs(
271
- **field_attrs,
272
- **attrs
273
- )
274
- )
411
+ def radio_group_control(html:, aria:, data:)
412
+ render_in_slot(
413
+ RadioButtonGroup.new(
414
+ legend: field_label || derived_label || I18n.t("nitro_kit.field.options_legend"),
415
+ options: normalized_options,
416
+ name:,
417
+ value: current_value,
418
+ id:,
419
+ description: field_description,
420
+ disabled: @disabled,
421
+ required: @required,
422
+ html: @control_html.merge(html),
423
+ aria: control_aria(aria),
424
+ data: @control_data.merge(data)
425
+ ),
426
+ :control
275
427
  )
276
428
  end
277
429
 
278
- private
430
+ def control_aria(extra)
431
+ attributes = @control_aria.merge(extra)
432
+ provided_description = extract_aria_attribute!(attributes, :describedby)
433
+ provided_invalid = extract_aria_attribute!(attributes, :invalid)
434
+ described_by = [
435
+ provided_description,
436
+ field_description ? description_id : nil,
437
+ field_error_messages.any? ? errors_id : nil
438
+ ].compact.join(" ")
439
+
440
+ attributes.merge(
441
+ describedby: described_by.presence,
442
+ invalid: field_error_messages.any? ? true : provided_invalid
443
+ ).compact
444
+ end
445
+
446
+ def switch_aria(extra)
447
+ attributes = control_aria(extra)
448
+ return attributes unless field_label == false
449
+ return attributes if attributes.keys.any? { |key| %w[label labelledby].include?(key.to_s.delete("_-")) }
450
+
451
+ accessible_name = derived_label || name.to_s.humanize.presence
452
+ raise ArgumentError, "an unlabeled switch requires a field name or control ARIA label" unless accessible_name
453
+
454
+ attributes.merge(label: accessible_name)
455
+ end
456
+
457
+ def extract_aria_attribute!(attributes, name)
458
+ key = attributes.keys.find { |candidate| candidate.to_s.delete("_-") == name.to_s.delete("_-") }
459
+ attributes.delete(key) if key
460
+ end
461
+
462
+ def derived_label
463
+ return unless @field_name
464
+
465
+ object = form&.object
466
+ if object && object.class.respond_to?(:human_attribute_name)
467
+ object.class.human_attribute_name(@field_name)
468
+ else
469
+ @field_name.humanize
470
+ end
471
+ end
279
472
 
280
- def base_class
281
- [
282
- "grid align-start",
283
- # Margins
284
- "[&>[data-slot=label]+[data-slot=control]]:mt-2 [&>[data-slot=label]+[data-slot=description]]:mt-1 [&>[data-slot=description]+[data-slot=control]]:mt-3 [&>[data-slot=control]+[data-slot=description]]:mt-3 [&>[data-slot=control]+[data-slot=error]]:mt-2",
285
- # When checkbox
286
- "[&[data-as=checkbox]]:grid-cols-[auto_1fr] data-[as=checkbox]:gap-x-2 [&[data-as=checkbox]_[data-slot=description]]:col-start-2",
287
- "[&:has([data-slot=error])_[data-slot=control]]:border-destructive"
288
- ]
473
+ def normalized_options
474
+ @normalized_options ||= Array(@options).map { |choice| Choice.coerce(choice) }.freeze
289
475
  end
290
476
 
291
- def description_class
292
- "text-sm text-muted-content"
477
+ def description_id
478
+ "#{id}-description" if id
293
479
  end
294
480
 
295
- def error_class
296
- "text-sm text-destructive-content"
481
+ def label_target_id
482
+ as == :combobox ? "#{id}-input" : id
297
483
  end
298
484
 
299
- def value
300
- return attrs[:value] if attrs[:value]
301
- return unless object = form&.object
485
+ def label_element_id
486
+ "#{id}-label" if as == :combobox && id
487
+ end
488
+
489
+ def default_field_id
490
+ return unless @field_name
491
+
492
+ @field_name.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "").presence
493
+ end
494
+
495
+ def default_field_name
496
+ return unless @field_name
302
497
 
303
- if object.respond_to?(@field_name)
304
- object.public_send(@field_name)
498
+ @multiple ? "#{@field_name}[]" : @field_name
499
+ end
500
+
501
+ def errors_id
502
+ "#{id}-errors" if id
503
+ end
504
+
505
+ def resolved_input_type
506
+ case as
507
+ when :string then :text
508
+ when :datetime, :datetime_local then :"datetime-local"
509
+ else as
305
510
  end
306
511
  end
307
512
 
308
- def value_before_typecast
309
- return attrs[:value] if attrs[:value]
310
- return unless object = form&.object
513
+ def normalized_value(type)
514
+ value = current_value_before_type_cast
515
+ return value unless type == :"datetime-local"
516
+ return value if value.nil?
311
517
 
312
- method_before_type_cast = @field_name + "_before_type_cast"
518
+ if value.is_a?(String)
519
+ stripped = value.strip
520
+ return value if stripped.empty?
521
+ return stripped if stripped.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?\z/)
313
522
 
314
- if object.respond_to?(method_before_type_cast)
315
- object.public_send(method_before_type_cast)
316
- else
317
- value
523
+ value = parse_datetime_value(stripped) || value
318
524
  end
525
+
526
+ value.respond_to?(:strftime) ? value.strftime("%Y-%m-%dT%H:%M:%S") : value
527
+ end
528
+
529
+ def parse_datetime_value(value)
530
+ Time.zone ? Time.zone.parse(value) : Time.parse(value)
531
+ rescue ArgumentError, TypeError
532
+ nil
533
+ end
534
+
535
+ def current_value
536
+ return @explicit_value unless @explicit_value.equal?(UNSET)
537
+ return unless form&.object && @field_name && form.object.respond_to?(@field_name)
538
+
539
+ form.object.public_send(@field_name)
540
+ end
541
+
542
+ def current_value_before_type_cast
543
+ return @explicit_value unless @explicit_value.equal?(UNSET)
544
+ return unless form&.object && @field_name
545
+
546
+ before_type_cast = "#{@field_name}_before_type_cast"
547
+ form.object.respond_to?(before_type_cast) ? form.object.public_send(before_type_cast) : current_value
319
548
  end
320
549
 
321
550
  def checked?
322
- return unless object = form&.object
323
- value = object.public_send(@field_name)
324
-
325
- case value
326
- when true, false
327
- value
328
- when String
329
- value == "1"
330
- when Numeric
331
- value == 1
332
- else
333
- false
551
+ return @checked unless @checked.nil?
552
+ return true if current_value.to_s == @checked_value.to_s
553
+
554
+ case current_value
555
+ when true then true
556
+ when String then current_value == "1"
557
+ when Numeric then current_value == 1
558
+ else false
334
559
  end
335
560
  end
336
561
  end