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,105 +2,391 @@
2
2
 
3
3
  module NitroKit
4
4
  class Pagination < Component
5
- def initialize(**attrs)
5
+ Item = Data.define(:kind, :button, :current_page, :content, :current, :label)
6
+ CurrentPage = Data.define(:text, :attributes)
7
+ private_constant :CurrentPage
8
+ ITEM_KINDS = %i[previous page ellipsis next].freeze
9
+ UNSET = Object.new.freeze
10
+ private_constant :UNSET
11
+
12
+ def initialize(
13
+ label: I18n.t("nitro_kit.pagination.label"),
14
+ pagy: nil,
15
+ page_url: nil,
16
+ id: nil,
17
+ html: {},
18
+ aria: {},
19
+ data: {},
20
+ desperately_need_a_class: nil
21
+ )
22
+ validate_label!(label, name: "label")
23
+ validate_aria!(aria, reserved: %w[label])
24
+ validate_pagy!(pagy, page_url:)
25
+ @pagy = pagy
26
+ @page_url = page_url
27
+ @items = []
28
+
6
29
  super(
7
- attrs,
8
- class: merge_class(nav_class, attrs[:class]),
9
- aria: { label: "Pagination" }
30
+ component: :pagination,
31
+ attributes: { id: },
32
+ html:,
33
+ aria: aria.merge(label:),
34
+ data:,
35
+ desperately_need_a_class:
10
36
  )
11
37
  end
12
38
 
13
- def view_template
14
- nav(**attrs) do
15
- yield
39
+ def view_template(&content)
40
+ if @pagy && content
41
+ raise ArgumentError, "Pagination accepts a Pagy object or a block, not both"
16
42
  end
17
- end
18
43
 
19
- def prev(text = nil, **attrs, &block)
20
- builder do
21
- page_link(**mattr(attrs, aria: { label: "Previous page" })) do
22
- if text || block_given?
23
- text_or_block(text, &block)
24
- else
25
- render(Icon.new("arrow-left"))
26
- plain("Previous")
27
- end
44
+ append_pagy_items if @pagy
45
+ yield self if content
46
+ validate_collection!
47
+
48
+ nav(**root_attributes) do
49
+ ol(**slot_attributes(:list)) do
50
+ @items.each { |item| render_item(item) }
28
51
  end
29
52
  end
30
53
  end
31
54
 
32
- def next(text = nil, **attrs, &block)
33
- builder do
34
- page_link(**mattr(attrs, aria: { label: "Next page" })) do
35
- if text || block_given?
36
- text_or_block(text, &block)
37
- else
38
- plain("Next")
39
- render(Icon.new("arrow-right"))
40
- end
41
- end
42
- end
55
+ def prev(
56
+ text = UNSET,
57
+ href: nil,
58
+ icon: "arrow-left",
59
+ disabled: false,
60
+ id: nil,
61
+ html: {},
62
+ aria: {},
63
+ data: {},
64
+ desperately_need_a_class: nil,
65
+ &content
66
+ )
67
+ text = default_text(text, I18n.t("nitro_kit.pagination.previous"), content)
68
+ validate_boolean!(:disabled, disabled)
69
+ append_navigation_item(
70
+ :previous,
71
+ text:,
72
+ href:,
73
+ icon:,
74
+ disabled: disabled || missing_href?(href),
75
+ default_label: I18n.t("nitro_kit.pagination.previous_page"),
76
+ id:,
77
+ html:,
78
+ aria:,
79
+ data:,
80
+ desperately_need_a_class:,
81
+ &content
82
+ )
43
83
  end
44
84
 
45
- def page(text = nil, current: false, **attrs, &block)
46
- builder do
47
- page_link(
48
- **mattr(
49
- attrs,
50
- aria: {
51
- current: current ? "page" : nil
52
- },
53
- disabled: current,
54
- class: [ page_class, current && "bg-zinc-200/50 dark:bg-zinc-800/50" ]
85
+ def page(
86
+ text = nil,
87
+ href: nil,
88
+ current: false,
89
+ id: nil,
90
+ html: {},
91
+ aria: {},
92
+ data: {},
93
+ desperately_need_a_class: nil,
94
+ &content
95
+ )
96
+ validate_boolean!(:current, current)
97
+ if !text.nil? && content
98
+ raise ArgumentError, "Pagination page accepts text or a block, not both"
99
+ end
100
+ validate_content!(text, content, name: "page label")
101
+ if missing_href?(href) && !current
102
+ raise ArgumentError, "Pagination page href is required unless the page is current"
103
+ end
104
+ validate_aria!(aria, reserved: %w[current disabled])
105
+
106
+ item_aria = current ? aria.merge(current: "page") : aria
107
+ button = nil
108
+ current_page = nil
109
+
110
+ if current && missing_href?(href)
111
+ current_page = CurrentPage.new(
112
+ text:,
113
+ attributes: slot_attributes(
114
+ :current,
115
+ attributes: { id: }.compact,
116
+ html:,
117
+ aria: item_aria,
118
+ data:,
119
+ desperately_need_a_class:
55
120
  )
56
- ) do
57
- text_or_block(text, &block)
58
- end
121
+ )
122
+ else
123
+ button = pagination_button(
124
+ text,
125
+ href:,
126
+ disabled: false,
127
+ id:,
128
+ html:,
129
+ aria: item_aria,
130
+ data:,
131
+ desperately_need_a_class:
132
+ )
59
133
  end
134
+
135
+ append(Item.new(kind: :page, button:, current_page:, content:, current:, label: nil))
60
136
  end
61
137
 
62
- def ellipsis(**attrs)
63
- builder do
64
- render(
65
- Button.new(
66
- **mattr(
67
- attrs,
68
- variant: :ghost,
69
- disabled: true,
70
- class: page_class
71
- )
72
- )
73
- ) do
74
- "…"
138
+ def ellipsis(label: I18n.t("nitro_kit.pagination.ellipsis"))
139
+ validate_label!(label, name: "ellipsis label")
140
+
141
+ append(Item.new(kind: :ellipsis, button: nil, current_page: nil, content: nil, current: false, label:))
142
+ end
143
+
144
+ def next(
145
+ text = UNSET,
146
+ href: nil,
147
+ icon: "arrow-right",
148
+ disabled: false,
149
+ id: nil,
150
+ html: {},
151
+ aria: {},
152
+ data: {},
153
+ desperately_need_a_class: nil,
154
+ &content
155
+ )
156
+ text = default_text(text, I18n.t("nitro_kit.pagination.next"), content)
157
+ validate_boolean!(:disabled, disabled)
158
+ append_navigation_item(
159
+ :next,
160
+ text:,
161
+ href:,
162
+ icon_end: icon,
163
+ disabled: disabled || missing_href?(href),
164
+ default_label: I18n.t("nitro_kit.pagination.next_page"),
165
+ id:,
166
+ html:,
167
+ aria:,
168
+ data:,
169
+ desperately_need_a_class:,
170
+ &content
171
+ )
172
+ end
173
+
174
+ private
175
+
176
+ def append_pagy_items
177
+ previous = @pagy.previous
178
+ prev(href: previous && pagy_page_url(previous))
179
+
180
+ pagy_series.each do |item|
181
+ case item
182
+ when Integer
183
+ page(item, href: pagy_page_url(item))
184
+ when String
185
+ page(item, current: true)
186
+ when :gap
187
+ ellipsis
188
+ else
189
+ raise ArgumentError, "Pagination received an unknown Pagy series item: #{item.inspect}"
75
190
  end
76
191
  end
192
+
193
+ following = @pagy.next
194
+ self.next(href: following && pagy_page_url(following))
77
195
  end
78
196
 
79
- private
197
+ # Pagy's `series` is protected; `data_hash` is its public accessor for it.
198
+ def pagy_series
199
+ @pagy.data_hash(data_keys: [ :series ]).fetch(:series)
200
+ end
201
+
202
+ def pagy_page_url(page)
203
+ return @page_url.call(page) if @page_url
204
+
205
+ @pagy.page_url(page)
206
+ end
207
+
208
+ def validate_pagy!(pagy, page_url:)
209
+ if page_url && !page_url.respond_to?(:call)
210
+ raise ArgumentError, "Pagination page_url must be callable"
211
+ end
212
+ return unless pagy
213
+
214
+ required = %i[previous next data_hash]
215
+ required << :page_url unless page_url
216
+ missing = required.reject { |method| pagy.respond_to?(method) }
217
+ return if missing.empty?
218
+
219
+ raise ArgumentError,
220
+ "Pagination pagy must respond to #{missing.join(", ")}"
221
+ end
222
+
223
+ def append_navigation_item(
224
+ kind,
225
+ text:,
226
+ href:,
227
+ icon: nil,
228
+ icon_end: nil,
229
+ disabled:,
230
+ default_label:,
231
+ id:,
232
+ html:,
233
+ aria:,
234
+ data:,
235
+ desperately_need_a_class:,
236
+ &content
237
+ )
238
+ validate_content!(text, content, icon || icon_end, name: "#{kind} label or icon")
239
+ validate_aria!(aria, reserved: %w[disabled current])
240
+ item_aria = accessible_item_aria(aria, text:, content:, default_label:)
241
+
242
+ button = pagination_button(
243
+ text,
244
+ href:,
245
+ icon:,
246
+ icon_end:,
247
+ disabled:,
248
+ id:,
249
+ html:,
250
+ aria: item_aria,
251
+ data:,
252
+ desperately_need_a_class:
253
+ )
80
254
 
81
- def page_link(text = nil, disabled: nil, **attrs)
82
- a(
83
- **mattr(
84
- attrs,
85
- role: "link",
86
- aria: { disabled: disabled.to_s },
87
- class: link_class
255
+ append(Item.new(kind:, button:, current_page: nil, content:, current: false, label: nil))
256
+ end
257
+
258
+ def pagination_button(
259
+ text,
260
+ href:,
261
+ disabled:,
262
+ icon: nil,
263
+ icon_end: nil,
264
+ id:,
265
+ html:,
266
+ aria:,
267
+ data:,
268
+ desperately_need_a_class:
269
+ )
270
+ Button.new(
271
+ text,
272
+ href: missing_href?(href) ? "#" : href,
273
+ variant: :ghost,
274
+ size: :sm,
275
+ icon:,
276
+ icon_end:,
277
+ id:,
278
+ disabled:,
279
+ html:,
280
+ aria:,
281
+ data:,
282
+ desperately_need_a_class:
283
+ )
284
+ end
285
+
286
+ def append(item)
287
+ validate_choice!(:item_kind, item.kind, ITEM_KINDS)
288
+
289
+ case item.kind
290
+ when :previous
291
+ raise ArgumentError, "Pagination accepts at most one previous item" if @items.any? { |entry| entry.kind == :previous }
292
+ raise ArgumentError, "Pagination previous must be the first item" if @items.any?
293
+ when :next
294
+ raise ArgumentError, "Pagination accepts at most one next item" if @items.any? { |entry| entry.kind == :next }
295
+ else
296
+ raise ArgumentError, "Pagination next must be the last item" if @items.any? { |entry| entry.kind == :next }
297
+ end
298
+
299
+ if item.current && @items.any?(&:current)
300
+ raise ArgumentError, "Pagination accepts at most one current page"
301
+ end
302
+
303
+ @items << item
304
+ nil
305
+ end
306
+
307
+ def render_item(item)
308
+ li(
309
+ **slot_attributes(
310
+ :item,
311
+ attributes: { data: { kind: item.kind } }
88
312
  )
89
313
  ) do
90
- yield
314
+ if item.kind == :ellipsis
315
+ render_ellipsis(item)
316
+ elsif item.current_page
317
+ render_current_page(item)
318
+ else
319
+ render_in_slot(item.button, item.kind, &item.content)
320
+ end
321
+ end
322
+ end
323
+
324
+ def render_current_page(item)
325
+ span(**item.current_page.attributes) do
326
+ span(**slot_attributes(:current_label)) do
327
+ item.content ? render(item.content) : plain(item.current_page.text.to_s)
328
+ end
329
+ end
330
+ end
331
+
332
+ def render_ellipsis(item)
333
+ span(**slot_attributes(:ellipsis, aria: { hidden: true })) { "…" }
334
+ span(**slot_attributes(:ellipsis_label)) { plain(item.label) }
335
+ end
336
+
337
+ def validate_collection!
338
+ raise ArgumentError, "Pagination requires at least one item" if @items.empty?
339
+
340
+ @items.each_cons(2) do |left, right|
341
+ if left.kind == :ellipsis && right.kind == :ellipsis
342
+ raise ArgumentError, "Pagination cannot contain consecutive ellipses"
343
+ end
344
+ end
345
+
346
+ if @items.any? { |item| item.kind == :ellipsis } && @items.none? { |item| item.kind == :page }
347
+ raise ArgumentError, "Pagination ellipsis requires at least one page"
91
348
  end
92
349
  end
93
350
 
94
- def nav_class
95
- "flex items-center justify-center gap-1 text-sm font-medium"
351
+ def validate_content!(text, content, icon = nil, name:)
352
+ return if content || icon || (!text.nil? && !text.to_s.strip.empty?)
353
+
354
+ raise ArgumentError, "Pagination #{name} is required"
96
355
  end
97
356
 
98
- def link_class
99
- "inline-flex items-center justify-center rounded-md border font-medium h-9 px-3 gap-2 border-transparent aria-disabled:text-muted-content [&>svg]:size-4"
357
+ def validate_label!(label, name:)
358
+ return if label.is_a?(String) && !label.strip.empty?
359
+
360
+ raise ArgumentError, "Pagination #{name} must be a non-blank String"
361
+ end
362
+
363
+ # An omitted label falls back to the default text, unless a content block
364
+ # supplies its own label.
365
+ def default_text(text, default, content)
366
+ return text unless text.equal?(UNSET)
367
+
368
+ content ? nil : default
369
+ end
370
+
371
+ def validate_aria!(aria, reserved:)
372
+ raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
373
+
374
+ normalized_keys = aria.keys.map { |key| key.to_s.downcase.tr("_", "-") }
375
+ collision = normalized_keys.find { |key| reserved.include?(key) }
376
+ return unless collision
377
+
378
+ raise ArgumentError, "Pagination owns aria-#{collision}"
379
+ end
380
+
381
+ def accessible_item_aria(aria, text:, content:, default_label:)
382
+ return aria if content || (!text.nil? && !text.to_s.strip.empty?)
383
+ return aria if aria.any? { |key, value| key.to_s.tr("_", "-") == "label" && value.to_s.strip.present? }
384
+
385
+ aria.merge(label: default_label)
100
386
  end
101
387
 
102
- def page_class
103
- "w-9 px-0"
388
+ def missing_href?(href)
389
+ href.nil? || (href.respond_to?(:empty?) && href.empty?)
104
390
  end
105
391
  end
106
392
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class PaginationBar < Component
5
+ Summary = Data.define(:text, :content, :html, :aria, :data, :css_class)
6
+ Child = Data.define(:component, :content)
7
+
8
+ def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
9
+ @summary = nil
10
+ @pagination = nil
11
+
12
+ super(
13
+ component: :pagination_bar,
14
+ attributes: { id: }.compact,
15
+ html:,
16
+ aria:,
17
+ data:,
18
+ desperately_need_a_class:
19
+ )
20
+ end
21
+
22
+ def view_template
23
+ yield self if block_given?
24
+ validate_regions!
25
+
26
+ div(**root_attributes) do
27
+ render_summary if @summary
28
+ render_in_slot(@pagination.component, :pagination, &@pagination.content)
29
+ end
30
+ end
31
+
32
+ alias :html_summary :summary
33
+
34
+ def summary(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
35
+ raise ArgumentError, "PaginationBar accepts at most one summary" if @summary
36
+ raise ArgumentError, "PaginationBar summary accepts text or a block, not both" if !text.nil? && block
37
+ unless block || (text.is_a?(String) && !text.strip.empty?)
38
+ raise ArgumentError, "PaginationBar summary requires non-blank text or a block"
39
+ end
40
+
41
+ @summary = Summary.new(
42
+ text:,
43
+ content: block,
44
+ html:,
45
+ aria:,
46
+ data:,
47
+ css_class: desperately_need_a_class
48
+ )
49
+ nil
50
+ end
51
+
52
+ def pagination(component, &content)
53
+ raise ArgumentError, "PaginationBar accepts exactly one Pagination" if @pagination
54
+ unless component.is_a?(NitroKit::Pagination)
55
+ raise ArgumentError, "PaginationBar pagination must be a NitroKit::Pagination"
56
+ end
57
+
58
+ @pagination = Child.new(component:, content:)
59
+ nil
60
+ end
61
+
62
+ private
63
+
64
+ def render_summary
65
+ p(
66
+ **slot_attributes(
67
+ :summary,
68
+ html: @summary.html,
69
+ aria: summary_aria,
70
+ data: @summary.data,
71
+ desperately_need_a_class: @summary.css_class
72
+ )
73
+ ) { text_or_block(@summary.text, &@summary.content) }
74
+ end
75
+
76
+ # Turbo Frame page changes replace the summary in place, so it announces
77
+ # politely unless the caller declares its own live region behavior.
78
+ def summary_aria
79
+ return @summary.aria if @summary.aria.keys.any? { |key| key.to_s.tr("_", "-") == "live" }
80
+
81
+ @summary.aria.merge(live: "polite")
82
+ end
83
+
84
+ def validate_regions!
85
+ return if @pagination
86
+
87
+ raise ArgumentError, "PaginationBar requires one Pagination"
88
+ end
89
+ end
90
+ end