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,95 +2,160 @@
2
2
 
3
3
  module NitroKit
4
4
  class Select < Component
5
- def initialize(options = nil, value: nil, include_empty: false, prompt: nil, index: nil, **attrs)
6
- @options = options
7
- @value = value&.to_s
8
- @include_empty = include_empty
9
- @prompt = prompt
10
- @index = index
5
+ def initialize(
6
+ options: [],
7
+ option_tags: nil,
8
+ id: nil,
9
+ name: nil,
10
+ value: nil,
11
+ include_blank: nil,
12
+ prompt: nil,
13
+ disabled: false,
14
+ required: false,
15
+ multiple: false,
16
+ autocomplete: nil,
17
+ html: {},
18
+ aria: {},
19
+ data: {},
20
+ control_html: {},
21
+ control_aria: {},
22
+ control_data: {},
23
+ desperately_need_a_class: nil
24
+ )
25
+ @options = Array(options).map { |choice| Choice.coerce(choice) }.freeze
26
+ @option_tags = validate_option_tags!(option_tags)
27
+ @value = value
28
+ @include_blank = validate_blank_option!(include_blank)
29
+ @prompt = validate_optional_text!(:prompt, prompt)
30
+ if @include_blank && @prompt
31
+ raise ArgumentError, "include_blank: and prompt: are mutually exclusive"
32
+ end
33
+ @disabled = validate_boolean!(:disabled, disabled)
34
+ @required = validate_boolean!(:required, required)
35
+ @multiple = validate_boolean!(:multiple, multiple)
36
+ @name = multiple_name(name)
37
+ @id = id
38
+ @autocomplete = autocomplete
39
+ @control_html = control_html
40
+ @control_aria = control_aria
41
+ @control_data = control_data
42
+
43
+ if @option_tags && @options.any?
44
+ raise ArgumentError, "options and option_tags are mutually exclusive"
45
+ end
11
46
 
12
47
  super(
13
- attrs,
14
- class: wrapper_class
48
+ component: :select,
49
+ html:,
50
+ aria:,
51
+ data:,
52
+ desperately_need_a_class:
15
53
  )
16
54
  end
17
55
 
18
- attr_reader :value, :options, :include_empty, :prompt, :index
56
+ attr_reader :options, :value, :include_blank, :prompt, :name, :id
19
57
 
20
58
  def view_template
21
- span(class: wrapper_class, data: { slot: "control" }) do
22
- select(**attrs, class: select_class) do
23
- if include_empty
24
- blank_text = if include_empty.is_a?(String)
25
- include_empty
26
- elsif @prompt
27
- @prompt
28
- else
29
- ""
30
- end
31
-
32
- html_option(value: "", selected: @value == "") { blank_text }
33
- end
59
+ span(**root_attributes) do
60
+ select(**control_attributes) do
61
+ render_blank_option if include_blank
62
+ render_prompt_option if prompt && selected_values.empty?
34
63
 
35
- if options
36
- options.each do |opt|
37
- if opt.is_a?(Array) && opt.length >= 2
38
- html_option(value: opt[1], selected: @value == opt[1].to_s) { opt[0] }
39
- else
40
- # Handle simple strings - use as both label and value
41
- html_option(value: opt.to_s, selected: @value == opt.to_s) { opt.to_s }
42
- end
43
- end
64
+ if @option_tags
65
+ raw safe(@option_tags)
44
66
  else
45
- yield if block_given?
67
+ options.each { |choice| render_option(choice) }
46
68
  end
47
69
  end
48
70
 
49
- chevron_icon
71
+ render_toggle_icon
50
72
  end
51
73
  end
52
74
 
53
- alias :html_option :option
75
+ private
54
76
 
55
- def option(text = nil, value = nil, **attrs, &block)
56
- builder do
57
- value ||= text
77
+ def render_toggle_icon
78
+ return if @multiple
58
79
 
59
- html_option(value:, selected: @value == value.to_s, **attrs) do
60
- if block_given?
61
- yield
62
- else
63
- text
64
- end
65
- end
80
+ svg(
81
+ **slot_attributes(
82
+ :icon,
83
+ attributes: {
84
+ viewbox: "0 0 20 20",
85
+ fill: "none",
86
+ stroke: "currentColor",
87
+ stroke_width: 1.5,
88
+ aria: { hidden: true }
89
+ }
90
+ )
91
+ ) do |icon|
92
+ icon.path(d: "m6.5 8 3.5 3.5L13.5 8", stroke_linecap: "round", stroke_linejoin: "round")
66
93
  end
67
94
  end
68
95
 
69
- private
96
+ def control_attributes
97
+ slot_attributes(
98
+ :control,
99
+ attributes: {
100
+ id:,
101
+ name:,
102
+ disabled: @disabled,
103
+ required: @required,
104
+ multiple: @multiple,
105
+ autocomplete: @autocomplete
106
+ }.compact,
107
+ html: @control_html,
108
+ aria: @control_aria,
109
+ data: @control_data
110
+ )
111
+ end
70
112
 
71
- def wrapper_class
72
- "w-fit inline-grid *:[grid-area:1/1] group/select"
113
+ def render_blank_option
114
+ text = include_blank == true ? "" : include_blank
115
+ option(value: "", selected: selected_values.empty?) { plain(text.to_s) }
73
116
  end
74
117
 
75
- def select_class
76
- [
77
- "appearance-none bg-background text-foreground rounded-md border px-3 py-2 pr-10 w-full",
78
- # Focus
79
- "focus:outline-none ring-ring ring-offset-2 ring-offset-background focus-visible:ring-2"
80
- ]
118
+ def render_prompt_option
119
+ option(value: "", selected: true) { plain(prompt) }
81
120
  end
82
121
 
83
- def chevron_icon
84
- svg(
85
- class: "size-4 self-center place-self-end mr-1.5 text-muted-content pointer-events-none group-hover/select:text-foreground",
86
- viewbox: "0 0 24 24",
87
- fill: "none",
88
- stroke: "currentColor",
89
- stroke_width: 1
90
- ) do |svg|
91
- svg.path(d: "m7 15 5 5 5-5")
92
- svg.path(d: "m7 9 5-5 5 5")
93
- end
122
+ def render_option(choice)
123
+ option(
124
+ id: choice.id,
125
+ value: choice.value,
126
+ selected: selected_values.include?(choice.value.to_s),
127
+ disabled: choice.disabled
128
+ ) { plain(choice.label.to_s) }
129
+ end
130
+
131
+ def selected_values
132
+ @selected_values ||= Array(value).compact.map(&:to_s)
133
+ end
134
+
135
+ def multiple_name(name)
136
+ return name unless @multiple && name && !name.end_with?("[]")
137
+
138
+ "#{name}[]"
139
+ end
140
+
141
+ def validate_option_tags!(option_tags)
142
+ return if option_tags.nil?
143
+ return option_tags if option_tags.is_a?(ActiveSupport::SafeBuffer)
144
+
145
+ raise ArgumentError, "option_tags must be an ActiveSupport::SafeBuffer"
146
+ end
147
+
148
+ def validate_blank_option!(value)
149
+ return value if value.nil? || value == true || value == false || value.is_a?(String)
150
+
151
+ raise ArgumentError, "include_blank must be true, false, nil, or a String"
152
+ end
153
+
154
+ def validate_optional_text!(name, value)
155
+ return if value.nil? || value == false
156
+ return value if value.is_a?(String) && !value.strip.empty?
157
+
158
+ raise ArgumentError, "#{name} must be a non-blank String, false, or nil"
94
159
  end
95
160
  end
96
161
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class SettingsLayout < Component
5
+ Item = ::Data.define(:text, :href, :icon, :current)
6
+
7
+ def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
8
+ @label = nil
9
+ @items = []
10
+ @content = nil
11
+ @phase = nil
12
+ @navigation_declared = false
13
+ @current_item = false
14
+
15
+ super(
16
+ component: :settings_layout,
17
+ attributes: { id: }.compact,
18
+ html:,
19
+ aria:,
20
+ data:,
21
+ desperately_need_a_class:
22
+ )
23
+ end
24
+
25
+ def view_template(&declarations)
26
+ collect_regions(&declarations)
27
+
28
+ div(**root_attributes) do
29
+ render_navigation
30
+ render_content
31
+ end
32
+ end
33
+
34
+ def navigation(label:, &declarations)
35
+ ensure_phase!(:structure, :navigation)
36
+ raise ArgumentError, "SettingsLayout accepts exactly one navigation region" if @navigation_declared
37
+ raise ArgumentError, "SettingsLayout navigation requires a block" unless declarations
38
+
39
+ @label = validate_text!(:label, label)
40
+ @navigation_declared = true
41
+ collect_items(&declarations)
42
+ nil
43
+ end
44
+
45
+ def item(text, href:, icon: nil, current: false)
46
+ ensure_phase!(:navigation, :item)
47
+ text = validate_text!(:text, text)
48
+ href = validate_text!(:href, href)
49
+ icon = item_icon(icon)
50
+ current = validate_boolean!(:current, current)
51
+
52
+ if current && @current_item
53
+ raise ArgumentError, "SettingsLayout accepts at most one current item"
54
+ end
55
+
56
+ @current_item = true if current
57
+ @items << Item.new(text:, href:, icon:, current:)
58
+ nil
59
+ end
60
+
61
+ def content(&block)
62
+ ensure_phase!(:structure, :content)
63
+ raise ArgumentError, "SettingsLayout accepts exactly one content region" if @content
64
+ raise ArgumentError, "SettingsLayout content requires a block" unless block
65
+
66
+ @content = block
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def collect_regions
73
+ raise ArgumentError, "SettingsLayout requires a declaration block" unless block_given?
74
+
75
+ @phase = :structure
76
+ output = capture(self) { |layout| yield layout }
77
+ reject_rendered_output!(:structure, output)
78
+ raise ArgumentError, "SettingsLayout requires one navigation region" unless @navigation_declared
79
+ raise ArgumentError, "SettingsLayout requires one content region" unless @content
80
+ ensure
81
+ @phase = nil
82
+ end
83
+
84
+ def collect_items(&declarations)
85
+ @phase = :navigation
86
+ output = capture(self, &declarations)
87
+ reject_rendered_output!(:navigation, output)
88
+ raise ArgumentError, "SettingsLayout navigation requires at least one item" if @items.empty?
89
+ ensure
90
+ @phase = :structure
91
+ end
92
+
93
+ def render_navigation
94
+ nav(**slot_attributes(:navigation, attributes: { aria: { label: @label } })) do
95
+ ul(**slot_attributes(:items)) do
96
+ @items.each { |item| render_item(item) }
97
+ end
98
+ end
99
+ end
100
+
101
+ def render_item(item)
102
+ li(**slot_attributes(:item)) do
103
+ a(
104
+ **slot_attributes(
105
+ :item_link,
106
+ attributes: {
107
+ href: item.href,
108
+ aria: { current: item.current ? "page" : nil },
109
+ data: { state: item.current ? "current" : "default" }
110
+ }
111
+ )
112
+ ) do
113
+ render_in_slot(item.icon, :item_icon) if item.icon
114
+ plain(item.text)
115
+ end
116
+ end
117
+ end
118
+
119
+ def render_content
120
+ div(**slot_attributes(:content)) { render(@content) }
121
+ end
122
+
123
+ def ensure_phase!(expected, declaration)
124
+ return if @phase == expected
125
+
126
+ location = expected == :structure ? "the render block" : "the navigation block"
127
+ raise ArgumentError, "SettingsLayout #{declaration} must be declared directly inside #{location}"
128
+ end
129
+
130
+ def reject_rendered_output!(location, output)
131
+ return if output.empty?
132
+
133
+ raise ArgumentError, "SettingsLayout #{location} accepts declarations, not rendered content"
134
+ end
135
+
136
+ def item_icon(value)
137
+ return if value.nil?
138
+
139
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
140
+ raise ArgumentError, "SettingsLayout icon must be a non-blank String or Symbol"
141
+ end
142
+
143
+ Icon.new(value, size: :sm)
144
+ end
145
+
146
+ def validate_text!(name, value)
147
+ return value if value.is_a?(String) && !value.strip.empty?
148
+
149
+ raise ArgumentError, "SettingsLayout #{name} must be a non-blank String"
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class Sheet < Component
5
+ SIDES = %i[left right].freeze
6
+ SIZES = %i[sm md lg].freeze
7
+
8
+ Trigger = Data.define(:text, :variant, :size, :icon, :icon_end, :label, :disabled, :html, :aria, :data, :content)
9
+ private_constant :Trigger
10
+
11
+ Panel = Data.define(:title, :description, :content)
12
+ private_constant :Panel
13
+
14
+ def initialize(
15
+ id:,
16
+ side: :right,
17
+ size: :md,
18
+ close_label: I18n.t("nitro_kit.sheet.close"),
19
+ html: {},
20
+ aria: {},
21
+ data: {},
22
+ desperately_need_a_class: nil
23
+ )
24
+ unless id.is_a?(String) && id.present? && !id.match?(/\s/)
25
+ raise ArgumentError, "Sheet id: must be a non-blank String without whitespace"
26
+ end
27
+ unless close_label.is_a?(String) && close_label.present?
28
+ raise ArgumentError, "Sheet close_label: must be a non-blank String"
29
+ end
30
+
31
+ @id = id
32
+ @side = validate_choice!(:side, side, SIDES)
33
+ @size = validate_choice!(:size, size, SIZES)
34
+ @close_label = close_label
35
+
36
+ super(
37
+ component: :sheet,
38
+ attributes: {
39
+ id:,
40
+ data: {
41
+ controller: "nk--dialog",
42
+ side: @side,
43
+ nk__dialog_dismissible_value: true
44
+ }
45
+ },
46
+ html:,
47
+ aria:,
48
+ data:,
49
+ size:,
50
+ desperately_need_a_class:
51
+ )
52
+ end
53
+
54
+ attr_reader :id, :side, :size
55
+
56
+ def view_template(&declarations)
57
+ collect_declarations(&declarations)
58
+
59
+ div(**root_attributes) do
60
+ render_trigger
61
+ render_panel
62
+ end
63
+ end
64
+
65
+ def trigger(
66
+ text = nil,
67
+ variant: :default,
68
+ size: :md,
69
+ icon: nil,
70
+ icon_end: nil,
71
+ label: nil,
72
+ disabled: false,
73
+ html: {},
74
+ aria: {},
75
+ data: {},
76
+ &content
77
+ )
78
+ ensure_collecting!
79
+ raise ArgumentError, "Sheet accepts exactly one trigger" if @trigger
80
+
81
+ @trigger = Trigger.new(
82
+ text:,
83
+ variant:,
84
+ size:,
85
+ icon:,
86
+ icon_end:,
87
+ label:,
88
+ disabled: validate_boolean!(:disabled, disabled),
89
+ html:,
90
+ aria:,
91
+ data:,
92
+ content:
93
+ )
94
+ nil
95
+ end
96
+
97
+ def panel(title:, description: nil, &content)
98
+ ensure_collecting!
99
+ raise ArgumentError, "Sheet accepts exactly one panel" if @panel
100
+ unless title.is_a?(String) && title.present?
101
+ raise ArgumentError, "Sheet title: must be a non-blank String"
102
+ end
103
+ unless description.nil? || (description.is_a?(String) && description.present?)
104
+ raise ArgumentError, "Sheet description: must be nil or a non-blank String"
105
+ end
106
+
107
+ @panel = Panel.new(title:, description:, content:)
108
+ nil
109
+ end
110
+
111
+ private
112
+
113
+ def collect_declarations
114
+ raise ArgumentError, "Sheet requires a declaration block" unless block_given?
115
+
116
+ @collecting = true
117
+ yield(self)
118
+ raise ArgumentError, "Sheet requires exactly one trigger" unless @trigger
119
+ raise ArgumentError, "Sheet requires exactly one panel" unless @panel
120
+ ensure
121
+ @collecting = false
122
+ end
123
+
124
+ def render_trigger
125
+ button = Button.new(
126
+ @trigger.text,
127
+ variant: @trigger.variant,
128
+ size: @trigger.size,
129
+ icon: @trigger.icon,
130
+ icon_end: @trigger.icon_end,
131
+ label: @trigger.label,
132
+ disabled: @trigger.disabled,
133
+ html: command_attributes(@trigger.html, "show-modal", disabled: @trigger.disabled),
134
+ aria: @trigger.aria.merge(haspopup: "dialog"),
135
+ data: @trigger.data
136
+ )
137
+
138
+ render_in_slot(button, :trigger, &@trigger.content)
139
+ end
140
+
141
+ def render_panel
142
+ dialog(
143
+ **slot_attributes(
144
+ :panel,
145
+ attributes: {
146
+ id: panel_id,
147
+ closedby: "any",
148
+ aria: {
149
+ labelledby: title_id,
150
+ describedby: @panel.description ? description_id : nil
151
+ }.compact,
152
+ data: {
153
+ nk__dialog_target: "panel",
154
+ action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel"
155
+ }
156
+ }
157
+ )
158
+ ) do
159
+ render_close
160
+ h2(**slot_attributes(:title, attributes: { id: title_id })) { @panel.title }
161
+ if @panel.description
162
+ p(**slot_attributes(:description, attributes: { id: description_id })) { @panel.description }
163
+ end
164
+ div(**slot_attributes(:body)) { render(@panel.content) } if @panel.content
165
+ end
166
+ end
167
+
168
+ def render_close
169
+ render_in_slot(
170
+ Button.new(
171
+ icon: :x,
172
+ label: @close_label,
173
+ variant: :ghost,
174
+ size: :sm,
175
+ html: command_attributes({}, "close")
176
+ ),
177
+ :close
178
+ )
179
+ end
180
+
181
+ def command_attributes(html, command, disabled: false)
182
+ unless html.is_a?(Hash)
183
+ raise ArgumentError, "html must be a Hash"
184
+ end
185
+ if html.keys.any? { |key| %w[command commandfor].include?(key.to_s.downcase) }
186
+ raise ArgumentError, "command and commandfor are owned by Sheet"
187
+ end
188
+
189
+ disabled ? html : html.merge(command:, commandfor: panel_id)
190
+ end
191
+
192
+ def panel_id = "#{id}-panel"
193
+ def title_id = "#{id}-title"
194
+ def description_id = "#{id}-description"
195
+
196
+ def ensure_collecting!
197
+ return if @collecting
198
+
199
+ raise ArgumentError, "Sheet declarations must be inside the render block"
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class StatGrid < Component
5
+ DEFAULT_COLUMNS = "1 sm:2 lg:3"
6
+ Stat = Data.define(:key, :label, :value, :detail)
7
+
8
+ def initialize(
9
+ cols: DEFAULT_COLUMNS,
10
+ gap: 4,
11
+ id: nil,
12
+ html: {},
13
+ aria: {},
14
+ data: {},
15
+ desperately_need_a_class: nil
16
+ )
17
+ @stats = []
18
+ @grid = Grid.new(cols:, gap:)
19
+
20
+ super(
21
+ component: :stat_grid,
22
+ attributes: { id: }.compact,
23
+ html:,
24
+ aria:,
25
+ data:,
26
+ desperately_need_a_class:
27
+ )
28
+ end
29
+
30
+ def view_template
31
+ yield self if block_given?
32
+ raise ArgumentError, "StatGrid requires at least one stat" if @stats.empty?
33
+
34
+ div(**root_attributes) do
35
+ render_in_slot(@grid, :grid) do
36
+ html_dl(**slot_attributes(:list)) do
37
+ @stats.each { |stat| render_stat(stat) }
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def stat(key:, label:, value:, detail: nil)
44
+ key = normalize_identity(key, name: "stat key")
45
+ raise ArgumentError, "StatGrid stat keys must be unique: #{key.inspect}" if @stats.any? { |stat| stat.key == key }
46
+
47
+ @stats << Stat.new(
48
+ key:,
49
+ label: present_text!(:label, label),
50
+ value: present_text!(:value, value),
51
+ detail: detail.nil? ? nil : present_text!(:detail, detail)
52
+ )
53
+ nil
54
+ end
55
+
56
+ alias :html_dl :dl
57
+
58
+ private
59
+
60
+ def render_stat(stat)
61
+ div(**slot_attributes(:stat, attributes: { data: { key: stat.key } })) do
62
+ dt(**slot_attributes(:label)) { plain(stat.label) }
63
+ dd(**slot_attributes(:value)) { plain(stat.value) }
64
+ dd(**slot_attributes(:detail)) { plain(stat.detail) } if stat.detail
65
+ end
66
+ end
67
+
68
+ def present_text!(name, value)
69
+ text = value.to_s
70
+ raise ArgumentError, "StatGrid #{name} must not be blank" if text.strip.empty?
71
+
72
+ text
73
+ end
74
+ end
75
+ end