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
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ Choice = Data.define(:label, :value, :disabled, :id, :description) do
5
+ def initialize(label:, value:, disabled: false, id: nil, description: nil)
6
+ unless disabled == true || disabled == false
7
+ raise ArgumentError, "disabled must be true or false; received #{disabled.inspect}"
8
+ end
9
+ if label.nil? || label.to_s.strip.empty?
10
+ raise ArgumentError, "choice label cannot be blank"
11
+ end
12
+ if value.nil?
13
+ raise ArgumentError, "choice value cannot be nil"
14
+ end
15
+ if id && (!id.is_a?(String) || id.strip.empty?)
16
+ raise ArgumentError, "choice id must be a non-blank String"
17
+ end
18
+ if description && (!description.is_a?(String) || description.strip.empty?)
19
+ raise ArgumentError, "choice description must be a non-blank String"
20
+ end
21
+
22
+ super(label:, value:, disabled:, id:, description:)
23
+ end
24
+
25
+ def self.coerce(choice)
26
+ case choice
27
+ when self
28
+ choice
29
+ when Hash
30
+ attributes = choice.symbolize_keys
31
+ unknown_keys = attributes.keys - %i[label value disabled id description]
32
+ if unknown_keys.any?
33
+ raise ArgumentError, "unknown choice attributes: #{unknown_keys.join(", ")}"
34
+ end
35
+ new(
36
+ label: attributes.fetch(:label),
37
+ value: attributes.fetch(:value),
38
+ disabled: attributes.fetch(:disabled, false),
39
+ id: attributes[:id],
40
+ description: attributes[:description]
41
+ )
42
+ when Array
43
+ unless choice.length.between?(1, 2)
44
+ raise ArgumentError,
45
+ "choice arrays hold a label and an optional value; " \
46
+ "pass a Hash to declare disabled, id, or description"
47
+ end
48
+
49
+ new(label: choice.fetch(0), value: choice.fetch(1, choice.fetch(0)))
50
+ else
51
+ new(label: choice, value: choice)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -2,146 +2,333 @@
2
2
 
3
3
  module NitroKit
4
4
  class Combobox < Component
5
- def initialize(
6
- options: [],
7
- id: nil,
8
-
9
- placement: "bottom",
10
- tab_inserts_suggestions: true,
11
- first_option_selection_mode: "selected",
12
- scroll_into_view_options: { block: "nearest", inline: "nearest" },
5
+ PLACEMENTS = %i[bottom_start bottom_end top_start top_end].freeze
13
6
 
14
- **attrs
7
+ def initialize(
8
+ id:,
9
+ name:,
10
+ label:,
11
+ options:,
12
+ value: nil,
13
+ placeholder: nil,
14
+ include_blank: true,
15
+ placement: :bottom_start,
16
+ required: false,
17
+ disabled: false,
18
+ autocomplete: "off",
19
+ html: {},
20
+ aria: {},
21
+ data: {},
22
+ control_aria: {},
23
+ desperately_need_a_class: nil
15
24
  )
16
- # floating-ui options
17
- @placement = placement
18
-
19
- # combobox-nav options
20
- @tab_inserts_suggestions = tab_inserts_suggestions
21
- @first_option_selection_mode = first_option_selection_mode
22
- @scroll_into_view_options = scroll_into_view_options
23
-
24
- @id = id || "nk--combobox-" + SecureRandom.hex(4)
25
+ @identifier = component_id(id)
26
+ @name = form_name(name)
27
+ @label = visible_label(label)
28
+ @options = typed_options(options)
29
+ @value = value
30
+ @selected_option = selected_option(value)
31
+ @placeholder = validate_optional_text!(:placeholder, placeholder)
32
+ @include_blank = validate_blank_option!(include_blank)
33
+ @placement = validate_choice!(:placement, placement, PLACEMENTS)
34
+ @required = validate_boolean!(:required, required)
35
+ @disabled = validate_boolean!(:disabled, disabled)
36
+ @autocomplete = autocomplete
37
+ @control_aria = attribute_hash(control_aria, name: "control_aria")
25
38
 
26
- @options = options
39
+ if !value.nil? && !@selected_option
40
+ raise ArgumentError, "Combobox value #{value.inspect} does not match a declared option"
41
+ end
42
+ if @label == false && !named_by_control_aria?
43
+ raise ArgumentError,
44
+ "a Combobox without label: requires control_aria: { label: } or control_aria: { labelledby: }"
45
+ end
27
46
 
28
47
  super(
29
- attrs,
30
- type: "text",
31
- class: input_class,
32
- data: {
33
- nk__combobox_target: "input",
34
- action: %w[
35
- focusin->nk--combobox#open
36
- focusin@window->nk--combobox#focusShift
37
- click@window->nk--combobox#windowClick
38
- input->nk--combobox#input
39
- keydown.esc->nk--combobox#clear
40
- keydown.down->nk--combobox#open
41
- ]
48
+ component: :combobox,
49
+ attributes: {
50
+ id: @identifier,
51
+ data: {
52
+ controller: "nk--combobox",
53
+ placement: placement_value,
54
+ state: "closed",
55
+ action: "click@window->nk--combobox#closeFromOutside",
56
+ nk__combobox_open_value: "false",
57
+ nk__combobox_required_value: @required,
58
+ nk__combobox_invalid_selection_value: I18n.t("nitro_kit.combobox.invalid_selection"),
59
+ nk__combobox_no_results_value: I18n.t("nitro_kit.combobox.no_results"),
60
+ nk__combobox_results_one_value: I18n.t("nitro_kit.combobox.results.one"),
61
+ nk__combobox_results_other_value: I18n.t("nitro_kit.combobox.results.other")
62
+ }
42
63
  },
43
- aria: {
44
- controls: id(:listbox)
45
- }
64
+ html:,
65
+ aria:,
66
+ data:,
67
+ desperately_need_a_class:
46
68
  )
47
69
  end
48
70
 
49
- attr_reader(
50
- :options,
51
- :placement,
52
- :tab_inserts_suggestions,
53
- :first_option_selection_mode,
54
- :scroll_into_view_options
55
- )
71
+ attr_reader :identifier, :name, :label, :options, :value, :placement
56
72
 
57
73
  def view_template
58
- div(
59
- class: "isolate",
60
- data: {
61
- slot: "control",
62
- controller: "nk--combobox",
63
- nk__combobox_placement_value: placement,
64
- nk__combobox_tab_inserts_suggestions_value: tab_inserts_suggestions.to_s,
65
- nk__combobox_first_option_selection_mode_value: first_option_selection_mode.to_s,
66
- nk__combobox_scroll_into_view_options_value: scroll_into_view_options&.to_json
67
- }
74
+ div(**root_attributes) do
75
+ render_label
76
+ render_control
77
+ render_native_select
78
+ render_listbox
79
+ render_status
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def render_label
86
+ return if label == false
87
+
88
+ render_in_slot(Label.new(label, for: input_id, id: label_id), :label)
89
+ end
90
+
91
+ def render_control
92
+ span(**slot_attributes(:control, attributes: { hidden: true }, data: { nk__combobox_target: "control" })) do
93
+ render_in_slot(
94
+ Input.new(
95
+ type: :text,
96
+ id: input_id,
97
+ value: @selected_option&.label&.to_s,
98
+ placeholder: @placeholder,
99
+ disabled: @disabled,
100
+ autocomplete: @autocomplete,
101
+ html: { role: "combobox" },
102
+ aria: @control_aria.merge(
103
+ autocomplete: "list",
104
+ controls: listbox_id,
105
+ expanded: false,
106
+ required: @required
107
+ ),
108
+ data: {
109
+ nk__combobox_target: "input",
110
+ action: [
111
+ "focus->nk--combobox#open",
112
+ "input->nk--combobox#filter",
113
+ "keydown->nk--combobox#navigate",
114
+ "blur->nk--combobox#validate"
115
+ ].join(" ")
116
+ }
117
+ ),
118
+ :input
119
+ )
120
+
121
+ svg(
122
+ **slot_attributes(
123
+ :icon,
124
+ attributes: {
125
+ viewbox: "0 0 16 16",
126
+ width: 16,
127
+ height: 16,
128
+ fill: "none",
129
+ stroke: "currentColor",
130
+ stroke_width: 1.5,
131
+ stroke_linecap: "round",
132
+ stroke_linejoin: "round",
133
+ focusable: "false",
134
+ aria: { hidden: true }
135
+ }
136
+ )
137
+ ) { |svg| svg.path(d: "m4 6 4 4 4-4") }
138
+ end
139
+ end
140
+
141
+ def render_native_select
142
+ render_in_slot(
143
+ Select.new(
144
+ options:,
145
+ id: value_id,
146
+ name:,
147
+ value: @selected_option&.value,
148
+ include_blank: @include_blank,
149
+ disabled: @disabled,
150
+ required: @required,
151
+ autocomplete: @autocomplete,
152
+ data: { nk__combobox_target: "native" },
153
+ control_aria: accessible_naming,
154
+ control_data: { nk__combobox_target: "value" }
155
+ ),
156
+ :native
157
+ )
158
+ end
159
+
160
+ def render_listbox
161
+ ul(
162
+ **slot_attributes(
163
+ :listbox,
164
+ attributes: {
165
+ id: listbox_id,
166
+ role: "listbox",
167
+ hidden: true,
168
+ aria: accessible_naming,
169
+ data: {
170
+ placement: placement_value,
171
+ state: "closed",
172
+ nk__combobox_target: "listbox"
173
+ }
174
+ }
175
+ )
68
176
  ) do
69
- span(class: wrapper_class) do
70
- render(Input.new(**attrs, value: display_value))
71
- chevron_icon
177
+ options.each_with_index { |option, index| render_option(option, index) }
178
+ end
179
+ end
180
+
181
+ def render_option(option, index)
182
+ selected = option.equal?(@selected_option)
183
+
184
+ li(
185
+ **slot_attributes(
186
+ :option,
187
+ attributes: {
188
+ id: option_id(index),
189
+ role: "option",
190
+ aria: {
191
+ disabled: option.disabled ? true : nil,
192
+ selected:,
193
+ label: option.description ? option.label.to_s : nil,
194
+ describedby: option.description ? option_description_id(index) : nil
195
+ }.compact,
196
+ data: {
197
+ value: option.value.to_s,
198
+ state: selected ? "selected" : "unselected",
199
+ nk__combobox_target: "option",
200
+ action: option.disabled ? nil : "click->nk--combobox#select pointermove->nk--combobox#activate"
201
+ }
202
+ }
203
+ )
204
+ ) do
205
+ span(**slot_attributes(:option_label)) { plain(option.label.to_s) }
206
+
207
+ if option.description
208
+ span(
209
+ **slot_attributes(:option_description, attributes: { id: option_description_id(index) })
210
+ ) { plain(option.description) }
72
211
  end
212
+ end
213
+ end
73
214
 
74
- # Since a combobox can function like a <select> element where the displayed
75
- # value and the form value differ, include the value in a hidden field
76
- input(
77
- type: "hidden",
78
- value: attrs[:value],
79
- name: attrs[:name],
80
- data: { nk__combobox_target: "hiddenField" }
215
+ def render_status
216
+ div(
217
+ **slot_attributes(
218
+ :status,
219
+ attributes: {
220
+ role: "status",
221
+ aria: { live: "polite", atomic: "true" },
222
+ data: { nk__combobox_target: "status" }
223
+ }
81
224
  )
225
+ )
226
+ end
227
+
228
+ def typed_options(value)
229
+ unless value.is_a?(Array) && value.any?
230
+ raise ArgumentError, "Combobox options must be a non-empty Array"
231
+ end
82
232
 
83
- ul(
84
- role: "listbox",
85
- id: id(:listbox),
86
- class: list_class,
87
- data: { nk__combobox_target: "list", state: "closed" }
88
- ) do
89
- options.each do |(key, value)|
90
- li(
91
- role: "option",
92
- data: { value: },
93
- class: merge_class(option_class)
94
- ) { key }
95
- end
233
+ coerced = value.map do |option|
234
+ unless option.is_a?(Choice) || option.is_a?(Hash) || option.is_a?(Array)
235
+ raise ArgumentError, "Combobox options must be Choice instances, Hashes, or label/value Arrays"
96
236
  end
237
+
238
+ Choice.coerce(option)
239
+ end
240
+
241
+ duplicates = coerced.group_by { |option| option.value.to_s }.select { |_key, options| options.many? }
242
+ if duplicates.any?
243
+ raise ArgumentError, "Combobox option values must be unique: #{duplicates.keys.join(", ")}"
97
244
  end
245
+
246
+ coerced.freeze
98
247
  end
99
248
 
100
- private
249
+ def selected_option(value)
250
+ return if value.nil?
101
251
 
102
- def id(suffix)
103
- "#{@id}-#{suffix}"
252
+ options.find { |option| option.value.to_s == value.to_s }
104
253
  end
105
254
 
106
- def display_value
107
- selected = options.find do |(key, value)|
108
- value.to_s == attrs[:value].to_s
109
- end
255
+ def placement_value
256
+ placement.to_s.tr("_", "-")
257
+ end
258
+
259
+ def input_id
260
+ "#{identifier}-input"
261
+ end
110
262
 
111
- selected&.first || attrs[:value]
263
+ def value_id
264
+ "#{identifier}-value"
112
265
  end
113
266
 
114
- def wrapper_class
115
- "inline-grid *:[grid-area:1/1] group/combobox"
267
+ def listbox_id
268
+ "#{identifier}-listbox"
116
269
  end
117
270
 
118
- def input_class
119
- "pr-8"
271
+ def option_id(index)
272
+ "#{identifier}-option-#{index + 1}"
120
273
  end
121
274
 
122
- def list_class
123
- [
124
- "absolute top-0 left-0 p-1 bg-background rounded-md border shadow-sm w-fit max-w-sm flex-col flex z-10",
125
- "max-h-60 overflow-y-auto",
126
- "data-[state=closed]:hidden [&:not(:has([role=option]))]:hidden",
127
- "[&_[aria-selected]]:bg-muted"
128
- ]
275
+ def option_description_id(index)
276
+ "#{option_id(index)}-description"
129
277
  end
130
278
 
131
- def option_class
132
- "hidden flex-none px-2 py-1 rounded font-medium truncate cursor-pointer hover:bg-muted [&[role=option]]:block"
279
+ def label_id
280
+ "#{identifier}-label"
133
281
  end
134
282
 
135
- def chevron_icon
136
- svg(
137
- class: "size-4 self-center place-self-end mr-2 pointer-events-none text-muted-content group-hover/combobox:text-foreground",
138
- viewbox: "0 0 24 24",
139
- fill: "none",
140
- stroke: "currentColor",
141
- stroke_width: 1
142
- ) do |svg|
143
- svg.path(d: "m6 9 6 6 6-6")
283
+ def accessible_naming
284
+ return { labelledby: label_id } unless label == false
285
+
286
+ naming = @control_aria.slice(*aria_naming_keys)
287
+ naming.presence || {}
288
+ end
289
+
290
+ def named_by_control_aria?
291
+ @control_aria.any? do |key, value|
292
+ aria_naming_keys.include?(key) && value.to_s.present?
144
293
  end
145
294
  end
295
+
296
+ def aria_naming_keys
297
+ @control_aria.keys.select do |key|
298
+ %w[label labelledby].include?(key.to_s.downcase.delete("_-"))
299
+ end
300
+ end
301
+
302
+ def component_id(value)
303
+ return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
304
+
305
+ raise ArgumentError, "Combobox id must be a non-blank String without whitespace"
306
+ end
307
+
308
+ def form_name(value)
309
+ return value if value.is_a?(String) && value.present?
310
+
311
+ raise ArgumentError, "Combobox name must be a non-blank String"
312
+ end
313
+
314
+ def visible_label(value)
315
+ return value if value == false
316
+ return value if value.is_a?(String) && value.present?
317
+
318
+ raise ArgumentError, "Combobox label must be a non-blank String or false"
319
+ end
320
+
321
+ def validate_optional_text!(name, value)
322
+ return if value.nil?
323
+ return value if value.is_a?(String) && value.present?
324
+
325
+ raise ArgumentError, "Combobox #{name} must be a non-blank String or nil"
326
+ end
327
+
328
+ def validate_blank_option!(value)
329
+ return value if value == true || value == false || value.is_a?(String) && value.present?
330
+
331
+ raise ArgumentError, "Combobox include_blank must be true, false, or a non-blank String"
332
+ end
146
333
  end
147
334
  end