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,101 +2,161 @@
2
2
 
3
3
  module NitroKit
4
4
  class Accordion < Component
5
- def initialize(**attrs)
5
+ MODES = %i[multiple single].freeze
6
+ Item = ::Data.define(:key, :title, :expanded, :content)
7
+
8
+ def initialize(
9
+ id:,
10
+ mode: :multiple,
11
+ html: {},
12
+ aria: {},
13
+ data: {},
14
+ desperately_need_a_class: nil
15
+ )
16
+ @identifier = component_id(id)
17
+ @mode = validate_choice!(:mode, mode, MODES)
18
+ @items = []
19
+
6
20
  super(
7
- attrs,
8
- class: item_class,
9
- data: { controller: "nk--accordion" }
21
+ component: :accordion,
22
+ attributes: {
23
+ id: @identifier,
24
+ data: { mode: @mode }
25
+ },
26
+ html:,
27
+ aria:,
28
+ data:,
29
+ desperately_need_a_class:
10
30
  )
11
31
  end
12
32
 
13
- def view_template
14
- div(**attrs) do
15
- yield
33
+ attr_reader :identifier, :mode
34
+
35
+ def view_template(&block)
36
+ collect_items(&block)
37
+
38
+ div(**root_attributes) do
39
+ @items.each { |item| render_item(item) }
16
40
  end
17
41
  end
18
42
 
19
- def item(**attrs)
20
- builder do
21
- div(**attrs) do
22
- yield
23
- end
43
+ def item(key, title:, expanded: false, &content)
44
+ ensure_collecting!
45
+
46
+ key = normalize_identity(key, name: "accordion item key")
47
+ title = validate_title!(title)
48
+ expanded = validate_boolean!(:expanded, expanded)
49
+ raise ArgumentError, "Accordion item #{key.inspect} requires content" unless content
50
+ raise ArgumentError, "Duplicate accordion item key #{key.inspect}" if @items.any? { |item| item.key == key }
51
+
52
+ if mode == :single && expanded && @items.any?(&:expanded)
53
+ raise ArgumentError, "Single accordion mode accepts only one expanded item"
24
54
  end
55
+
56
+ @items << Item.new(key:, title:, expanded:, content:)
57
+ nil
25
58
  end
26
59
 
27
- def trigger(text = nil, **attrs)
28
- builder do
29
- button(
30
- **mattr(
31
- attrs,
32
- type: "button",
33
- class: trigger_class,
34
- data: {
35
- action: "nk--accordion#toggle",
36
- nk__accordion_target: "trigger"
37
- },
38
- aria: { expanded: "false" }
60
+ private
61
+
62
+ def collect_items
63
+ raise ArgumentError, "Accordion requires an item declaration block" unless block_given?
64
+
65
+ @collecting = true
66
+ yield(self)
67
+ raise ArgumentError, "Accordion requires at least one item" if @items.empty?
68
+ ensure
69
+ @collecting = false
70
+ end
71
+
72
+ def render_item(item)
73
+ details(
74
+ **slot_attributes(
75
+ :item,
76
+ attributes: {
77
+ name: mode == :single ? identifier : nil,
78
+ open: item.expanded,
79
+ data: { key: item.key }
80
+ }
81
+ )
82
+ ) do
83
+ summary(
84
+ **slot_attributes(
85
+ :trigger,
86
+ attributes: {
87
+ id: trigger_id(item)
88
+ }
39
89
  )
40
90
  ) do
41
- block_given? ? yield : plain(text)
42
- chevron_icon
91
+ span(**slot_attributes(:label)) { item.title }
92
+ chevron
43
93
  end
44
- end
45
- end
46
94
 
47
- def content(**attrs)
48
- builder do
49
95
  div(
50
- **mattr(
51
- attrs,
52
- class: content_class,
53
- data: {
54
- nk__accordion_target: "content"
55
- },
56
- aria: { hidden: "true" }
96
+ **slot_attributes(
97
+ :content,
98
+ attributes: {
99
+ id: content_id(item)
100
+ }
57
101
  )
58
102
  ) do
59
- div(class: "pb-4") { yield }
103
+ render(item.content)
60
104
  end
61
105
  end
62
106
  end
63
107
 
64
- private
108
+ def chevron
109
+ svg(
110
+ **slot_attributes(
111
+ :icon,
112
+ attributes: {
113
+ viewbox: "0 0 16 16",
114
+ width: 16,
115
+ height: 16,
116
+ fill: "none",
117
+ stroke: "currentColor",
118
+ stroke_width: 1.5,
119
+ stroke_linecap: "round",
120
+ stroke_linejoin: "round",
121
+ focusable: "false",
122
+ aria: { hidden: true }
123
+ }
124
+ )
125
+ ) do |svg|
126
+ svg.path(d: "m4 6 4 4 4-4")
127
+ end
128
+ end
129
+
130
+ def trigger_id(item)
131
+ "#{identifier}-#{item.key}-trigger"
132
+ end
65
133
 
66
- def item_class
67
- "divide-y"
134
+ def content_id(item)
135
+ "#{identifier}-#{item.key}-content"
68
136
  end
69
137
 
70
- def trigger_class
71
- [
72
- "flex w-full items-center justify-between py-4 font-medium cursor-pointer",
73
- "group/accordion-trigger hover:underline transition-colors",
74
- "[&[aria-expanded='true']>svg]:rotate-180",
75
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
76
- ]
138
+ def ensure_collecting!
139
+ return if @collecting
140
+
141
+ raise ArgumentError, "Accordion items must be declared inside the render block"
77
142
  end
78
143
 
79
- def content_class
80
- [
81
- "overflow-hidden transition-all duration-200",
82
- "[&[aria-hidden='true']]:h-0 [&[aria-hidden='false']]:h-auto"
83
- ]
144
+ def validate_title!(title)
145
+ return title if title.is_a?(String) && title.present?
146
+
147
+ raise ArgumentError, "Accordion item title must be a non-blank String"
84
148
  end
85
149
 
86
- def arrow_class
87
- "transition-transform duration-200 text-muted-content group-hover/accordion-trigger:text-primary"
150
+ def validate_boolean!(name, value)
151
+ return value if value == true || value == false
152
+
153
+ raise ArgumentError, "Accordion #{name} must be true or false"
88
154
  end
89
155
 
90
- def chevron_icon
91
- svg(
92
- class: "transition-transform duration-200 size-4 self-center place-self-end mr-2 pointer-events-none text-muted-content group-hover/accordion-trigger:text-primary",
93
- viewbox: "0 0 24 24",
94
- fill: "none",
95
- stroke: "currentColor",
96
- stroke_width: 1
97
- ) do |svg|
98
- svg.path(d: "m6 9 6 6 6-6")
99
- end
156
+ def component_id(value)
157
+ return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
158
+
159
+ raise ArgumentError, "Accordion id must be a non-blank String without whitespace"
100
160
  end
101
161
  end
102
162
  end
@@ -2,72 +2,109 @@
2
2
 
3
3
  module NitroKit
4
4
  class Alert < Component
5
- VARIANTS = %i[default warning error success]
5
+ VARIANTS = %i[default info success warning error].freeze
6
6
 
7
- def initialize(variant: :default, **attrs)
8
- @variant = variant
7
+ # The palette family each semantic variant borrows in `palette.css`.
8
+ # Alert owns this mapping; it is not the Badge color axis.
9
+ VARIANT_PALETTE = {
10
+ default: :zinc,
11
+ info: :blue,
12
+ success: :green,
13
+ warning: :amber,
14
+ error: :red
15
+ }.freeze
16
+
17
+ LIVE_MODES = %i[off polite assertive].freeze
18
+
19
+ Child = ::Data.define(:component, :content)
20
+ private_constant :Child
21
+
22
+ def initialize(
23
+ variant: :default,
24
+ title: nil,
25
+ description: nil,
26
+ live: :off,
27
+ id: nil,
28
+ html: {},
29
+ aria: {},
30
+ data: {},
31
+ desperately_need_a_class: nil
32
+ )
33
+ @variant = validate_choice!(:variant, variant, VARIANTS)
34
+ @live = validate_choice!(:live, live, LIVE_MODES)
35
+ @title_content = content_from_keyword(:title, title)
36
+ @description_content = content_from_keyword(:description, description)
37
+ @icon = nil
9
38
 
10
39
  super(
11
- attrs,
12
- role: "alert",
13
- class: [ base_class, variant_class ]
40
+ component: :alert,
41
+ attributes: { id:, role: live_role },
42
+ html:,
43
+ aria:,
44
+ data:,
45
+ variant:,
46
+ desperately_need_a_class:
14
47
  )
15
48
  end
16
49
 
17
50
  attr_reader :variant
18
51
 
19
- def view_template
20
- div(**attrs) do
21
- yield
22
- end
23
- end
52
+ def view_template(&block)
53
+ collect_declarations(&block)
24
54
 
25
- def title(text = nil, **attrs, &block)
26
- builder do
27
- h5(**mattr(attrs, class: title_class)) do
28
- text_or_block(text, &block)
55
+ div(**root_attributes) do
56
+ render_in_slot(@icon.component, :icon, &@icon.content) if @icon
57
+ if @title_content
58
+ div(**slot_attributes(:title)) { render_deferred_content(@title_content) }
59
+ end
60
+ if @description_content
61
+ div(**slot_attributes(:description)) { render_deferred_content(@description_content) }
29
62
  end
30
63
  end
31
64
  end
32
65
 
33
- def description(text = nil, **attrs, &block)
34
- builder do
35
- div(**mattr(attrs, class: description_class)) do
36
- text_or_block(text, &block)
37
- end
66
+ def icon(component, &content)
67
+ ensure_collecting!
68
+ unless component.is_a?(NitroKit::Icon)
69
+ raise ArgumentError, "Alert icon must be a NitroKit::Icon"
38
70
  end
71
+ raise ArgumentError, "Alert accepts at most one icon" if @icon
72
+
73
+ @icon = Child.new(component:, content:)
74
+ nil
39
75
  end
40
76
 
41
- private
77
+ def title(text = nil, &block)
78
+ ensure_collecting!
79
+ @title_content = declare_content(:title, @title_content, text, &block)
80
+ nil
81
+ end
42
82
 
43
- def base_class
44
- [
45
- "relative border w-full rounded-md p-5 text-sm space-y-2",
46
- "[&>svg~*]:pl-8 [&>svg]:absolute [&>svg]:top-5 [&>svg]:left-5"
47
- ]
83
+ def description(text = nil, &block)
84
+ ensure_collecting!
85
+ @description_content = declare_content(:description, @description_content, text, &block)
86
+ nil
48
87
  end
49
88
 
50
- def variant_class
51
- case variant
52
- when :default
53
- "border-border bg-background text-foreground"
54
- when :warning
55
- "bg-yellow-300/20 dark:bg-yellow-300/20 text-yellow-900 dark:text-yellow-100 border-yellow-500/80 dark:border-yellow-400/50"
56
- when :success
57
- "bg-green-300/20 dark:bg-green-300/20 text-green-900 dark:text-green-100 border-green-500/80 dark:border-green-400/50"
58
- when :error
59
- "bg-red-300/20 dark:bg-red-300/20 text-red-900 dark:text-red-100 border-red-400/80 dark:border-red-400/50"
60
- else
61
- raise ArgumentError, "Invalid variant: #{variant}"
62
- end
89
+ private
90
+
91
+ def collect_declarations
92
+ return unless block_given?
93
+
94
+ @collecting = true
95
+ yield(self)
96
+ ensure
97
+ @collecting = false
63
98
  end
64
99
 
65
- def title_class
66
- "font-medium text-lg leading-5"
100
+ def ensure_collecting!
101
+ return if @collecting
102
+
103
+ raise ArgumentError, "Alert declarations must be inside the render block"
67
104
  end
68
105
 
69
- def description_class
70
- ""
106
+ def live_role
107
+ { off: nil, polite: "status", assertive: "alert" }.fetch(@live)
71
108
  end
72
109
  end
73
110
  end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class AppNavigation < Component
5
+ alias_method :html_header, :header
6
+ alias_method :html_footer, :footer
7
+
8
+ Item = ::Data.define(:text, :href, :icon, :badge, :current, :html, :aria, :data, :css_class)
9
+ Section = ::Data.define(:label, :entries)
10
+ Entry = ::Data.define(:kind)
11
+
12
+ def initialize(
13
+ label:,
14
+ id: nil,
15
+ html: {},
16
+ aria: {},
17
+ data: {},
18
+ desperately_need_a_class: nil
19
+ )
20
+ @label = validate_text!(:label, label)
21
+ @entries = []
22
+ @entry_target = nil
23
+ @phase = nil
24
+ @header = nil
25
+ @body = nil
26
+ @footer = nil
27
+ @spacer = false
28
+ @current_item = false
29
+ @item_count = 0
30
+
31
+ super(
32
+ component: :app_navigation,
33
+ attributes: { id:, aria: { label: @label } }.compact,
34
+ html:,
35
+ aria:,
36
+ data:,
37
+ desperately_need_a_class:
38
+ )
39
+ end
40
+
41
+ attr_reader :label
42
+
43
+ def view_template(&declarations)
44
+ collect_structure(&declarations)
45
+ collect_body
46
+
47
+ nav(**root_attributes) do
48
+ render_header
49
+ render_body
50
+ render_footer
51
+ end
52
+ end
53
+
54
+ def header(&content)
55
+ ensure_phase!(:structure, :header)
56
+ raise ArgumentError, "AppNavigation accepts at most one header" if @header
57
+ raise ArgumentError, "AppNavigation header requires a block" unless content
58
+
59
+ @header = content
60
+ nil
61
+ end
62
+
63
+ def body(&content)
64
+ ensure_phase!(:structure, :body)
65
+ raise ArgumentError, "AppNavigation accepts exactly one body" if @body
66
+ raise ArgumentError, "AppNavigation body requires a block" unless content
67
+
68
+ @body = content
69
+ nil
70
+ end
71
+
72
+ def footer(&content)
73
+ ensure_phase!(:structure, :footer)
74
+ raise ArgumentError, "AppNavigation accepts at most one footer" if @footer
75
+ raise ArgumentError, "AppNavigation footer requires a block" unless content
76
+
77
+ @footer = content
78
+ nil
79
+ end
80
+
81
+ def section(label: nil, &content)
82
+ ensure_phase!(:body, :section)
83
+ raise ArgumentError, "AppNavigation section requires a block" unless content
84
+
85
+ entries = []
86
+ previous_entries = @entry_target
87
+ previous_phase = @phase
88
+
89
+ begin
90
+ @entry_target = entries
91
+ @phase = :section
92
+ output = capture(self, &content)
93
+ reject_rendered_output!(:section, output)
94
+ unless entries.any?(Item)
95
+ raise ArgumentError, "AppNavigation section requires at least one item"
96
+ end
97
+ previous_entries << Section.new(label: validate_optional_text!(:label, label), entries:)
98
+ nil
99
+ ensure
100
+ @entry_target = previous_entries
101
+ @phase = previous_phase
102
+ end
103
+ end
104
+
105
+ def item(
106
+ text,
107
+ href:,
108
+ icon: nil,
109
+ badge: nil,
110
+ badge_color: :neutral,
111
+ current: false,
112
+ html: {},
113
+ aria: {},
114
+ data: {},
115
+ desperately_need_a_class: nil
116
+ )
117
+ ensure_entry_phase!(:item)
118
+ text = validate_text!(:text, text)
119
+ href = validate_text!(:href, href)
120
+ icon = item_icon(icon)
121
+ badge = item_badge(badge, badge_color)
122
+ current = validate_boolean!(:current, current)
123
+
124
+ if current && @current_item
125
+ raise ArgumentError, "AppNavigation accepts at most one current item"
126
+ end
127
+
128
+ @current_item = true if current
129
+ @entry_target << Item.new(
130
+ text:,
131
+ href:,
132
+ icon:,
133
+ badge:,
134
+ current:,
135
+ html:,
136
+ aria:,
137
+ data:,
138
+ css_class: desperately_need_a_class
139
+ )
140
+ @item_count += 1
141
+ nil
142
+ end
143
+
144
+ def divider
145
+ ensure_entry_phase!(:divider)
146
+ @entry_target << Entry.new(kind: :divider)
147
+ nil
148
+ end
149
+
150
+ def spacer
151
+ ensure_phase!(:body, :spacer)
152
+ raise ArgumentError, "AppNavigation accepts at most one spacer" if @spacer
153
+
154
+ @spacer = true
155
+ @entry_target << Entry.new(kind: :spacer)
156
+ nil
157
+ end
158
+
159
+ private
160
+
161
+ def collect_structure
162
+ raise ArgumentError, "AppNavigation requires a declaration block" unless block_given?
163
+
164
+ @phase = :structure
165
+ output = capture(self) { |navigation| yield navigation }
166
+ reject_rendered_output!(:structure, output)
167
+ raise ArgumentError, "AppNavigation requires exactly one body" unless @body
168
+ ensure
169
+ @phase = nil
170
+ end
171
+
172
+ def collect_body
173
+ @phase = :body
174
+ @entry_target = @entries
175
+ @item_count = 0
176
+ output = capture(self, &@body)
177
+ reject_rendered_output!(:body, output)
178
+ raise ArgumentError, "AppNavigation body requires at least one item" if @item_count.zero?
179
+ ensure
180
+ @entry_target = nil
181
+ @phase = nil
182
+ end
183
+
184
+ def render_header
185
+ html_header(**slot_attributes(:header)) { render(@header) } if @header
186
+ end
187
+
188
+ def render_body
189
+ ul(**slot_attributes(:body)) do
190
+ @entries.each { |entry| render_entry(entry) }
191
+ end
192
+ end
193
+
194
+ def render_footer
195
+ html_footer(**slot_attributes(:footer)) { render(@footer) } if @footer
196
+ end
197
+
198
+ def render_entry(entry)
199
+ case entry
200
+ when Item then render_item(entry)
201
+ when Section then render_section(entry)
202
+ else entry.kind == :divider ? render_divider : render_spacer
203
+ end
204
+ end
205
+
206
+ def render_section(entry)
207
+ li(**slot_attributes(:section)) do
208
+ span(**slot_attributes(:section_label)) { plain(entry.label) } if entry.label
209
+ ul(**slot_attributes(:section_list, attributes: section_list_attributes(entry))) do
210
+ entry.entries.each { |child| render_entry(child) }
211
+ end
212
+ end
213
+ end
214
+
215
+ def section_list_attributes(entry)
216
+ entry.label ? { aria: { label: entry.label } } : {}
217
+ end
218
+
219
+ def render_item(entry)
220
+ li(**slot_attributes(:item)) do
221
+ a(
222
+ **slot_attributes(
223
+ :item_link,
224
+ attributes: {
225
+ href: entry.href,
226
+ aria: { current: entry.current ? "page" : nil },
227
+ data: { state: entry.current ? "current" : "default" }
228
+ },
229
+ html: entry.html,
230
+ aria: entry.aria,
231
+ data: entry.data,
232
+ desperately_need_a_class: entry.css_class
233
+ )
234
+ ) do
235
+ render_in_slot(entry.icon, :item_icon) if entry.icon
236
+ span(**slot_attributes(:item_label)) { plain(entry.text) }
237
+ render_in_slot(entry.badge, :item_badge) if entry.badge
238
+ end
239
+ end
240
+ end
241
+
242
+ def render_divider
243
+ li(**slot_attributes(:divider, aria: { hidden: true }))
244
+ end
245
+
246
+ def render_spacer
247
+ li(**slot_attributes(:spacer, aria: { hidden: true }))
248
+ end
249
+
250
+ def ensure_phase!(expected, declaration)
251
+ return if @phase == expected
252
+
253
+ location = expected == :structure ? "the render block" : "the body"
254
+ raise ArgumentError, "AppNavigation #{declaration} must be declared directly inside #{location}"
255
+ end
256
+
257
+ def ensure_entry_phase!(declaration)
258
+ return if %i[body section].include?(@phase)
259
+
260
+ raise ArgumentError, "AppNavigation #{declaration} must be declared inside the body or a section"
261
+ end
262
+
263
+ def reject_rendered_output!(location, output)
264
+ return if output.empty?
265
+
266
+ raise ArgumentError, "AppNavigation #{location} accepts declarations, not rendered content"
267
+ end
268
+
269
+ def validate_text!(name, value)
270
+ return value if value.is_a?(String) && !value.strip.empty?
271
+
272
+ raise ArgumentError, "#{name} must be a non-blank String"
273
+ end
274
+
275
+ def validate_optional_text!(name, value)
276
+ return if value.nil?
277
+
278
+ validate_text!(name, value)
279
+ end
280
+
281
+ def item_icon(value)
282
+ return if value.nil?
283
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
284
+ raise ArgumentError, "icon must be a non-blank String or Symbol"
285
+ end
286
+
287
+ Icon.new(value, size: :sm)
288
+ end
289
+
290
+ def item_badge(value, color)
291
+ if value.nil?
292
+ return if color == :neutral
293
+
294
+ raise ArgumentError, "badge_color requires a badge"
295
+ end
296
+
297
+ Badge.new(validate_badge_text!(value), size: :sm, color:)
298
+ end
299
+
300
+ def validate_badge_text!(value)
301
+ return value if value.is_a?(String) && !value.strip.empty?
302
+ return value.to_s if value.is_a?(Numeric)
303
+
304
+ raise ArgumentError, "badge must be a non-blank String or Numeric"
305
+ end
306
+ end
307
+ end