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,122 +2,253 @@
2
2
 
3
3
  module NitroKit
4
4
  class Toast < Component
5
- class FlashMessages < Component
6
- def initialize(flash)
7
- @flash = flash
8
- end
5
+ DEFAULT_ID = "nk-toast"
9
6
 
10
- attr_reader :flash
11
-
12
- def view_template
13
- flash.each do |severity, message|
14
- render(
15
- Toast::Item.new(
16
- description: message,
17
- variant: severity.to_sym == :alert ? :error : :default
18
- )
19
- )
20
- end
21
- end
22
- end
7
+ ItemDeclaration = ::Data.define(:component, :content)
8
+ private_constant :ItemDeclaration
23
9
 
24
10
  class Item < Component
25
- VARIANTS = %i[default warning error success]
26
-
27
- def initialize(title: nil, description: nil, variant: :default, **attrs)
28
- @title = title
29
- @description = description
30
- @variant = variant
11
+ VARIANTS = %i[default info success warning error].freeze
12
+ ASSERTIVE_VARIANTS = %i[error].freeze
13
+
14
+ def initialize(
15
+ title: nil,
16
+ description: nil,
17
+ variant: :default,
18
+ dismissible: true,
19
+ id: nil,
20
+ html: {},
21
+ aria: {},
22
+ data: {},
23
+ desperately_need_a_class: nil
24
+ )
25
+ @title = optional_text(:title, title)
26
+ @description = optional_text(:description, description)
27
+ @variant = validate_choice!(:variant, variant, VARIANTS)
28
+ @dismissible = validate_boolean!(:dismissible, dismissible)
31
29
 
32
30
  super(
33
- **mattr(
34
- attrs,
35
- class: [
36
- base_class,
37
- variant_class
38
- ],
39
- role: "status",
40
- aria: { live: "off", atomic: "true" },
41
- tabindex: "0",
42
- data: { state: "closed" }
43
- )
31
+ component: :toast_item,
32
+ attributes: {
33
+ id:,
34
+ role: ASSERTIVE_VARIANTS.include?(@variant) ? "alert" : "status",
35
+ data: {
36
+ state: "open",
37
+ turbo_temporary: true,
38
+ nk__toast_target: "item",
39
+ nk__toast_permanent: dismissible ? nil : "true",
40
+ action: [
41
+ "pointerenter->nk--toast#pause",
42
+ "pointerleave->nk--toast#resume",
43
+ "focusin->nk--toast#pause",
44
+ "focusout->nk--toast#resume",
45
+ "transitionend->nk--toast#remove"
46
+ ].join(" ")
47
+ }
48
+ },
49
+ html:,
50
+ aria: aria.merge(atomic: true),
51
+ data:,
52
+ variant:,
53
+ desperately_need_a_class:
44
54
  )
45
55
  end
46
56
 
47
- attr_reader :title, :description, :variant
57
+ attr_reader :title, :description, :variant, :dismissible
48
58
 
49
59
  def view_template(&block)
50
- li(**attrs) do
51
- div(class: "grid gap-1") do
52
- div(class: "text-sm font-semibold", data: { slot: "title" }) do
53
- title && plain(title)
54
- end
55
-
56
- div(class: "text-sm opacity-90", data: { slot: "description" }) do
57
- text_or_block(description, &block)
58
- end
60
+ if title.nil? && description.nil? && !block
61
+ raise ArgumentError, "Toast item requires a title, description, or block"
62
+ end
63
+
64
+ li(**root_attributes) do
65
+ div(**slot_attributes(:content)) do
66
+ p(**slot_attributes(:title)) { plain(title) } if title
67
+ div(**slot_attributes(:description)) do
68
+ block ? yield : plain(description.to_s)
69
+ end if description || block
59
70
  end
71
+
72
+ dismiss_button if dismissible
60
73
  end
61
74
  end
62
75
 
63
76
  private
64
77
 
65
- def base_class
66
- "shrink-0 pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md p-4 pr-8 shadow-lg transition-all border opacity-0 transition-all data-[state=open]:opacity-100 data-[state=open]:-translate-y-full"
78
+ def dismiss_button
79
+ render_in_slot(
80
+ Button.new(
81
+ icon: :x,
82
+ variant: :ghost,
83
+ size: :sm,
84
+ aria: { label: I18n.t("nitro_kit.toast.dismiss") },
85
+ data: { action: "click->nk--toast#dismiss" }
86
+ ),
87
+ :dismiss
88
+ )
67
89
  end
68
90
 
69
- def variant_class
70
- case variant
71
- when :default
72
- "border-border bg-background text-foreground"
73
- when :warning
74
- "bg-yellow-50 dark:bg-yellow-950 text-yellow-900 dark:text-yellow-100 border-yellow-500/80 dark:border-yellow-400/50"
75
- when :success
76
- "bg-green-50 dark:bg-green-950 text-green-900 dark:text-green-100 border-green-500/80 dark:border-green-400/50"
77
- when :error
78
- "bg-red-50 dark:bg-red-950 text-red-900 dark:text-red-100 border-red-400/80 dark:border-red-400/50"
79
- else
80
- raise ArgumentError, "Invalid variant `#{variant}'"
81
- end
91
+ def optional_text(name, value)
92
+ return if value.nil?
93
+ return value if value.is_a?(String) && value.present?
94
+
95
+ raise ArgumentError, "Toast item #{name} must be nil or a non-blank String"
82
96
  end
83
97
  end
84
98
 
85
- def initialize(**attrs)
86
- super(
87
- attrs,
88
- role: "region",
89
- tabindex: "-1",
90
- aria: { label: "Notifications" },
91
- class: "pointer-events-none"
99
+ class FlashMessages < Toast
100
+ VARIANT_BY_SEVERITY = {
101
+ "alert" => :error,
102
+ "error" => :error,
103
+ "warning" => :warning,
104
+ "success" => :success,
105
+ "info" => :info,
106
+ "notice" => :default
107
+ }.freeze
108
+
109
+ def initialize(
110
+ flash:,
111
+ duration: 5_000,
112
+ label: I18n.t("nitro_kit.toast.label"),
113
+ id: DEFAULT_ID,
114
+ html: {},
115
+ aria: {},
116
+ data: {},
117
+ desperately_need_a_class: nil
92
118
  )
93
- end
119
+ unless flash.respond_to?(:each)
120
+ raise ArgumentError, "Toast::FlashMessages flash must be enumerable"
121
+ end
122
+
123
+ @flash = flash
94
124
 
95
- def view_template
96
- div(**attrs) do
97
- ol(class: list_class, data: { nk__toast_target: "list" })
125
+ super(
126
+ duration:,
127
+ label:,
128
+ id:,
129
+ html:,
130
+ aria:,
131
+ data:,
132
+ desperately_need_a_class:
133
+ )
98
134
  end
99
135
 
100
- flash_sink
136
+ attr_reader :flash
101
137
 
102
- template(data: { nk__toast_target: "template" }) do
103
- item
138
+ def view_template
139
+ super do |toast|
140
+ flash.each do |severity, message|
141
+ toast.item(
142
+ description: message.to_s,
143
+ variant: VARIANT_BY_SEVERITY.fetch(severity.to_s, :default)
144
+ )
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ def initialize(
151
+ duration: 5_000,
152
+ label: I18n.t("nitro_kit.toast.label"),
153
+ id: DEFAULT_ID,
154
+ html: {},
155
+ aria: {},
156
+ data: {},
157
+ desperately_need_a_class: nil
158
+ )
159
+ unless duration.is_a?(Integer) && duration.positive?
160
+ raise ArgumentError, "Toast duration must be a positive Integer"
161
+ end
162
+ unless label.is_a?(String) && label.present?
163
+ raise ArgumentError, "Toast label must be a non-blank String"
164
+ end
165
+ unless id.is_a?(String) && id.present? && !id.match?(/\s/)
166
+ raise ArgumentError, "Toast id: must be a non-blank String without whitespace"
104
167
  end
168
+
169
+ @duration = duration
170
+ @id = id
171
+ @items = []
172
+
173
+ super(
174
+ component: :toast,
175
+ attributes: {
176
+ id:,
177
+ role: "region",
178
+ data: {
179
+ controller: "nk--toast",
180
+ action: "turbo:before-cache@document->nk--toast#teardown",
181
+ nk__toast_duration_value: duration
182
+ }
183
+ },
184
+ html:,
185
+ aria: aria.merge(label:, live: "polite"),
186
+ data:,
187
+ desperately_need_a_class:
188
+ )
105
189
  end
106
190
 
107
- def item(title: nil, description: nil, **attrs, &block)
108
- render(Item.new(title:, description:, **attrs), &block)
191
+ attr_reader :duration, :id
192
+
193
+ def list_id
194
+ "#{id}-list"
109
195
  end
110
196
 
111
- def flash_sink
112
- div(id: "nk--toast-sink", data: { nk__toast_target: "sink" }, hidden: true) do
113
- render(FlashMessages.new(view_context.flash))
197
+ def view_template(&block)
198
+ collect_items(&block)
199
+
200
+ section(**root_attributes) do
201
+ ol(**slot_attributes(:list, attributes: { id: list_id })) do
202
+ @items.each do |entry|
203
+ render_in_slot(entry.component, :item, &entry.content)
204
+ end
205
+ end
114
206
  end
115
207
  end
116
208
 
209
+ def item(
210
+ title: nil,
211
+ description: nil,
212
+ variant: :default,
213
+ dismissible: true,
214
+ id: nil,
215
+ html: {},
216
+ aria: {},
217
+ data: {},
218
+ desperately_need_a_class: nil,
219
+ &content
220
+ )
221
+ ensure_collecting!
222
+ component = Item.new(
223
+ title:,
224
+ description:,
225
+ variant:,
226
+ dismissible:,
227
+ id:,
228
+ html:,
229
+ aria:,
230
+ data:,
231
+ desperately_need_a_class:
232
+ )
233
+ @items << ItemDeclaration.new(component:, content:)
234
+ nil
235
+ end
236
+
117
237
  private
118
238
 
119
- def list_class
120
- "fixed z-[100] flex max-h-screen w-full p-5 bottom-0 right-0 flex-col h-0 md:max-w-[420px]"
239
+ def collect_items
240
+ return unless block_given?
241
+
242
+ @collecting = true
243
+ yield(self)
244
+ ensure
245
+ @collecting = false
246
+ end
247
+
248
+ def ensure_collecting!
249
+ return if @collecting
250
+
251
+ raise ArgumentError, "Toast items must be declared inside the render block"
121
252
  end
122
253
  end
123
254
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class Toolbar < Component
5
+ Region = Data.define(:content, :html, :aria, :data, :css_class)
6
+
7
+ def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
8
+ @leading_region = nil
9
+ @trailing_region = nil
10
+
11
+ super(
12
+ component: :toolbar,
13
+ attributes: { id: }.compact,
14
+ html:,
15
+ aria:,
16
+ data:,
17
+ desperately_need_a_class:
18
+ )
19
+ end
20
+
21
+ def view_template
22
+ yield self if block_given?
23
+ validate_regions!
24
+
25
+ div(**root_attributes) do
26
+ render_region(:leading, @leading_region) if @leading_region
27
+ render_region(:trailing, @trailing_region) if @trailing_region
28
+ end
29
+ end
30
+
31
+ def leading(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
32
+ raise ArgumentError, "Toolbar accepts at most one leading region" if @leading_region
33
+ @leading_region = Region.new(
34
+ content: block,
35
+ html:,
36
+ aria:,
37
+ data:,
38
+ css_class: desperately_need_a_class
39
+ )
40
+ nil
41
+ end
42
+
43
+ def trailing(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
44
+ raise ArgumentError, "Toolbar accepts at most one trailing region" if @trailing_region
45
+ @trailing_region = Region.new(
46
+ content: block,
47
+ html:,
48
+ aria:,
49
+ data:,
50
+ css_class: desperately_need_a_class
51
+ )
52
+ nil
53
+ end
54
+
55
+ private
56
+
57
+ def render_region(name, region)
58
+ div(
59
+ **slot_attributes(
60
+ name,
61
+ html: region.html,
62
+ aria: region.aria,
63
+ data: region.data,
64
+ desperately_need_a_class: region.css_class
65
+ )
66
+ ) { render(region.content) if region.content }
67
+ end
68
+
69
+ def validate_regions!
70
+ return if @leading_region || @trailing_region
71
+
72
+ raise ArgumentError, "Toolbar requires a leading or trailing region"
73
+ end
74
+ end
75
+ end
@@ -2,54 +2,255 @@
2
2
 
3
3
  module NitroKit
4
4
  class Tooltip < Component
5
- def initialize(content: nil, placement: nil, **attrs)
6
- @string_content = content
7
- @placement = placement
5
+ PLACEMENTS = %i[top right bottom left].freeze
6
+ HTML_TRIGGERS = %i[div span].freeze
7
+
8
+ TriggerAttributes = ::Data.define(:html, :aria, :data)
9
+
10
+ Trigger = ::Data.define(
11
+ :as,
12
+ :text,
13
+ :href,
14
+ :variant,
15
+ :size,
16
+ :icon,
17
+ :icon_end,
18
+ :label,
19
+ :disabled,
20
+ :target,
21
+ :rel,
22
+ :download,
23
+ :html,
24
+ :aria,
25
+ :data,
26
+ :css_class,
27
+ :content
28
+ )
29
+
30
+ def initialize(
31
+ id:,
32
+ content:,
33
+ placement: :top,
34
+ html: {},
35
+ aria: {},
36
+ data: {},
37
+ desperately_need_a_class: nil
38
+ )
39
+ @identifier = component_id(id)
40
+ @content = tooltip_content(content)
41
+ @placement = validate_choice!(:placement, placement, PLACEMENTS)
8
42
 
9
43
  super(
10
- attrs,
11
- data: {
12
- action: "mouseover->nk--tooltip#open mouseout->nk--tooltip#close",
13
- controller: "nk--tooltip",
14
- nk__tooltip_placement_value: placement
15
- }
44
+ component: :tooltip,
45
+ attributes: {
46
+ id: @identifier,
47
+ data: {
48
+ controller: "nk--tooltip",
49
+ placement: @placement,
50
+ dismissed: nil,
51
+ action: "pointerleave->nk--tooltip#resetIfUninterested focusout->nk--tooltip#resetIfUninterested"
52
+ }
53
+ },
54
+ html:,
55
+ aria:,
56
+ data:,
57
+ desperately_need_a_class:
16
58
  )
17
59
  end
18
60
 
19
- attr_reader :placement, :string_content
61
+ attr_reader :identifier, :content, :placement
20
62
 
21
- def view_template
22
- span(**attrs) do
23
- content(string_content) if string_content
24
- yield
25
- end
26
- end
63
+ def view_template(&block)
64
+ collect_trigger(&block)
27
65
 
28
- def content(text = nil, **attrs, &block)
29
- builder do
30
- div(
31
- **mattr(
32
- attrs,
33
- class: tooltip_class,
34
- data: {
35
- state: "closed",
36
- nk__tooltip_target: "content"
66
+ span(**root_attributes) do
67
+ render_trigger
68
+ span(
69
+ **slot_attributes(
70
+ :content,
71
+ attributes: {
72
+ id: content_id,
73
+ role: "tooltip"
37
74
  }
38
75
  )
39
- ) do
40
- text_or_block(text, &block)
41
- end
76
+ ) { plain(content) }
42
77
  end
43
78
  end
44
79
 
80
+ def trigger(
81
+ text = nil,
82
+ as: Button,
83
+ href: nil,
84
+ variant: :default,
85
+ size: :md,
86
+ icon: nil,
87
+ icon_end: nil,
88
+ label: nil,
89
+ target: nil,
90
+ rel: nil,
91
+ download: nil,
92
+ html: {},
93
+ aria: {},
94
+ data: {},
95
+ desperately_need_a_class: nil,
96
+ &content
97
+ )
98
+ ensure_collecting!
99
+ raise ArgumentError, "Tooltip accepts exactly one trigger" if @trigger
100
+ unless as == Button || as == :custom || HTML_TRIGGERS.include?(as)
101
+ raise ArgumentError, "Tooltip as: must be NitroKit::Button, :custom, :div, or :span"
102
+ end
103
+ if text.nil? && !content && icon.nil?
104
+ raise ArgumentError, "Tooltip trigger requires text, a block, or an icon"
105
+ end
106
+ if as == :custom && content&.arity != 1
107
+ raise ArgumentError, "Tooltip custom trigger block must accept trigger attributes"
108
+ end
109
+ if as != Button && [ href, icon, icon_end, label, target, rel, download ].any?
110
+ raise ArgumentError, "Tooltip #{as.inspect} trigger does not accept Button options"
111
+ end
112
+
113
+ @trigger = Trigger.new(
114
+ as:,
115
+ text:,
116
+ href:,
117
+ variant:,
118
+ size:,
119
+ icon:,
120
+ icon_end:,
121
+ label:,
122
+ disabled: false,
123
+ target:,
124
+ rel:,
125
+ download:,
126
+ html:,
127
+ aria:,
128
+ data:,
129
+ css_class: desperately_need_a_class,
130
+ content:
131
+ )
132
+ nil
133
+ end
134
+
45
135
  private
46
136
 
47
- def tooltip_class
48
- [
49
- "absolute z-50 overflow-hidden w-fit max-w-sm",
50
- "px-3 py-1.5 text-sm bg-background rounded-md border shadow-sm",
51
- "data-[state=closed]:hidden"
52
- ]
137
+ def collect_trigger
138
+ raise ArgumentError, "Tooltip requires a trigger declaration block" unless block_given?
139
+
140
+ @collecting = true
141
+ yield(self)
142
+ raise ArgumentError, "Tooltip requires one trigger" unless @trigger
143
+ ensure
144
+ @collecting = false
145
+ end
146
+
147
+ def render_trigger
148
+ return render_custom_trigger if @trigger.as == :custom
149
+ return render_html_trigger if HTML_TRIGGERS.include?(@trigger.as)
150
+
151
+ component = Button.new(
152
+ @trigger.text,
153
+ href: @trigger.href,
154
+ variant: @trigger.variant,
155
+ size: @trigger.size,
156
+ icon: @trigger.icon,
157
+ icon_end: @trigger.icon_end,
158
+ label: @trigger.label,
159
+ disabled: @trigger.disabled,
160
+ target: @trigger.target,
161
+ rel: @trigger.rel,
162
+ download: @trigger.download,
163
+ id: trigger_id,
164
+ html: @trigger.html,
165
+ aria: with_description(@trigger.aria),
166
+ data: owned_data(@trigger.data),
167
+ desperately_need_a_class: @trigger.css_class
168
+ )
169
+
170
+ if @trigger.content
171
+ render_in_slot(component, :trigger) { render(@trigger.content) }
172
+ else
173
+ render_in_slot(component, :trigger)
174
+ end
175
+ end
176
+
177
+ def render_html_trigger
178
+ attributes = trigger_attributes
179
+ unless attributes.html.keys.any? { |key| normalized_attribute(key) == "tabindex" }
180
+ attributes.html[:tabindex] = 0
181
+ end
182
+
183
+ public_send(
184
+ @trigger.as,
185
+ **slot_attributes(
186
+ :trigger,
187
+ attributes: attributes.html,
188
+ aria: attributes.aria,
189
+ data: attributes.data
190
+ )
191
+ ) { text_or_block(@trigger.text, &@trigger.content) }
192
+ end
193
+
194
+ def render_custom_trigger
195
+ span(**slot_attributes(:trigger)) do
196
+ raw(safe(capture(trigger_attributes, &@trigger.content)))
197
+ end
198
+ end
199
+
200
+ def trigger_attributes
201
+ TriggerAttributes.new(
202
+ html: merge_distinct_attributes({ id: trigger_id }, @trigger.html),
203
+ aria: with_description(@trigger.aria),
204
+ data: owned_data(@trigger.data)
205
+ )
206
+ end
207
+
208
+ def owned_data(data)
209
+ action_key = data.keys.find { |key| key.to_s.tr("_", "-") == "action" }
210
+ app_action = action_key && data[action_key]
211
+ data.except(action_key).merge(
212
+ action: [
213
+ "keydown.esc->nk--tooltip#dismiss",
214
+ app_action
215
+ ].compact.join(" ")
216
+ )
217
+ end
218
+
219
+ def with_description(aria)
220
+ unless aria.is_a?(Hash)
221
+ raise ArgumentError, "aria must be a Hash"
222
+ end
223
+
224
+ key = aria.keys.find { |candidate| candidate.to_s.downcase.delete("_-") == "describedby" }
225
+ descriptions = key ? aria.fetch(key).to_s.split : []
226
+
227
+ aria.except(key).merge(describedby: (descriptions + [ content_id ]).uniq.join(" "))
228
+ end
229
+
230
+ def trigger_id
231
+ "#{identifier}-trigger"
232
+ end
233
+
234
+ def content_id
235
+ "#{identifier}-content"
236
+ end
237
+
238
+ def tooltip_content(value)
239
+ return value if value.is_a?(String) && value.present?
240
+
241
+ raise ArgumentError, "Tooltip content must be a non-blank String"
242
+ end
243
+
244
+ def component_id(value)
245
+ return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
246
+
247
+ raise ArgumentError, "Tooltip id must be a non-blank String without whitespace"
248
+ end
249
+
250
+ def ensure_collecting!
251
+ return if @collecting
252
+
253
+ raise ArgumentError, "Tooltip trigger must be declared inside the render block"
53
254
  end
54
255
  end
55
256
  end