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,18 +2,25 @@
2
2
 
3
3
  module NitroKit
4
4
  class FieldGroup < Component
5
- def initialize(**attrs)
6
- super(attrs, class: base_class, data: { slot: "control" })
5
+ def initialize(
6
+ html: {},
7
+ aria: {},
8
+ data: {},
9
+ desperately_need_a_class: nil
10
+ )
11
+ super(
12
+ component: :field_group,
13
+ html:,
14
+ aria:,
15
+ data:,
16
+ desperately_need_a_class:
17
+ )
7
18
  end
8
19
 
9
20
  def view_template(&block)
10
- div(**attrs, &block)
11
- end
12
-
13
- private
21
+ raise ArgumentError, "field group requires a block" unless block
14
22
 
15
- def base_class
16
- "space-y-6"
23
+ div(**root_attributes, &block)
17
24
  end
18
25
  end
19
26
  end
@@ -2,57 +2,55 @@
2
2
 
3
3
  module NitroKit
4
4
  class Fieldset < Component
5
- def initialize(legend: nil, description: nil, **attrs)
6
- @legend = legend
7
- @description = description
8
- super(
9
- attrs,
10
- class: base_class
11
- )
12
- end
5
+ alias :html_legend :legend
13
6
 
14
- def view_template
15
- fieldset(**attrs) do
16
- legend(@legend) if @legend
17
- description(@description) if @description
7
+ def initialize(
8
+ legend: nil,
9
+ description: nil,
10
+ disabled: false,
11
+ name: nil,
12
+ html: {},
13
+ aria: {},
14
+ data: {},
15
+ desperately_need_a_class: nil
16
+ )
17
+ @legend_content = content_from_keyword(:legend, legend)
18
+ @description_content = content_from_keyword(:description, description)
19
+ disabled = validate_boolean!(:disabled, disabled)
18
20
 
19
- yield
20
- end
21
+ super(
22
+ component: :fieldset,
23
+ attributes: { disabled:, name: }.compact,
24
+ html:,
25
+ aria:,
26
+ data:,
27
+ desperately_need_a_class:
28
+ )
21
29
  end
22
30
 
23
- alias :html_legend :legend
31
+ def view_template(&block)
32
+ raise ArgumentError, "Fieldset requires a block" unless block
24
33
 
25
- def legend(text = nil, **attrs, &block)
26
- builder do
27
- html_legend(**mattr(attrs, class: legend_class)) do
28
- text_or_block(text, &block)
29
- end
30
- end
31
- end
34
+ fields = capture(self) { |fieldset| yield fieldset }
35
+ require_content!("Fieldset", :legend, @legend_content)
32
36
 
33
- def description(text = nil, **attrs, &block)
34
- builder do
35
- div(**mattr(attrs, class: description_class, data: { slot: "text" })) do
36
- text_or_block(text, &block)
37
+ fieldset(**root_attributes) do
38
+ html_legend(**slot_attributes(:legend)) { render_deferred_content(@legend_content) }
39
+ if @description_content
40
+ p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
37
41
  end
42
+ div(**slot_attributes(:fields)) { raw(safe(fields)) }
38
43
  end
39
44
  end
40
45
 
41
- private
42
-
43
- def base_class
44
- [
45
- "[&>*+[data-slot=control]]:mt-6 [&>*+[data-slot=text]]:mt-1",
46
- "[&+&]:mt-8"
47
- ]
48
- end
49
-
50
- def legend_class
51
- "text-lg font-semibold"
46
+ def legend(text = nil, &block)
47
+ @legend_content = declare_content(:legend, @legend_content, text, &block)
48
+ nil
52
49
  end
53
50
 
54
- def description_class
55
- "text-sm text-muted-content"
51
+ def description(text = nil, &block)
52
+ @description_content = declare_content(:description, @description_content, text, &block)
53
+ nil
56
54
  end
57
55
  end
58
56
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class Flex < Component
5
+ GAPS = LayoutOptions::GAPS
6
+ DIRECTIONS = %i[row col row_reverse col_reverse].freeze
7
+ ALIGNMENTS = %i[start center end stretch baseline].freeze
8
+ JUSTIFICATIONS = %i[start center end between around evenly].freeze
9
+ WRAPS = %i[nowrap wrap wrap_reverse].freeze
10
+
11
+ def initialize(
12
+ dir:,
13
+ gap: 4,
14
+ align: :start,
15
+ justify: :start,
16
+ wrap: :nowrap,
17
+ id: nil,
18
+ html: {},
19
+ aria: {},
20
+ data: {},
21
+ desperately_need_a_class: nil
22
+ )
23
+ dir = responsive_value(:dir, dir, DIRECTIONS)
24
+ gap = responsive_value(:gap, gap, GAPS)
25
+ align = responsive_value(:align, align, ALIGNMENTS)
26
+ justify = responsive_value(:justify, justify, JUSTIFICATIONS)
27
+ wrap = responsive_value(:wrap, wrap, WRAPS)
28
+
29
+ super(
30
+ component: :flex,
31
+ attributes: { id:, data: { dir:, gap:, align:, justify:, wrap: } }.compact,
32
+ html:,
33
+ aria:,
34
+ data:,
35
+ desperately_need_a_class:
36
+ )
37
+ end
38
+
39
+ def view_template
40
+ div(**root_attributes) { yield if block_given? }
41
+ end
42
+
43
+ private
44
+
45
+ def responsive_value(property, value, allowed)
46
+ ResponsiveValue.new(property:, value:, allowed:).to_s
47
+ end
48
+ end
49
+ end
@@ -2,102 +2,343 @@
2
2
 
3
3
  module NitroKit
4
4
  class FormBuilder < ActionView::Helpers::FormBuilder
5
- # Fields
5
+ FIELD_TYPES = {
6
+ color_field: :color,
7
+ date_field: :date,
8
+ datetime_field: :datetime_local,
9
+ datetime_local_field: :datetime_local,
10
+ email_field: :email,
11
+ file_field: :file,
12
+ hidden_field: :hidden,
13
+ month_field: :month,
14
+ number_field: :number,
15
+ password_field: :password,
16
+ phone_field: :tel,
17
+ range_field: :range,
18
+ search_field: :search,
19
+ telephone_field: :tel,
20
+ text_area: :textarea,
21
+ text_field: :text,
22
+ time_field: :time,
23
+ url_field: :url,
24
+ week_field: :week
25
+ }.freeze
26
+ CONTROL_OPTIONS = %i[
27
+ id name value placeholder disabled readonly required autocomplete checked
28
+ multiple accept min max step minlength maxlength rows cols wrap pattern inputmode
29
+ ].freeze
30
+ UNSUPPORTED_HELPERS = {
31
+ label: "form.field(:attribute, label: \"...\")",
32
+ collection_select: "form.field(:attribute, as: :select, options: ...)",
33
+ grouped_collection_select: "form.field(:attribute, as: :select, option_tags: ...)",
34
+ collection_radio_buttons: "form.field(:attribute, as: :radio_group, options: ...)",
35
+ collection_check_boxes: "NitroKit::CheckboxGroup through form.field(:attribute, as: :checkbox)",
36
+ date_select: "form.field(:attribute, as: :date)",
37
+ time_zone_select: "form.field(:attribute, as: :select, options: ...)"
38
+ }.freeze
6
39
 
7
- def fieldset(**attrs, &block)
8
- @template.render(NitroKit::Fieldset.new(**attrs), &block)
40
+ UNSUPPORTED_HELPERS.each do |method_name, replacement|
41
+ define_method(method_name) do |*_arguments, **_options, &_block|
42
+ raise ArgumentError,
43
+ "NitroKit::FormBuilder does not implement #{method_name}; use #{replacement}"
44
+ end
9
45
  end
10
46
 
11
- def field(field_name, label: nil, errors: nil, **attrs, &block)
12
- if label.nil?
13
- label = attrs.fetch(:label, field_name.to_s.humanize)
14
- end
47
+ def fieldset(**attributes, &block)
48
+ @template.render(NitroKit::Fieldset.new(**attributes), &block)
49
+ end
15
50
 
16
- if errors.nil?
17
- errors = object && object.respond_to?(:errors) && object.errors.include?(field_name) ? object
18
- .errors
19
- .full_messages_for(field_name) : nil
20
- end
51
+ def field(
52
+ field_name,
53
+ as: :string,
54
+ label: nil,
55
+ errors: nil,
56
+ html: {},
57
+ aria: {},
58
+ data: {},
59
+ control_html: {},
60
+ control_aria: {},
61
+ control_data: {},
62
+ wrapper_html: {},
63
+ wrapper_aria: {},
64
+ wrapper_data: {},
65
+ **attributes,
66
+ &block
67
+ )
68
+ as = validate_as!(as)
69
+ label = default_label_for(field_name) if label.nil?
70
+ errors ||= errors_for(field_name)
71
+ control_html = merge_control_boundary(:html, control_html, html)
72
+ control_aria = merge_control_boundary(:aria, control_aria, aria)
73
+ control_data = merge_control_boundary(:data, control_data, data)
21
74
 
22
- @template.render(NitroKit::Field.new(self, field_name, label:, errors:, **attrs), &block)
23
- end
24
-
25
- def group(**attrs, &block)
26
- @template.render(FieldGroup.new(**attrs), &block)
27
- end
28
-
29
- # Input types
30
-
31
- %i[
32
- color_field
33
- date_field
34
- datetime_field
35
- datetime_local_field
36
- email_field
37
- file_field
38
- hidden_field
39
- month_field
40
- number_field
41
- password_field
42
- phone_field
43
- radio_button
44
- range_field
45
- search_field
46
- telephone_field
47
- text_area
48
- text_field
49
- time_field
50
- url_field
51
- week_field
52
- ]
53
- .each do |method|
54
- define_method(method) do |*args, **attrs, &block|
55
- type = method.to_s.gsub(/_field$/, "")
56
- field(*args, **attrs, type:, label: false, &block)
57
- end
75
+ if as == :rich_text
76
+ return rich_text_field(
77
+ field_name,
78
+ label:,
79
+ errors:,
80
+ control_html:,
81
+ control_aria:,
82
+ control_data:,
83
+ html: wrapper_html,
84
+ aria: wrapper_aria,
85
+ data: wrapper_data,
86
+ **attributes
87
+ )
58
88
  end
89
+ self.multipart = true if as == :file
90
+
91
+ @template.render(
92
+ NitroKit::Field.new(
93
+ self,
94
+ field_name,
95
+ as:,
96
+ label:,
97
+ errors:,
98
+ html: wrapper_html,
99
+ aria: wrapper_aria,
100
+ data: wrapper_data,
101
+ control_html:,
102
+ control_aria:,
103
+ control_data:,
104
+ **attributes
105
+ ),
106
+ &block
107
+ )
108
+ end
59
109
 
60
- def radio_button(method, value = "1", **attrs)
61
- field(method, as: :radio_button, label: false, value:, **attrs)
110
+ def group(**attributes, &block)
111
+ @template.render(NitroKit::FieldGroup.new(**attributes), &block)
62
112
  end
63
113
 
64
- def checkbox(method, checked_value = "1", unchecked_value = "0", *args, include_hidden: true, **attrs)
65
- if include_hidden
66
- @template.concat(hidden_field(method, value: unchecked_value))
114
+ def dropzone(
115
+ field_name,
116
+ id: nil,
117
+ label: I18n.t("nitro_kit.dropzone.label"),
118
+ description: nil,
119
+ presentation: :input,
120
+ direct_upload: true,
121
+ multiple: false,
122
+ accept: nil,
123
+ max_files: 1,
124
+ max_bytes: nil,
125
+ disabled: false,
126
+ required: false,
127
+ html: {},
128
+ aria: {},
129
+ data: {},
130
+ desperately_need_a_class: nil
131
+ )
132
+ self.multipart = true
133
+
134
+ @template.render(
135
+ Dropzone.new(
136
+ id: id || field_id(field_name),
137
+ name: self.field_name(field_name, multiple:),
138
+ label:,
139
+ description:,
140
+ presentation:,
141
+ direct_upload:,
142
+ multiple:,
143
+ accept:,
144
+ max_files:,
145
+ max_bytes:,
146
+ disabled:,
147
+ required:,
148
+ html:,
149
+ aria:,
150
+ data:,
151
+ desperately_need_a_class:
152
+ )
153
+ )
154
+ end
155
+
156
+ FIELD_TYPES.each do |method_name, field_type|
157
+ define_method(method_name) do |field_name, options = {}, **attributes|
158
+ self.multipart = true if field_type == :file
159
+ field(
160
+ field_name,
161
+ as: field_type,
162
+ label: false,
163
+ **normalize_control_options(options.merge(attributes))
164
+ )
67
165
  end
166
+ end
167
+
168
+ def hidden_field(field_name, options = {}, **attributes)
169
+ options = options.merge(attributes).symbolize_keys
170
+ value = options.key?(:value) ? options.delete(:value) : value_for(field_name)
68
171
 
69
- field(method, *args, as: :checkbox, label: false, value: checked_value, **attrs)
172
+ @template.render(
173
+ Input.new(
174
+ type: :hidden,
175
+ id: options.delete(:id) || field_id(field_name),
176
+ name: options.delete(:name) || self.field_name(field_name),
177
+ value:,
178
+ disabled: options.delete(:disabled) { false },
179
+ autocomplete: options.delete(:autocomplete),
180
+ data: options.delete(:data) || {},
181
+ aria: options.delete(:aria) || {},
182
+ html: options
183
+ )
184
+ )
70
185
  end
71
186
 
72
- # Buttons
187
+ def radio_button(field_name, tag_value = "1", options = {}, **attributes)
188
+ control_options = normalize_control_options(options.merge(attributes))
189
+ control_options[:control_aria] = accessible_control_aria(field_name, control_options[:control_aria])
73
190
 
74
- def submit(value = nil, **attrs, &block)
75
- if value.nil? && !block_given?
76
- value = "Save changes"
77
- end
191
+ field(
192
+ field_name,
193
+ as: :radio_button,
194
+ label: false,
195
+ options: [ [ tag_value, tag_value ] ],
196
+ **control_options
197
+ )
198
+ end
199
+
200
+ def check_box(
201
+ field_name,
202
+ options = {},
203
+ checked_value = "1",
204
+ unchecked_value = "0",
205
+ **attributes
206
+ )
207
+ options = options.merge(attributes).symbolize_keys
208
+ include_hidden = options.delete(:include_hidden) { true }
209
+ control_options = normalize_control_options(options)
210
+ control_options[:control_aria] = accessible_control_aria(field_name, control_options[:control_aria])
78
211
 
79
- @template.render(NitroKit::Button.new(value, variant: :primary, type: :submit, **attrs), &block)
212
+ field(
213
+ field_name,
214
+ as: :checkbox,
215
+ label: false,
216
+ checked_value:,
217
+ unchecked_value:,
218
+ include_hidden:,
219
+ **control_options
220
+ )
80
221
  end
81
222
 
82
- def button(value = nil, **attrs, &block)
83
- if value.nil? && !block_given?
84
- value = "Save changes"
85
- end
223
+ alias :checkbox :check_box
224
+
225
+ def submit(value = nil, options = {}, **attributes, &block)
226
+ options = options.symbolize_keys.merge(attributes)
227
+ value = I18n.t("nitro_kit.form.submit") if value.nil? && !block
228
+ options[:name] = "commit" unless options.key?(:name)
229
+ options[:value] = value if value && options[:name] == "commit" && !options.key?(:value)
86
230
 
87
- @template.render(NitroKit::Button.new(value, **attrs), &block)
231
+ @template.render(Button.new(value, variant: :primary, type: :submit, **options), &block)
88
232
  end
89
233
 
90
- def select(method, choices = nil, options = {}, html_options = {}, &block)
91
- field_options = {
234
+ def button(value = nil, options = {}, **attributes, &block)
235
+ options = options.symbolize_keys.merge(attributes)
236
+ value = I18n.t("nitro_kit.form.submit") if value.nil? && !block
237
+ options[:type] = :submit unless options.key?(:type)
238
+
239
+ @template.render(Button.new(value, **options), &block)
240
+ end
241
+
242
+ def select(field_name, choices = nil, options = {}, html_options = {}, &block)
243
+ options = options.symbolize_keys
244
+ option_tags = @template.capture(&block) if block
245
+ option_tags ||= choices if choices.is_a?(ActiveSupport::SafeBuffer)
246
+ prompt = options[:prompt] == true ? I18n.t("helpers.select.prompt", default: "Please select") : options[:prompt]
247
+ selected = options.key?(:selected) ? { value: options[:selected] } : {}
248
+
249
+ field(
250
+ field_name,
92
251
  as: :select,
93
- options: choices,
252
+ options: option_tags ? nil : choices,
253
+ option_tags:,
94
254
  include_blank: options[:include_blank],
95
- prompt: options[:prompt]
96
- }.compact
255
+ prompt:,
256
+ label: false,
257
+ **selected,
258
+ **normalize_control_options(html_options)
259
+ )
260
+ end
261
+
262
+ private
97
263
 
98
- field_attributes = options.except(:include_blank, :prompt, :selected)
264
+ def rich_text_field(field_name, label:, errors:, description: nil, control_html: {}, **attributes)
265
+ editor_options = {
266
+ id: field_id(field_name),
267
+ placeholder: attributes[:placeholder],
268
+ required: attributes[:required]
269
+ }.compact.merge(control_html)
270
+ editor = rich_text_area(field_name, editor_options)
271
+
272
+ @template.render(
273
+ NitroKit::Field.new(
274
+ self,
275
+ field_name,
276
+ as: :rich_text,
277
+ label:,
278
+ description:,
279
+ errors:,
280
+ rich_text_content: editor,
281
+ **attributes
282
+ )
283
+ )
284
+ end
285
+
286
+ def accessible_control_aria(field_name, aria)
287
+ has_name = aria.any? do |key, value|
288
+ %w[label labelledby].include?(key.to_s.tr("_", "-")) && value.to_s.present?
289
+ end
290
+ has_name ? aria : aria.merge(label: default_label_for(field_name))
291
+ end
292
+
293
+ def default_label_for(field_name)
294
+ if object && object.class.respond_to?(:human_attribute_name)
295
+ object.class.human_attribute_name(field_name)
296
+ else
297
+ field_name.to_s.humanize
298
+ end
299
+ end
300
+
301
+ def validate_as!(as)
302
+ normalized = as.to_s.tr("-", "_").to_sym
303
+ return normalized if NitroKit::Field::TYPES.include?(normalized)
304
+
305
+ raise ArgumentError,
306
+ "Unknown as: #{as.inspect}; expected one of: #{NitroKit::Field::TYPES.map(&:inspect).join(", ")}"
307
+ end
308
+
309
+ def merge_control_boundary(name, control, shorthand)
310
+ control = (control || {}).symbolize_keys
311
+ shorthand = (shorthand || {}).symbolize_keys
312
+ duplicates = control.keys & shorthand.keys
313
+ if duplicates.any?
314
+ raise ArgumentError, "#{duplicates.first} was given through both #{name}: and control_#{name}:"
315
+ end
316
+
317
+ control.merge(shorthand)
318
+ end
319
+
320
+ def errors_for(field_name)
321
+ return unless object&.respond_to?(:errors) && object.errors.include?(field_name)
322
+
323
+ object.errors.full_messages_for(field_name)
324
+ end
325
+
326
+ def normalize_control_options(options)
327
+ options = options.symbolize_keys
328
+ normalized = options.extract!(*CONTROL_OPTIONS)
329
+ control_html = merge_control_boundary(:html, options.delete(:control_html), options.delete(:html))
330
+ control_data = merge_control_boundary(:data, options.delete(:control_data), options.delete(:data))
331
+ control_aria = merge_control_boundary(:aria, options.delete(:control_aria), options.delete(:aria))
332
+
333
+ normalized.merge(
334
+ control_html: merge_control_boundary(:html, control_html, options),
335
+ control_data:,
336
+ control_aria:
337
+ )
338
+ end
99
339
 
100
- field(method, **field_options, **field_attributes, **html_options, &block)
340
+ def value_for(field_name)
341
+ object.public_send(field_name) if object&.respond_to?(field_name)
101
342
  end
102
343
  end
103
344
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class FormSection < Component
5
+ Child = Data.define(:component, :content)
6
+
7
+ def initialize(
8
+ title: nil,
9
+ description: nil,
10
+ id: nil,
11
+ html: {},
12
+ aria: {},
13
+ data: {},
14
+ desperately_need_a_class: nil
15
+ )
16
+ @title_content = content_from_keyword(:title, title)
17
+ @description_content = content_from_keyword(:description, description)
18
+ @status = nil
19
+ @form = nil
20
+ @title_id = "#{id || "nk-form-section-#{SecureRandom.hex(4)}"}-title"
21
+
22
+ super(
23
+ component: :form_section,
24
+ attributes: { id:, aria: { labelledby: @title_id } }.compact,
25
+ html:,
26
+ aria:,
27
+ data:,
28
+ desperately_need_a_class:
29
+ )
30
+ end
31
+
32
+ def view_template
33
+ yield self if block_given?
34
+ require_content!("FormSection", :title, @title_content)
35
+ raise ArgumentError, "FormSection requires exactly one form" unless @form
36
+
37
+ section(**root_attributes) do
38
+ header(**slot_attributes(:header)) do
39
+ h2(**slot_attributes(:title, attributes: { id: @title_id })) do
40
+ render_deferred_content(@title_content)
41
+ end
42
+ if @description_content
43
+ p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
44
+ end
45
+ end
46
+ render_in_slot(@status.component, :status, &@status.content) if @status
47
+ div(**slot_attributes(:form), &@form)
48
+ end
49
+ end
50
+
51
+ def title(text = nil, &block)
52
+ @title_content = declare_content(:title, @title_content, text, &block)
53
+ nil
54
+ end
55
+
56
+ def description(text = nil, &block)
57
+ @description_content = declare_content(:description, @description_content, text, &block)
58
+ nil
59
+ end
60
+
61
+ def status(component, &content)
62
+ unless component.is_a?(NitroKit::Alert)
63
+ raise ArgumentError, "FormSection status must be a NitroKit::Alert"
64
+ end
65
+ raise ArgumentError, "FormSection accepts at most one status" if @status
66
+
67
+ @status = Child.new(component:, content:)
68
+ nil
69
+ end
70
+
71
+ def form(&content)
72
+ raise ArgumentError, "FormSection form requires a block" unless content
73
+ raise ArgumentError, "FormSection accepts exactly one form" if @form
74
+
75
+ @form = content
76
+ nil
77
+ end
78
+ end
79
+ end