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,22 +2,168 @@
2
2
 
3
3
  module NitroKit
4
4
  class AvatarStack < Component
5
- def initialize(size: :md, **attrs)
6
- @size = size
5
+ SIZES = Avatar::SIZES
7
6
 
8
- super(attrs)
7
+ Overflow = ::Data.define(:count, :label, :html, :aria, :data, :css_class)
8
+ private_constant :Overflow
9
+
10
+ def initialize(
11
+ label:,
12
+ size: :md,
13
+ max: nil,
14
+ id: nil,
15
+ html: {},
16
+ aria: {},
17
+ data: {},
18
+ desperately_need_a_class: nil
19
+ )
20
+ unless label.is_a?(String) && label.present?
21
+ raise ArgumentError, "AvatarStack label: must be a non-blank String"
22
+ end
23
+ raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
24
+ if aria.keys.any? { |key| key.to_s.downcase.tr("_", "-") == "label" }
25
+ raise ArgumentError, "AvatarStack group label is owned by label:"
26
+ end
27
+ unless max.nil? || (max.is_a?(Integer) && max.positive?)
28
+ raise ArgumentError, "max must be a positive Integer or nil"
29
+ end
30
+
31
+ @size = validate_choice!(:size, size, SIZES)
32
+ @max = max
33
+ @avatars = []
34
+ @overflow = nil
35
+
36
+ super(
37
+ component: :avatar_stack,
38
+ attributes: { id:, role: "group" },
39
+ html:,
40
+ aria: aria.merge(label:),
41
+ data:,
42
+ size:,
43
+ desperately_need_a_class:
44
+ )
9
45
  end
10
46
 
11
- attr_reader :size
47
+ attr_reader :size, :max
48
+
49
+ def view_template(&block)
50
+ collect_declarations(&block)
51
+
52
+ visible, hidden = partition_avatars
12
53
 
13
- def view_template
14
- div(**mattr(attrs, class: "flex items-center -space-x-3 [&>div]:ring-2 [&>div]:ring-background")) do
15
- yield
54
+ span(**root_attributes) do
55
+ visible.each { |avatar| render_in_slot(avatar, :avatar) }
56
+ render_overflow(overflow_declaration(hidden))
16
57
  end
17
58
  end
18
59
 
19
- def avatar(*args, **attrs, &block)
20
- render(Avatar.new(*args, size:, **attrs, &block))
60
+ def avatar(
61
+ src: nil,
62
+ alt: "",
63
+ fallback: nil,
64
+ loading: "lazy",
65
+ decoding: "async",
66
+ id: nil,
67
+ html: {},
68
+ aria: {},
69
+ data: {},
70
+ desperately_need_a_class: nil
71
+ )
72
+ ensure_collecting!
73
+
74
+ @avatars << Avatar.new(
75
+ src:,
76
+ alt:,
77
+ fallback:,
78
+ size:,
79
+ loading:,
80
+ decoding:,
81
+ id:,
82
+ html:,
83
+ aria:,
84
+ data:,
85
+ desperately_need_a_class:
86
+ )
87
+ nil
88
+ end
89
+
90
+ def overflow(
91
+ count,
92
+ label: nil,
93
+ html: {},
94
+ aria: {},
95
+ data: {},
96
+ desperately_need_a_class: nil
97
+ )
98
+ ensure_collecting!
99
+ raise ArgumentError, "AvatarStack accepts at most one overflow" if @overflow
100
+ if max
101
+ raise ArgumentError, "AvatarStack max: already owns the overflow indicator"
102
+ end
103
+ unless count.is_a?(Integer) && count.positive?
104
+ raise ArgumentError, "count must be a positive Integer"
105
+ end
106
+ raise ArgumentError, "label must be a String or nil" unless label.nil? || label.is_a?(String)
107
+ raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
108
+ if aria.keys.any? { |key| key.to_s.downcase.tr("_", "-") == "label" }
109
+ raise ArgumentError, "AvatarStack overflow label is owned by label:"
110
+ end
111
+
112
+ @overflow = Overflow.new(
113
+ count:,
114
+ label:,
115
+ html:,
116
+ aria:,
117
+ data:,
118
+ css_class: desperately_need_a_class
119
+ )
120
+ nil
121
+ end
122
+
123
+ private
124
+
125
+ def collect_declarations
126
+ return unless block_given?
127
+
128
+ @collecting = true
129
+ yield(self)
130
+ ensure
131
+ @collecting = false
132
+ end
133
+
134
+ def ensure_collecting!
135
+ return if @collecting
136
+
137
+ raise ArgumentError, "AvatarStack items must be declared inside the render block"
138
+ end
139
+
140
+ def partition_avatars
141
+ return [ @avatars, [] ] unless max
142
+
143
+ [ @avatars.first(max), @avatars.drop(max) ]
144
+ end
145
+
146
+ def overflow_declaration(hidden)
147
+ return @overflow unless max
148
+ return nil if hidden.empty?
149
+
150
+ Overflow.new(count: hidden.size, label: nil, html: {}, aria: {}, data: {}, css_class: nil)
151
+ end
152
+
153
+ def render_overflow(declaration)
154
+ return unless declaration
155
+
156
+ span(
157
+ **slot_attributes(
158
+ :overflow,
159
+ html: declaration.html,
160
+ aria: declaration.aria.merge(
161
+ label: declaration.label || I18n.t("nitro_kit.avatar_stack.overflow", count: declaration.count)
162
+ ),
163
+ data: declaration.data,
164
+ desperately_need_a_class: declaration.css_class
165
+ )
166
+ ) { "+#{declaration.count}" }
21
167
  end
22
168
  end
23
169
  end
@@ -2,100 +2,62 @@
2
2
 
3
3
  module NitroKit
4
4
  class Badge < Component
5
- VARIANTS = %i[default outline]
5
+ VARIANTS = %i[default outline].freeze
6
+ SIZES = %i[xs sm md].freeze
7
+ COLORS = %i[
8
+ zinc red orange amber yellow lime green emerald teal cyan sky blue indigo
9
+ violet purple fuchsia pink rose neutral info success warning danger
10
+ ].freeze
11
+
12
+ def initialize(
13
+ text = nil,
14
+ variant: :default,
15
+ size: :md,
16
+ color: :zinc,
17
+ id: nil,
18
+ html: {},
19
+ aria: {},
20
+ data: {},
21
+ desperately_need_a_class: nil
22
+ )
23
+ if !text.nil? && text.to_s.strip.empty?
24
+ raise ArgumentError, "Badge label content is required"
25
+ end
6
26
 
7
- def initialize(text = nil, variant: :default, size: :md, color: :gray, **attrs)
8
27
  @text = text
9
- @variant = variant
10
- @size = size
11
- @color = color
28
+ @variant = validate_choice!(:variant, variant, VARIANTS)
29
+ @size = validate_choice!(:size, size, SIZES)
30
+ @color = validate_choice!(:color, color, COLORS)
12
31
 
13
32
  super(
14
- attrs,
15
- class: [
16
- base_class,
17
- variant_class,
18
- size_class
19
- ]
33
+ component: :badge,
34
+ attributes: {
35
+ id:,
36
+ data: { color: }
37
+ },
38
+ html:,
39
+ aria:,
40
+ data:,
41
+ variant:,
42
+ size:,
43
+ desperately_need_a_class:
20
44
  )
21
45
  end
22
46
 
23
47
  attr_reader :text, :variant, :size, :color
24
48
 
25
49
  def view_template(&block)
26
- span(**attrs) do
27
- text_or_block(text, &block)
50
+ if block && !text.nil?
51
+ raise ArgumentError, "Badge accepts label text or block content, not both"
28
52
  end
29
- end
30
-
31
- private
32
-
33
- def base_class
34
- "inline-flex items-center gap-x-1.5 rounded-md font-medium whitespace-nowrap"
35
- end
36
-
37
- def variant_class
38
- case variant
39
- when :default
40
- color_class
41
- when :outline
42
- "border"
43
- else
44
- raise ArgumentError, "Invalid variant: #{variant}"
53
+ if !block && text.nil?
54
+ raise ArgumentError, "Badge label content is required"
45
55
  end
46
- end
47
-
48
- def size_class
49
- case size
50
- when :sm
51
- "text-xs px-1.5 py-0.5"
52
- when :md
53
- "text-sm px-2 py-0.5"
54
- else
55
- raise ArgumentError, "Invalid size: #{size}"
56
- end
57
- end
58
56
 
59
- def color_class
60
- case color
61
- when :gray
62
- "text-zinc-700 dark:text-zinc-200 bg-zinc-400/15 dark:bg-zinc-400/40"
63
- when :red
64
- "text-red-900 dark:text-red-200 bg-red-300/50 dark:bg-red-400/40"
65
- when :orange
66
- "text-orange-700 dark:text-orange-200 bg-orange-400/20 dark:bg-orange-400/40"
67
- when :amber
68
- "text-amber-700 dark:text-amber-200 bg-amber-400/20 dark:bg-amber-400/40"
69
- when :yellow
70
- "text-yellow-900 dark:text-yellow-200 bg-yellow-400/40 dark:bg-yellow-400/40"
71
- when :lime
72
- "text-lime-700 dark:text-lime-200 bg-lime-400/20 dark:bg-lime-400/40"
73
- when :green
74
- "text-green-800 dark:text-green-200 bg-green-500/20 dark:bg-green-400/40"
75
- when :emerald
76
- "text-emerald-700 dark:text-emerald-200 bg-emerald-400/20 dark:bg-emerald-400/40"
77
- when :teal
78
- "text-teal-700 dark:text-teal-200 bg-teal-400/20 dark:bg-teal-400/40"
79
- when :cyan
80
- "text-cyan-700 dark:text-cyan-200 bg-cyan-400/20 dark:bg-cyan-400/40"
81
- when :sky
82
- "text-sky-700 dark:text-sky-200 bg-sky-400/20 dark:bg-sky-400/40"
83
- when :blue
84
- "text-blue-700 dark:text-blue-200 bg-blue-400/20 dark:bg-blue-400/40"
85
- when :indigo
86
- "text-indigo-700 dark:text-indigo-200 bg-indigo-400/20 dark:bg-indigo-400/40"
87
- when :violet
88
- "text-violet-700 dark:text-violet-200 bg-violet-400/20 dark:bg-violet-400/40"
89
- when :purple
90
- "text-purple-700 dark:text-purple-200 bg-purple-400/20 dark:bg-purple-400/40"
91
- when :fuchsia
92
- "text-fuchsia-700 dark:text-fuchsia-200 bg-fuchsia-400/20 dark:bg-fuchsia-400/40"
93
- when :pink
94
- "text-pink-700 dark:text-pink-200 bg-pink-400/20 dark:bg-pink-400/40"
95
- when :rose
96
- "text-rose-700 dark:text-rose-200 bg-rose-400/20 dark:bg-rose-400/40"
97
- else
98
- raise ArgumentError, "Unknown color `#{color}'"
57
+ span(**root_attributes) do
58
+ span(**slot_attributes(:label)) do
59
+ block ? text_or_block(nil, &block) : plain(text.to_s)
60
+ end
99
61
  end
100
62
  end
101
63
  end
@@ -2,7 +2,13 @@
2
2
 
3
3
  module NitroKit
4
4
  class Button < Component
5
- VARIANTS = %i[default primary destructive ghost]
5
+ VARIANTS = %i[default primary destructive ghost].freeze
6
+ SIZES = %i[xs sm md lg xl].freeze
7
+ TYPES = %i[button submit reset].freeze
8
+ SUBMISSION_INDICATORS = %i[spinner].freeze
9
+ DEFAULT_TYPE = :button
10
+ UNSET_TYPE = Object.new.freeze
11
+ private_constant :UNSET_TYPE
6
12
 
7
13
  def initialize(
8
14
  text = nil,
@@ -10,137 +16,208 @@ module NitroKit
10
16
  variant: :default,
11
17
  size: :md,
12
18
  icon: nil,
13
- icon_right: nil,
14
- **attrs
19
+ icon_end: nil,
20
+ label: nil,
21
+ id: nil,
22
+ type: UNSET_TYPE,
23
+ name: nil,
24
+ value: nil,
25
+ form: nil,
26
+ target: nil,
27
+ rel: nil,
28
+ download: nil,
29
+ disabled: false,
30
+ loading: false,
31
+ submission_indicator: nil,
32
+ html: {},
33
+ aria: {},
34
+ data: {},
35
+ desperately_need_a_class: nil
15
36
  )
37
+ if !href.nil? && (!href.is_a?(String) || href.empty?)
38
+ raise ArgumentError, "Button href must be nil or a non-blank String"
39
+ end
40
+ if href && !type.equal?(UNSET_TYPE)
41
+ raise ArgumentError, "Link Buttons do not accept type; type: applies to native button elements"
42
+ end
43
+ if href && [ name, value, form ].any? { |option| !option.nil? }
44
+ raise ArgumentError, "Link Buttons do not accept name, value, or form"
45
+ end
46
+ if !href && [ target, rel, download ].any? { |option| !option.nil? }
47
+ raise ArgumentError, "Button elements do not accept target, rel, or download"
48
+ end
49
+ if !label.nil? && (!label.is_a?(String) || label.strip.empty?)
50
+ raise ArgumentError, "Button label: must be a non-blank String"
51
+ end
52
+ if text.is_a?(String) && text.strip.empty?
53
+ raise ArgumentError, "Button text must be non-blank"
54
+ end
55
+
16
56
  @text = text
17
57
  @href = href
18
58
  @icon = icon
19
- @icon_right = icon_right
20
- @size = size
21
- @variant = variant
59
+ @icon_end = icon_end
60
+ @label = label
61
+ @variant = validate_choice!(:variant, variant, VARIANTS)
62
+ @size = validate_choice!(:size, size, SIZES)
63
+ @type = validate_choice!(:type, resolved_type(type), TYPES)
64
+ @loading = validate_boolean!(:loading, loading)
65
+ @disabled = validate_boolean!(:disabled, disabled) || @loading
66
+ @aria = aria
67
+ @submission_feedback = submission_feedback(data)
68
+ @submission_indicator = if submission_indicator.nil?
69
+ nil
70
+ else
71
+ validate_choice!(:submission_indicator, submission_indicator, SUBMISSION_INDICATORS)
72
+ end
73
+ if @submission_indicator && (href || @type != :submit)
74
+ raise ArgumentError, "submission_indicator: applies only to native submit Buttons"
75
+ end
76
+ if @submission_indicator && @loading
77
+ raise ArgumentError, "submission_indicator: and loading: cannot be combined"
78
+ end
79
+
80
+ owned_aria = {
81
+ label: @label,
82
+ busy: @loading ? true : nil
83
+ }.compact
84
+
85
+ native_attributes = if href
86
+ {
87
+ id:,
88
+ href: @disabled ? nil : href,
89
+ target:,
90
+ rel:,
91
+ download:,
92
+ tabindex: @disabled ? -1 : nil,
93
+ aria: owned_aria
94
+ }.compact.tap do |attributes|
95
+ if @disabled
96
+ attributes[:href] = nil
97
+ attributes[:aria] = attributes[:aria].merge(disabled: true)
98
+ end
99
+ attributes.delete(:aria) if attributes[:aria].empty?
100
+ end
101
+ else
102
+ {
103
+ id:,
104
+ type: @type,
105
+ name:,
106
+ value:,
107
+ form:,
108
+ disabled: @disabled,
109
+ aria: owned_aria.presence,
110
+ data: submission_data
111
+ }.compact
112
+ end
22
113
 
23
114
  super(
24
- attrs,
25
- class: [
26
- base_class,
27
- variant_class,
28
- size_class
29
- ]
115
+ component: :button,
116
+ attributes: native_attributes,
117
+ html:,
118
+ aria:,
119
+ data:,
120
+ variant:,
121
+ size:,
122
+ desperately_need_a_class:
30
123
  )
31
124
  end
32
125
 
33
- attr_reader(
34
- :text,
35
- :href,
36
- :icon,
37
- :icon_right,
38
- :size,
39
- :variant
40
- )
126
+ attr_reader :text, :href, :icon, :icon_end, :label, :size, :variant
127
+
128
+ def loading? = @loading
41
129
 
42
130
  def view_template(&block)
43
- if href
44
- a(href:, **attrs) do
45
- contents(&block)
46
- end
47
- else
48
- button(type: :button, **attrs) do
49
- contents(&block)
50
- end
131
+ raise ArgumentError, "Button accepts text or a block, not both" if !text.nil? && block
132
+ unless !text.nil? || block || icon || icon_end
133
+ raise ArgumentError, "Button requires text, a block, or an icon"
134
+ end
135
+ # Only render time knows whether a block supplies the visible label.
136
+ if text.nil? && !block && !accessible_label?
137
+ raise ArgumentError, "Icon-only Button requires label:, aria: { label: }, or aria: { labelledby: }"
51
138
  end
139
+
140
+ tag = href ? :a : :button
141
+ public_send(tag, **root_attributes) { contents(&block) }
52
142
  end
53
143
 
54
144
  private
55
145
 
56
- def contents(&block)
57
- has_content = text.present? || block_given?
146
+ def resolved_type(type)
147
+ return DEFAULT_TYPE if type.equal?(UNSET_TYPE)
148
+
149
+ type.to_s.to_sym
150
+ end
151
+
152
+ def submission_data
153
+ return unless @submission_feedback || @submission_indicator
58
154
 
59
- if !has_content
60
- return render(Icon.new(icon))
155
+ {
156
+ controller: "nk--button",
157
+ action: [
158
+ "submit@document->nk--button#submit",
159
+ "turbo:submit-end@document->nk--button#reset",
160
+ "turbo:before-cache@document->nk--button#reset"
161
+ ].join(" ")
162
+ }
163
+ end
164
+
165
+ def submission_feedback(data)
166
+ data.each do |key, value|
167
+ next unless normalized_data_attribute(key) == "turbo-submits-with"
168
+ return if value == false || value.nil?
169
+ return value if value.is_a?(String) && !value.strip.empty?
170
+
171
+ raise ArgumentError, "data-turbo-submits-with must be a non-blank String"
61
172
  end
62
173
 
63
- render(Icon.new(icon)) if icon
174
+ nil
175
+ end
64
176
 
65
- if block_given?
66
- yield
67
- else
68
- span { plain(text) }
177
+ def contents(&block)
178
+ icon_only = text.nil? && !block
179
+ submission_spinner if @submission_indicator == :spinner
180
+ spinner(icon_only:) if loading?
181
+ icon_slot(icon, :icon_start, icon_only:) if icon && !loading?
182
+
183
+ if block || !text.nil?
184
+ span(**slot_attributes(:label)) do
185
+ block ? yield : plain(text.to_s)
186
+ end
69
187
  end
70
188
 
71
- render(Icon.new(icon_right)) if icon_right
189
+ icon_slot(icon_end, :icon_end, icon_only:) if icon_end
72
190
  end
73
191
 
74
- def base_class
75
- [
76
- "inline-flex items-center cursor-pointer shrink-0 justify-center rounded-md border gap-2 font-medium select-none",
77
- # Disabled
78
- "disabled:opacity-70 disabled:pointer-events-none",
79
- # Focus
80
- "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-ring ring-offset-background",
81
- # Icon
82
- "[&_svg]:pointer-events-none [&_svg]:shrink-0"
83
- ]
192
+ def submission_spinner
193
+ span(**slot_attributes(:submission_spinner, attributes: { aria: { hidden: true } })) do
194
+ render(Icon.new(:"loader-circle", size: icon_size))
195
+ end
84
196
  end
85
197
 
86
- def variant_class
87
- case variant
88
- when :default
89
- [
90
- "bg-background text-foreground border-border",
91
- "hover:bg-zinc-50 dark:hover:bg-zinc-900"
92
- ]
93
- when :primary
94
- [
95
- "bg-primary text-primary-foreground border-primary",
96
- "hover:bg-primary/90 dark:hover:bg-primary/90"
97
- ]
98
- when :destructive
99
- [
100
- "bg-destructive text-destructive-foreground border-destructive",
101
- "hover:bg-destructive/90 dark:hover:bg-destructive/90",
102
- "disabled:text-destructive-foreground/80"
103
- ]
104
- when :ghost
105
- [
106
- "bg-transparent text-foreground border-transparent",
107
- "hover:bg-zinc-200/50 dark:hover:bg-zinc-900",
108
- "disabled:text-muted-content"
109
- ]
110
- else
111
- raise ArgumentError, "Unknown variant `#{variant}'"
198
+ def spinner(icon_only:)
199
+ span(**slot_attributes(:spinner, attributes: { aria: { hidden: true } })) do
200
+ render(Icon.new(:"loader-circle", size: icon_only ? :md : icon_size))
112
201
  end
113
202
  end
114
203
 
115
- def size_class
116
- case size
117
- when :xs
118
- "px-1.5 h-6 text-xs [&_svg]:size-3"
119
- when :sm
120
- [
121
- "px-2.5 h-7 text-sm [&_svg]:size-4",
122
- "[&_svg:first-child:last-child]:-mx-1"
123
- ]
124
- when :md
125
- [
126
- "px-4 h-10 text-base [&_svg]:size-4",
127
- # If icon only, make square
128
- "[&_svg:first-child:last-child]:-mx-1"
129
- ]
130
- when :lg
131
- [
132
- "px-5 h-11 text-lg [&_svg]:size-5",
133
- # If icon only, make square
134
- "[&_svg:first-child:last-child]:-mx-2"
135
- ]
136
- when :xl
137
- [
138
- "px-8 h-14 text-2xl [&_svg]:size-5 gap-x-4",
139
- # If icon only, make square
140
- "[&_svg:first-child:last-child]:-mx-8"
141
- ]
142
- else
143
- raise ArgumentError, "Unknown size `#{size}'"
204
+ def icon_slot(name, slot, icon_only:)
205
+ span(**slot_attributes(slot)) do
206
+ render(Icon.new(name, size: icon_only ? :md : icon_size))
207
+ end
208
+ end
209
+
210
+ def icon_size
211
+ { xs: :xs, sm: :sm, md: :sm, lg: :md, xl: :md }.fetch(size)
212
+ end
213
+
214
+ def accessible_label?
215
+ return true if @label
216
+
217
+ %w[label labelledby].any? do |name|
218
+ key = @aria.keys.find { |candidate| candidate.to_s.downcase.delete("_-") == name }
219
+ value = key && @aria[key]
220
+ value.is_a?(String) && !value.strip.empty?
144
221
  end
145
222
  end
146
223
  end