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,75 +2,132 @@
2
2
 
3
3
  module NitroKit
4
4
  class Switch < Component
5
+ SIZES = %i[md lg].freeze
6
+
5
7
  def initialize(
8
+ label: nil,
9
+ description: nil,
10
+ id: nil,
11
+ name: nil,
12
+ value: "1",
13
+ unchecked_value: "0",
14
+ include_hidden: true,
6
15
  checked: false,
16
+ disabled: false,
17
+ required: false,
18
+ invalid: false,
7
19
  size: :md,
8
- description: nil,
9
- **attrs
20
+ html: {},
21
+ aria: {},
22
+ data: {},
23
+ control_html: {},
24
+ control_aria: {},
25
+ control_data: {},
26
+ desperately_need_a_class: nil
10
27
  )
11
- @checked = checked
12
- @size = size
13
- @description = description
28
+ @label = validate_optional_text!(:label, label)
29
+ @description = validate_optional_text!(:description, description)
30
+ @id = id
31
+ @name = name
32
+ @value = value
33
+ @unchecked_value = unchecked_value
34
+ @include_hidden = validate_boolean!(:include_hidden, include_hidden)
35
+ @checked = validate_boolean!(:checked, checked)
36
+ @disabled = validate_boolean!(:disabled, disabled)
37
+ @required = validate_boolean!(:required, required)
38
+ @invalid = validate_boolean!(:invalid, invalid)
39
+ @size = validate_choice!(:size, size, SIZES)
40
+ @control_html = control_html
41
+ @control_aria = control_aria
42
+ @control_data = control_data
14
43
 
15
- super(**attrs)
44
+ if include_hidden && unchecked_value.nil?
45
+ raise ArgumentError, "unchecked_value cannot be nil when include_hidden is true"
46
+ end
47
+ if description && (!id.is_a?(String) || id.strip.empty?)
48
+ raise ArgumentError, "switch requires a non-blank String id when description is present"
49
+ end
50
+
51
+ super(
52
+ component: :switch,
53
+ size: @size,
54
+ attributes: {
55
+ data: { disabled: @disabled ? "true" : nil }.compact
56
+ },
57
+ html:,
58
+ aria:,
59
+ data:,
60
+ desperately_need_a_class:
61
+ )
16
62
  end
17
63
 
18
- attr_reader :checked, :description, :size
64
+ attr_reader :label, :description, :id, :name, :value, :unchecked_value, :size
65
+
66
+ def view_template(&block)
67
+ require_accessible_name!(&block)
19
68
 
20
- def view_template
21
- button(
22
- **mattr(
23
- **attrs,
24
- type: "button",
25
- class: [ base_class, size_class ],
26
- data: { controller: "nk--switch", action: "nk--switch#toggle" },
27
- role: "switch",
28
- aria: { checked: checked.to_s }
29
- )
30
- ) do
31
- span(class: "sr-only") { description }
32
- handle
69
+ div(**root_attributes) do
70
+ render_hidden_control
71
+
72
+ if label.nil? && !block
73
+ render_control
74
+ render_track
75
+ else
76
+ render_in_slot(Label.new(for: id), :label) do
77
+ render_control
78
+ render_track
79
+ span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
80
+ end
81
+ end
82
+
83
+ # The description stays outside the label so aria-describedby carries it
84
+ # instead of it joining the control's accessible name.
85
+ if description
86
+ span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) }
87
+ end
33
88
  end
34
89
  end
35
90
 
36
91
  private
37
92
 
38
- def handle
39
- span(aria: { hidden: true }, class: handle_class, data: { slot: "handle" })
93
+ def render_track
94
+ span(**slot_attributes(:track), aria: { hidden: "true" }) do
95
+ span(**slot_attributes(:handle))
96
+ end
40
97
  end
41
98
 
42
- def base_class
43
- [
44
- "inline-flex items-center shrink-0",
45
- "bg-background rounded-full border",
46
- "transition-colors duration-200 ease-in-out",
47
- "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ring-offset-background",
48
-
49
- # Checked
50
- "[&[aria-checked=true]]:bg-foreground [&[aria-checked=true]]:border-foreground",
99
+ def render_hidden_control
100
+ return unless @include_hidden && name
51
101
 
52
- # Checked > Handle
53
- "[&[aria-checked=false]_[data-slot=handle]]:bg-primary",
54
- "[&[aria-checked=true]_[data-slot=handle]]:bg-background"
55
- ]
102
+ render_in_slot(
103
+ Input.new(
104
+ type: :hidden,
105
+ name:,
106
+ value: unchecked_value,
107
+ disabled: @disabled,
108
+ autocomplete: "off"
109
+ ),
110
+ :unchecked
111
+ )
56
112
  end
57
113
 
58
- def handle_class
59
- [
60
- "pointer-events-none inline-block rounded-full shadow-sm ring-0",
61
- "transition translate-x-[3px] duration-200 ease-in-out"
62
- ]
63
- end
64
-
65
- def size_class
66
- case size
67
- when :md
68
- "h-6 w-10 [&_[data-slot=handle]]:size-4 [&[aria-checked=true]_[data-slot=handle]]:translate-x-[calc(theme(spacing.5)-1px)]"
69
- when :sm
70
- "h-5 w-8 [&_[data-slot=handle]]:size-3 [&[aria-checked=true]_[data-slot=handle]]:translate-x-[calc(theme(spacing.4)-1px)]"
71
- else
72
- raise ArgumentError, "Unknown size: #{size}"
73
- end
114
+ def render_control
115
+ render_in_slot(
116
+ Input.new(
117
+ type: :checkbox,
118
+ id:,
119
+ name:,
120
+ value:,
121
+ checked: @checked,
122
+ disabled: @disabled,
123
+ required: @required,
124
+ # role is owned by Switch; a control_html role: is replaced, not merged.
125
+ html: @control_html.merge(role: "switch"),
126
+ aria: control_aria,
127
+ data: @control_data
128
+ ),
129
+ :control
130
+ )
74
131
  end
75
132
  end
76
133
  end
@@ -2,87 +2,279 @@
2
2
 
3
3
  module NitroKit
4
4
  class Table < Component
5
- def initialize(wrapper: {}, **attrs)
6
- super(attrs)
7
- @wrapper = wrapper
5
+ ALIGNMENTS = %i[left center right].freeze
6
+ DEFAULT_ALIGNMENT = :left
7
+ SCOPES = %i[col row].freeze
8
+ DIRECTIONS = %i[asc desc].freeze
9
+ SORT_ICONS = { asc: "arrow-up", desc: "arrow-down", none: "chevrons-up-down" }.freeze
10
+ Section = Data.define(:content, :html, :aria, :data, :css_class)
11
+ Caption = Data.define(:text, :content, :html, :aria, :data, :css_class)
12
+
13
+ # Table renders in two phases. `caption`, `thead`, and `tbody` only collect
14
+ # declarations, so the component owns caption → head → body order regardless
15
+ # of caller order. `tr`, `th`, and `td` run inside a collected block and
16
+ # render immediately, which is why their validation raises before the cell
17
+ # it describes is emitted rather than after the table is complete.
18
+ def initialize(
19
+ sort: nil,
20
+ direction: nil,
21
+ id: nil,
22
+ html: {},
23
+ aria: {},
24
+ data: {},
25
+ table_html: {},
26
+ table_aria: {},
27
+ table_data: {},
28
+ desperately_need_a_class: nil
29
+ )
30
+ @sort = sort.nil? ? nil : normalize_sort_key(sort, name: "sort")
31
+ @direction = direction.nil? ? nil : validate_choice!(:direction, direction.to_sym, DIRECTIONS)
32
+ unless @sort.nil? == @direction.nil?
33
+ raise ArgumentError, "Table sort: and direction: must both be set or both be nil"
34
+ end
35
+
36
+ super(
37
+ component: :table,
38
+ attributes: { id:, data: { sort: @sort, direction: @direction }.compact }.compact,
39
+ html:,
40
+ aria:,
41
+ data:,
42
+ desperately_need_a_class:
43
+ )
44
+
45
+ @table_attributes = slot_attributes(
46
+ :element,
47
+ html: table_html,
48
+ aria: table_aria,
49
+ data: table_data
50
+ )
51
+ @caption = nil
52
+ @head = nil
53
+ @bodies = []
54
+ @sort_keys = []
8
55
  end
9
56
 
10
- attr_reader :wrapper
57
+ attr_reader :sort, :direction
11
58
 
12
59
  def view_template
13
- div(**mattr(wrapper, class: "w-full overflow-x-scroll")) do
14
- table(
15
- **mattr(
16
- attrs,
17
- class: "w-full caption-bottom text-sm divide-y"
18
- )
19
- ) do
20
- yield
60
+ @collecting_sections = true
61
+ yield self if block_given?
62
+ @collecting_sections = false
63
+
64
+ div(**root_attributes) do
65
+ table(**@table_attributes) do
66
+ render_caption if @caption
67
+ render_section(:head, @head, :html_thead) if @head
68
+ @bodies.each { |body| render_section(:body, body, :html_tbody) }
21
69
  end
22
70
  end
71
+ ensure
72
+ @collecting_sections = false
23
73
  end
24
74
 
75
+ alias :html_caption :caption
25
76
  alias :html_thead :thead
26
77
  alias :html_tbody :tbody
27
78
  alias :html_tr :tr
28
79
  alias :html_th :th
29
80
  alias :html_td :td
30
81
 
31
- def thead(**attrs)
32
- builder do
33
- html_thead(**attrs) { yield }
34
- end
82
+ def caption(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
83
+ ensure_collecting_sections!(:caption)
84
+ raise ArgumentError, "Table accepts at most one caption" if @caption
85
+ raise ArgumentError, "Table caption accepts text or a block, not both" if !text.nil? && block
86
+ validate_content_text!("Table caption", text) unless block
87
+
88
+ @caption = Caption.new(
89
+ text:,
90
+ content: block,
91
+ html:,
92
+ aria:,
93
+ data:,
94
+ css_class: desperately_need_a_class
95
+ )
96
+ nil
35
97
  end
36
98
 
37
- def tbody(**attrs)
38
- builder do
39
- html_tbody(**mattr(attrs, class: "[&_tr:last-child]:border-0")) { yield }
40
- end
99
+ def thead(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
100
+ ensure_collecting_sections!(:thead)
101
+ raise ArgumentError, "Table accepts at most one thead" if @head
102
+ raise ArgumentError, "Table thead requires a block" unless block
103
+
104
+ @head = Section.new(content: block, html:, aria:, data:, css_class: desperately_need_a_class)
105
+ nil
41
106
  end
42
107
 
43
- def tr(**attrs)
44
- builder do
45
- html_tr(**mattr(attrs, class: "border-b")) { yield }
108
+ def tbody(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
109
+ ensure_collecting_sections!(:tbody)
110
+ raise ArgumentError, "Table tbody requires a block" unless block
111
+
112
+ @bodies << Section.new(content: block, html:, aria:, data:, css_class: desperately_need_a_class)
113
+ nil
114
+ end
115
+
116
+ def tr(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
117
+ raise ArgumentError, "Table rows must be declared inside thead or tbody" unless @rendering_section
118
+ raise ArgumentError, "Table tr requires a block" unless block
119
+
120
+ html_tr(**slot_attributes(:row, html:, aria:, data:, desperately_need_a_class:)) do
121
+ @rendering_row = true
122
+ render(block)
123
+ ensure
124
+ @rendering_row = false
46
125
  end
47
126
  end
48
127
 
49
- def th(text = nil, align: :left, **attrs, &block)
50
- builder do
51
- html_th(**mattr(attrs, class: [ header_cell_classes, cell_classes, align_classes(align), "font-medium" ])) do
128
+ def th(
129
+ text = nil,
130
+ align: :left,
131
+ scope: :col,
132
+ sort: nil,
133
+ href: nil,
134
+ sort_data: {},
135
+ html: {},
136
+ aria: {},
137
+ data: {},
138
+ desperately_need_a_class: nil,
139
+ &block
140
+ )
141
+ ensure_rendering_row!(:th)
142
+ raise ArgumentError, "Table th accepts text or a block, not both" if !text.nil? && block
143
+ raise ArgumentError, "Table th href: requires sort:" if sort.nil? && !href.nil?
144
+ alignment = validate_choice!(:align, align, ALIGNMENTS)
145
+ scope = validate_choice!(:scope, scope, SCOPES)
146
+ key = sort.nil? ? nil : declare_sort_header!(sort, href:)
147
+
148
+ html_th(
149
+ **slot_attributes(
150
+ :header,
151
+ attributes: {
152
+ scope:,
153
+ aria: key ? { sort: aria_sort(key) } : {},
154
+ data: { align: alignment_value(alignment), sort_key: key }.compact
155
+ },
156
+ html:,
157
+ aria:,
158
+ data:,
159
+ desperately_need_a_class:
160
+ )
161
+ ) do
162
+ if key
163
+ render_sort_link(key, text, href:, data: sort_data, &block)
164
+ else
52
165
  text_or_block(text, &block)
53
166
  end
54
167
  end
55
168
  end
56
169
 
57
- def td(text = nil, align: nil, **attrs, &block)
58
- builder do
59
- html_td(**mattr(attrs, class: [ cell_classes, align_classes(align) ])) do
60
- text_or_block(text, &block)
170
+ def td(
171
+ text = nil,
172
+ align: :left,
173
+ html: {},
174
+ aria: {},
175
+ data: {},
176
+ desperately_need_a_class: nil,
177
+ &block
178
+ )
179
+ ensure_rendering_row!(:td)
180
+ raise ArgumentError, "Table td accepts text or a block, not both" if !text.nil? && block
181
+ alignment = validate_choice!(:align, align, ALIGNMENTS)
182
+ html_td(
183
+ **slot_attributes(
184
+ :cell,
185
+ attributes: { data: { align: alignment_value(alignment) }.compact },
186
+ html:,
187
+ aria:,
188
+ data:,
189
+ desperately_need_a_class:
190
+ )
191
+ ) { text_or_block(text, &block) }
192
+ end
193
+
194
+ private
195
+
196
+ def alignment_value(alignment)
197
+ alignment unless alignment == DEFAULT_ALIGNMENT
198
+ end
199
+
200
+ def render_sort_link(key, text, href:, data:, &block)
201
+ a(**slot_attributes(:sort, attributes: { href: }, data:)) do
202
+ span(**slot_attributes(:sort_label)) do
203
+ block ? text_or_block(nil, &block) : plain(text || key.humanize)
61
204
  end
205
+ render_in_slot(
206
+ Icon.new(SORT_ICONS.fetch(active_sort?(key) ? direction : :none), size: :xs),
207
+ :sort_indicator
208
+ )
62
209
  end
63
210
  end
64
211
 
65
- private
212
+ def active_sort?(key) = sort == key
213
+
214
+ def aria_sort(key)
215
+ return "none" unless active_sort?(key)
216
+
217
+ direction == :asc ? "ascending" : "descending"
218
+ end
219
+
220
+ def declare_sort_header!(key, href:)
221
+ key = normalize_sort_key(key, name: "th sort")
222
+ unless href.is_a?(String) && !href.strip.empty?
223
+ raise ArgumentError, "Table th sort: requires a non-blank String href:"
224
+ end
225
+ raise ArgumentError, "Table sort keys must be unique: #{key.inspect}" if @sort_keys.include?(key)
66
226
 
67
- def header_cell_classes
68
- ""
227
+ @sort_keys << key
228
+ key
69
229
  end
70
230
 
71
- def cell_classes
72
- "py-2 min-h-10 px-2"
231
+ def normalize_sort_key(value, name:)
232
+ normalized = value.to_s.strip if value.is_a?(Symbol) || value.is_a?(String)
233
+ return normalized if normalized.present?
234
+
235
+ raise ArgumentError, "Table #{name} must be a Symbol or non-blank String"
236
+ end
237
+
238
+ def render_caption
239
+ html_caption(
240
+ **slot_attributes(
241
+ :caption,
242
+ html: @caption.html,
243
+ aria: @caption.aria,
244
+ data: @caption.data,
245
+ desperately_need_a_class: @caption.css_class
246
+ )
247
+ ) { text_or_block(@caption.text, &@caption.content) }
73
248
  end
74
249
 
75
- def align_classes(align = nil)
76
- case align
77
- when :left
78
- "text-left"
79
- when :center
80
- "text-center"
81
- when :right
82
- "text-right"
83
- else
84
- nil
250
+ def render_section(slot, section, tag)
251
+ public_send(
252
+ tag,
253
+ **slot_attributes(
254
+ slot,
255
+ html: section.html,
256
+ aria: section.aria,
257
+ data: section.data,
258
+ desperately_need_a_class: section.css_class
259
+ )
260
+ ) do
261
+ @rendering_section = true
262
+ render(section.content)
263
+ ensure
264
+ @rendering_section = false
85
265
  end
86
266
  end
267
+
268
+ def ensure_collecting_sections!(name)
269
+ return if @collecting_sections
270
+
271
+ raise ArgumentError, "Table #{name} must be declared inside the render block"
272
+ end
273
+
274
+ def ensure_rendering_row!(name)
275
+ return if @rendering_row
276
+
277
+ raise ArgumentError, "Table #{name} must be declared inside tr"
278
+ end
87
279
  end
88
280
  end