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
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class ProgressiveImage < Component
5
+ include Phlex::Rails::Helpers::Routes
6
+
7
+ SIZES = %i[sm md lg].freeze
8
+ PIXELS = { sm: 320, md: 720, lg: 1_440 }.freeze
9
+ PLACEHOLDER_PIXELS = 48
10
+
11
+ def initialize(
12
+ attachment:,
13
+ alt: nil,
14
+ size: :md,
15
+ decorative: false,
16
+ id: nil,
17
+ html: {},
18
+ aria: {},
19
+ data: {},
20
+ desperately_need_a_class: nil
21
+ )
22
+ @attachment = validate_attachment!(attachment)
23
+ @size = validate_choice!(:size, size, SIZES)
24
+ @decorative = validate_boolean!(:decorative, decorative)
25
+ @attached = attachment&.attached? || false
26
+ @alt = validate_alt!(alt)
27
+
28
+ validate_attached_contract! if @attached
29
+
30
+ super(
31
+ component: :progressive_image,
32
+ attributes: {
33
+ id:,
34
+ aria: { busy: @attached ? true : nil },
35
+ data: {
36
+ state: @attached ? "loading" : "empty",
37
+ controller: @attached ? "nk--progressive-image" : nil
38
+ }.compact
39
+ },
40
+ html:,
41
+ aria:,
42
+ data:,
43
+ size:,
44
+ desperately_need_a_class:
45
+ )
46
+ end
47
+
48
+ attr_reader :attachment, :alt, :size, :decorative
49
+
50
+ def view_template
51
+ div(**root_attributes) do
52
+ if @attached
53
+ render_placeholder
54
+ render_image
55
+ end
56
+
57
+ render_fallback
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def render_placeholder
64
+ img(
65
+ **slot_attributes(
66
+ :placeholder,
67
+ attributes: image_dimensions.merge(
68
+ src: variant_url(PLACEHOLDER_PIXELS),
69
+ alt: "",
70
+ loading: "eager",
71
+ decoding: "async"
72
+ ),
73
+ aria: { hidden: true }
74
+ )
75
+ )
76
+ end
77
+
78
+ def render_image
79
+ pixels = PIXELS.fetch(size)
80
+ image_url = variant_url(pixels)
81
+
82
+ img(
83
+ **slot_attributes(
84
+ :image,
85
+ attributes: image_dimensions.merge(
86
+ src: image_url,
87
+ srcset: "#{image_url} 1x, #{variant_url(pixels * 2)} 2x",
88
+ alt:,
89
+ loading: "lazy",
90
+ decoding: "async",
91
+ data: { nk__progressive_image_target: "image" }
92
+ )
93
+ )
94
+ )
95
+ end
96
+
97
+ def render_fallback
98
+ visible = !@attached
99
+ accessible = !decorative
100
+
101
+ span(
102
+ **slot_attributes(
103
+ :fallback,
104
+ attributes: {
105
+ hidden: visible ? nil : true,
106
+ # Only an attached image can change state after render, so only
107
+ # that fallback is a live region.
108
+ role: (accessible && @attached) ? "status" : nil
109
+ },
110
+ aria: {
111
+ hidden: accessible ? nil : true
112
+ },
113
+ data: @attached ? { nk__progressive_image_target: "fallback" } : {}
114
+ )
115
+ ) { fallback_text }
116
+ end
117
+
118
+ def fallback_text
119
+ alt.strip.empty? ? I18n.t("nitro_kit.progressive_image.unavailable") : alt
120
+ end
121
+
122
+ def variant_url(pixels)
123
+ representation = attachment.variant(resize_to_limit: [ pixels, pixels ])
124
+ return representation if representation.is_a?(String)
125
+
126
+ url_for(representation)
127
+ end
128
+
129
+ def image_dimensions
130
+ blob = attachment.blob
131
+ return {} if blob.respond_to?(:analyzed?) && !blob.analyzed?
132
+
133
+ metadata = blob.metadata
134
+ return {} unless metadata.is_a?(Hash)
135
+
136
+ width = metadata["width"] || metadata[:width]
137
+ height = metadata["height"] || metadata[:height]
138
+ return {} unless positive_dimension?(width) && positive_dimension?(height)
139
+
140
+ { width:, height: }
141
+ end
142
+
143
+ def positive_dimension?(value)
144
+ value.is_a?(Numeric) && value.positive?
145
+ end
146
+
147
+ def validate_attachment!(value)
148
+ return value if value.nil? || value.respond_to?(:attached?)
149
+
150
+ raise ArgumentError, "attachment must be nil or an Active Storage attachment"
151
+ end
152
+
153
+ def validate_attached_contract!
154
+ missing = %i[variant blob].reject { |method| attachment.respond_to?(method) }
155
+ unless missing.empty?
156
+ raise ArgumentError, "attached image must expose #{missing.join(' and ')}"
157
+ end
158
+
159
+ blob = attachment.blob
160
+ unless blob.respond_to?(:image?) && blob.image? && blob.respond_to?(:variable?) && blob.variable?
161
+ raise ArgumentError, "attached image must have an image blob that supports variants"
162
+ end
163
+ end
164
+
165
+ def validate_alt!(value)
166
+ unless value.nil? || value.is_a?(String)
167
+ raise ArgumentError, "alt must be a String or nil"
168
+ end
169
+ return "" if decorative
170
+
171
+ if value.nil?
172
+ raise ArgumentError, "alt: is required unless decorative: true"
173
+ end
174
+ if @attached && value.strip.empty?
175
+ raise ArgumentError, "attached non-decorative images require non-blank alt text"
176
+ end
177
+
178
+ value
179
+ end
180
+ end
181
+ end
@@ -2,34 +2,75 @@
2
2
 
3
3
  module NitroKit
4
4
  class RadioButton < Component
5
- def initialize(label: nil, id: nil, wrapper: {}, size: :md, **attrs)
6
- @label = label
7
- @id = id || "nk--" + SecureRandom.hex(4)
8
- @size = size
9
- @wrapper = wrapper
5
+ SIZES = %i[md lg].freeze
6
+
7
+ def initialize(
8
+ label: nil,
9
+ description: nil,
10
+ id: nil,
11
+ name: nil,
12
+ value: "1",
13
+ checked: false,
14
+ disabled: false,
15
+ required: false,
16
+ invalid: false,
17
+ size: :md,
18
+ html: {},
19
+ aria: {},
20
+ data: {},
21
+ control_html: {},
22
+ control_aria: {},
23
+ control_data: {},
24
+ desperately_need_a_class: nil
25
+ )
26
+ @label = validate_optional_text!(:label, label)
27
+ @description = validate_optional_text!(:description, description)
28
+ @id = id
29
+ @name = name
30
+ @value = value
31
+ @checked = validate_boolean!(:checked, checked)
32
+ @disabled = validate_boolean!(:disabled, disabled)
33
+ @required = validate_boolean!(:required, required)
34
+ @invalid = validate_boolean!(:invalid, invalid)
35
+ @size = validate_choice!(:size, size, SIZES)
36
+ @control_html = control_html
37
+ @control_aria = control_aria
38
+ @control_data = control_data
39
+
40
+ if description && (!id.is_a?(String) || id.strip.empty?)
41
+ raise ArgumentError, "radio button requires a non-blank String id when description is present"
42
+ end
10
43
 
11
44
  super(
12
- attrs,
13
- id: @id,
14
- type: "radio",
15
- class: input_class
45
+ component: :radio_button,
46
+ size: @size,
47
+ attributes: {
48
+ data: { disabled: @disabled ? "true" : nil }.compact
49
+ },
50
+ html:,
51
+ aria:,
52
+ data:,
53
+ desperately_need_a_class:
16
54
  )
17
55
  end
18
56
 
19
- alias :html_label :label
57
+ attr_reader :label, :description, :id, :name, :value, :size
20
58
 
21
- attr_reader :id, :label, :size, :wrapper
59
+ def view_template(&block)
60
+ require_accessible_name!(&block)
22
61
 
23
- def view_template
24
- div(**mattr(wrapper, class: wrapper_class)) do
25
- html_label(class: merge_class("inline-grid *:[grid-area:1/1] shrink-0 place-items-center", size_class)) do
26
- input(**attrs)
27
- dot
28
- end
29
-
30
- if label.present? || block_given?
31
- render(Label.new(for: id)) do
32
- label || (block_given? ? yield : nil)
62
+ div(**root_attributes) do
63
+ if label.nil? && !block
64
+ render_control
65
+ span(**slot_attributes(:indicator), aria: { hidden: "true" })
66
+ else
67
+ render_in_slot(Label.new(for: id), :label) do
68
+ render_control
69
+ span(**slot_attributes(:indicator), aria: { hidden: "true" })
70
+ span(**slot_attributes(:content)) do
71
+ span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
72
+ span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) } if description
73
+ end
33
74
  end
34
75
  end
35
76
  end
@@ -37,41 +78,22 @@ module NitroKit
37
78
 
38
79
  private
39
80
 
40
- def dot
41
- svg(
42
- class: merge_class(
43
- "text-primary opacity-0 pointer-events-none",
44
- "peer-checked:opacity-100"
81
+ def render_control
82
+ render_in_slot(
83
+ Input.new(
84
+ type: :radio,
85
+ id:,
86
+ name:,
87
+ value:,
88
+ checked: @checked,
89
+ disabled: @disabled,
90
+ required: @required,
91
+ html: @control_html,
92
+ aria: control_aria,
93
+ data: @control_data
45
94
  ),
46
- viewbox: "0 0 20 20",
47
- fill: "currentColor",
48
- stroke: "none"
49
- ) do |svg|
50
- svg.circle(cx: 10, cy: 10, r: 10)
51
- end
52
- end
53
-
54
- def wrapper_class
55
- "inline-flex items-center gap-2"
56
- end
57
-
58
- def input_class
59
- [
60
- "peer appearance-none shadow-sm rounded-full border text-foreground bg-background",
61
- "[&[aria-checked='true']]:bg-primary",
62
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
63
- ]
64
- end
65
-
66
- def size_class
67
- case size
68
- when :md
69
- "[&>input]:size-5 [&>svg]:size-2.5"
70
- when :lg
71
- "[&>input]:size-7 [&>svg]:size-3.5"
72
- else
73
- raise ArgumentError, "Unknown size `#{size}'"
74
- end
95
+ :control
96
+ )
75
97
  end
76
98
  end
77
99
  end
@@ -2,56 +2,127 @@
2
2
 
3
3
  module NitroKit
4
4
  class RadioButtonGroup < Component
5
- def initialize(options_arg = nil, options: [], name: nil, value: nil, **attrs)
6
- @options = options_arg || options
5
+ ORIENTATIONS = %i[vertical horizontal].freeze
6
+ PRESENTATIONS = %i[list cards segmented].freeze
7
7
 
8
- @name = name
9
- @group_value = value
8
+ alias :html_legend :legend
9
+
10
+ def initialize(
11
+ legend:,
12
+ options:,
13
+ name:,
14
+ value: nil,
15
+ id: nil,
16
+ description: nil,
17
+ orientation: :vertical,
18
+ presentation: :list,
19
+ disabled: false,
20
+ required: false,
21
+ size: :md,
22
+ html: {},
23
+ aria: {},
24
+ data: {},
25
+ desperately_need_a_class: nil
26
+ )
27
+ @legend = validate_text!(:legend, legend)
28
+ @options = Array(options).map { |choice| Choice.coerce(choice) }.freeze
29
+ @name = validate_text!(:name, name)
30
+ @value = value
31
+ @id = id || deterministic_id(name)
32
+ @description = validate_optional_text!(:description, description)
33
+ @orientation = validate_choice!(:orientation, orientation, ORIENTATIONS)
34
+ @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
35
+ @disabled = validate_boolean!(:disabled, disabled)
36
+ @required = validate_boolean!(:required, required)
37
+ @size = validate_choice!(:size, size, RadioButton::SIZES)
38
+
39
+ raise ArgumentError, "options cannot be empty" if @options.empty?
40
+ validate_unique_choices!
10
41
 
11
42
  super(
12
- attrs,
13
- name: @name,
14
- class: "flex items-start flex-col gap-2"
43
+ component: :radio_button_group,
44
+ size: @size,
45
+ attributes: {
46
+ id: @id,
47
+ disabled: @disabled,
48
+ data: {
49
+ orientation: @orientation,
50
+ presentation: @presentation,
51
+ required: @required ? "true" : nil
52
+ }.compact
53
+ }.compact,
54
+ html:,
55
+ aria:,
56
+ data:,
57
+ desperately_need_a_class:
15
58
  )
16
59
  end
17
60
 
18
- attr_reader :name, :group_value, :options
61
+ attr_reader :legend, :options, :name, :value, :description, :id, :size, :orientation, :presentation
19
62
 
20
63
  def view_template
21
- div(**attrs) do
22
- if block_given?
23
- yield
24
- else
25
- options.map { |o| item(*o) }
64
+ fieldset(**root_attributes) do
65
+ html_legend(**slot_attributes(:legend)) { plain(legend) }
66
+ if description
67
+ p(**slot_attributes(:description, attributes: { id: description_id }.compact)) do
68
+ plain(description.to_s)
69
+ end
26
70
  end
27
- end
28
- end
29
71
 
30
- def title(text = nil, **attrs, &block)
31
- builder do
32
- render(Label.new(**attrs)) do
33
- text_or_block(text, &block)
72
+ div(**slot_attributes(:choices)) do
73
+ options.each_with_index { |choice, index| render_choice(choice, index) }
34
74
  end
35
75
  end
36
76
  end
37
77
 
38
- def item(text = nil, value_as_arg = nil, value: nil, **attrs, &block)
39
- builder do
40
- value ||= value_as_arg
41
-
42
- render(
43
- RadioButton.new(
44
- **mattr(
45
- attrs,
46
- name: attrs.fetch(:name, name),
47
- value:,
48
- checked: group_value.presence == value
49
- )
50
- )
51
- ) do
52
- text_or_block(text, &block)
53
- end
54
- end
78
+ private
79
+
80
+ # The fieldset and its legend already scope the group description, so a
81
+ # choice never repeats it through aria-describedby.
82
+ def render_choice(choice, index)
83
+ render_in_slot(
84
+ RadioButton.new(
85
+ label: choice.label,
86
+ description: choice.description,
87
+ id: choice.id || choice_id(index),
88
+ name:,
89
+ value: choice.value,
90
+ checked: selected?(choice),
91
+ disabled: @disabled || choice.disabled,
92
+ required: @required,
93
+ size:
94
+ ),
95
+ :choice
96
+ )
97
+ end
98
+
99
+ def selected?(choice)
100
+ !value.nil? && value.to_s == choice.value.to_s
101
+ end
102
+
103
+ def deterministic_id(name)
104
+ derived = name.to_s.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
105
+ return derived unless derived.empty?
106
+
107
+ raise ArgumentError, "name #{name.inspect} cannot derive an id; pass id:"
108
+ end
109
+
110
+ def choice_id(index)
111
+ "#{id}-#{index}" if id
112
+ end
113
+
114
+ def validate_text!(name, text)
115
+ return text if text.is_a?(String) && !text.strip.empty?
116
+
117
+ raise ArgumentError, "#{name} must be a non-blank String"
118
+ end
119
+
120
+ def validate_unique_choices!
121
+ duplicate_value = options.group_by { |choice| choice.value.to_s }.find { |_value, matches| matches.many? }
122
+ raise ArgumentError, "choice values must be unique" if duplicate_value
123
+
124
+ ids = options.each_with_index.filter_map { |choice, index| choice.id || choice_id(index) }
125
+ raise ArgumentError, "choice ids must be unique" unless ids.uniq.length == ids.length
55
126
  end
56
127
  end
57
128
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class ResponsiveValue
5
+ BREAKPOINTS = {
6
+ "sm" => "40rem",
7
+ "md" => "48rem",
8
+ "lg" => "64rem",
9
+ "xl" => "80rem",
10
+ "2xl" => "96rem"
11
+ }.freeze
12
+
13
+ def initialize(property:, value:, allowed:)
14
+ @property = property
15
+ @allowed = allowed.map { |candidate| normalize(candidate) }.freeze
16
+ @values = parse(value).freeze
17
+ freeze
18
+ end
19
+
20
+ def to_s
21
+ [ nil, *BREAKPOINTS.keys ].filter_map do |breakpoint|
22
+ value = @values[breakpoint]
23
+ next unless value
24
+
25
+ breakpoint ? "#{breakpoint}:#{value}" : value
26
+ end.join(" ")
27
+ end
28
+
29
+ private
30
+
31
+ def parse(value)
32
+ tokens = tokens_from(value)
33
+ values = {}
34
+
35
+ tokens.each do |token|
36
+ breakpoint, candidate = split(token)
37
+ validate_breakpoint!(breakpoint) if breakpoint
38
+ validate_value!(candidate)
39
+
40
+ if values.key?(breakpoint)
41
+ label = breakpoint ? "breakpoint #{breakpoint.inspect}" : "base value"
42
+ raise ArgumentError, "Duplicate #{@property} #{label}"
43
+ end
44
+
45
+ values[breakpoint] = candidate
46
+ end
47
+
48
+ unless values.key?(nil)
49
+ raise ArgumentError, "#{@property} must include an unprefixed base value"
50
+ end
51
+
52
+ values
53
+ end
54
+
55
+ def tokens_from(value)
56
+ tokens = case value
57
+ when String
58
+ value.split
59
+ when Integer, Symbol
60
+ [ normalize(value) ]
61
+ else
62
+ raise ArgumentError,
63
+ "#{@property} must be an Integer, Symbol, or String; received #{value.inspect}"
64
+ end
65
+
66
+ raise ArgumentError, "#{@property} cannot be blank" if tokens.empty?
67
+
68
+ tokens
69
+ end
70
+
71
+ def normalize(value)
72
+ value.to_s.tr("_", "-")
73
+ end
74
+
75
+ def split(token)
76
+ breakpoint, separator, value = token.partition(":")
77
+ separator.empty? ? [ nil, breakpoint ] : [ breakpoint, value ]
78
+ end
79
+
80
+ def validate_breakpoint!(breakpoint)
81
+ return if BREAKPOINTS.key?(breakpoint)
82
+
83
+ raise ArgumentError,
84
+ "Unknown #{@property} breakpoint #{breakpoint.inspect}; expected one of: #{BREAKPOINTS.keys.join(", ")}"
85
+ end
86
+
87
+ def validate_value!(value)
88
+ return if @allowed.include?(value)
89
+
90
+ raise ArgumentError,
91
+ "Unknown #{@property} value #{value.inspect}; expected one of: #{@allowed.join(", ")}"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class RichTextArea < Component
5
+ def initialize(content, id: nil, data: {}, aria: {}, html: {}, desperately_need_a_class: nil)
6
+ @content = validate_content!(content)
7
+
8
+ super(component: :rich_text_area, attributes: { id: }.compact, data:, aria:, html:, desperately_need_a_class:)
9
+ end
10
+
11
+ attr_reader :content
12
+
13
+ def view_template
14
+ div(**root_attributes) do
15
+ div(**slot_attributes(:editor)) { raw(content) }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ # The host application's rich-text helper owns the editor markup. Nitro
22
+ # only wraps it, so it must already be trusted output rather than a String
23
+ # this component would have to escape or mark safe on the caller's behalf.
24
+ def validate_content!(content)
25
+ raise ArgumentError, "content is required" if content.nil?
26
+ return content if content.is_a?(ActiveSupport::SafeBuffer) && content.html_safe?
27
+
28
+ raise ArgumentError, "RichTextArea content must be an ActiveSupport::SafeBuffer"
29
+ end
30
+ end
31
+ end