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.
- checksums.yaml +4 -4
- data/.agents/plugins/marketplace.json +20 -0
- data/CHANGELOG.md +172 -0
- data/LICENSE +28 -0
- data/README.md +22 -15
- data/STYLE_GUIDE.md +425 -0
- data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
- data/app/assets/stylesheets/nitro_kit.css +7239 -0
- data/app/components/nitro_kit/accordion.rb +125 -65
- data/app/components/nitro_kit/alert.rb +80 -43
- data/app/components/nitro_kit/app_navigation.rb +307 -0
- data/app/components/nitro_kit/app_shell.rb +227 -0
- data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
- data/app/components/nitro_kit/appearance_picker.rb +156 -0
- data/app/components/nitro_kit/auth_shell.rb +34 -0
- data/app/components/nitro_kit/avatar.rb +85 -29
- data/app/components/nitro_kit/avatar_stack.rb +155 -9
- data/app/components/nitro_kit/badge.rb +43 -81
- data/app/components/nitro_kit/button.rb +180 -103
- data/app/components/nitro_kit/button_group.rb +63 -11
- data/app/components/nitro_kit/button_to.rb +69 -0
- data/app/components/nitro_kit/card.rb +42 -45
- data/app/components/nitro_kit/checkbox.rb +115 -62
- data/app/components/nitro_kit/checkbox_group.rb +131 -22
- data/app/components/nitro_kit/choice.rb +55 -0
- data/app/components/nitro_kit/combobox.rb +292 -105
- data/app/components/nitro_kit/command_palette.rb +412 -0
- data/app/components/nitro_kit/component.rb +322 -46
- data/app/components/nitro_kit/container.rb +32 -0
- data/app/components/nitro_kit/control_group.rb +45 -0
- data/app/components/nitro_kit/danger_zone.rb +78 -0
- data/app/components/nitro_kit/data_section.rb +106 -0
- data/app/components/nitro_kit/details_table.rb +229 -0
- data/app/components/nitro_kit/dialog.rb +257 -83
- data/app/components/nitro_kit/dropdown.rb +335 -122
- data/app/components/nitro_kit/dropzone.rb +326 -0
- data/app/components/nitro_kit/empty_state.rb +99 -0
- data/app/components/nitro_kit/field.rb +461 -236
- data/app/components/nitro_kit/field_group.rb +15 -8
- data/app/components/nitro_kit/fieldset.rb +37 -39
- data/app/components/nitro_kit/flex.rb +49 -0
- data/app/components/nitro_kit/form_builder.rb +313 -72
- data/app/components/nitro_kit/form_section.rb +79 -0
- data/app/components/nitro_kit/grid.rb +34 -0
- data/app/components/nitro_kit/icon.rb +54 -25
- data/app/components/nitro_kit/input.rb +97 -10
- data/app/components/nitro_kit/label.rb +22 -6
- data/app/components/nitro_kit/layout_options.rb +7 -0
- data/app/components/nitro_kit/page_header.rb +76 -0
- data/app/components/nitro_kit/pagination.rb +355 -69
- data/app/components/nitro_kit/pagination_bar.rb +90 -0
- data/app/components/nitro_kit/progressive_image.rb +181 -0
- data/app/components/nitro_kit/radio_button.rb +77 -55
- data/app/components/nitro_kit/radio_button_group.rb +107 -36
- data/app/components/nitro_kit/responsive_value.rb +94 -0
- data/app/components/nitro_kit/rich_text_area.rb +31 -0
- data/app/components/nitro_kit/select.rb +129 -64
- data/app/components/nitro_kit/settings_layout.rb +152 -0
- data/app/components/nitro_kit/sheet.rb +202 -0
- data/app/components/nitro_kit/stat_grid.rb +75 -0
- data/app/components/nitro_kit/switch.rb +108 -51
- data/app/components/nitro_kit/table.rb +237 -45
- data/app/components/nitro_kit/tabs.rb +168 -63
- data/app/components/nitro_kit/textarea.rb +81 -10
- data/app/components/nitro_kit/toast.rb +211 -80
- data/app/components/nitro_kit/toolbar.rb +75 -0
- data/app/components/nitro_kit/tooltip.rb +235 -34
- data/app/components/nitro_kit/typeset.rb +28 -0
- data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
- data/app/javascript/controllers/nk/appearance_controller.js +73 -0
- data/app/javascript/controllers/nk/avatar_controller.js +15 -0
- data/app/javascript/controllers/nk/button_controller.js +56 -0
- data/app/javascript/controllers/nk/checkable_controller.js +41 -0
- data/app/javascript/controllers/nk/combobox_controller.js +314 -82
- data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
- data/app/javascript/controllers/nk/dialog_controller.js +14 -10
- data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
- data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
- data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
- data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
- data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
- data/app/javascript/controllers/nk/overlay_position.js +37 -0
- data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
- data/app/javascript/controllers/nk/tabs_controller.js +135 -23
- data/app/javascript/controllers/nk/toast_controller.js +112 -45
- data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
- data/config/importmap.rb +2 -0
- data/config/locales/en.yml +98 -0
- data/docs/agent_guide.md +139 -0
- data/docs/agent_native_spec.md +422 -0
- data/docs/component_contracts.md +263 -0
- data/docs/customization.md +595 -0
- data/docs/hotwire.md +94 -0
- data/docs/initialization_prompt.md +36 -0
- data/docs/migration_1_to_2.md +318 -0
- data/docs/new_app_strategy.md +22 -0
- data/docs/patterns/application_foundation.md +136 -0
- data/docs/patterns/crud_resource.md +144 -0
- data/docs/patterns/destructive_action.md +105 -0
- data/docs/patterns/flash_and_toast.md +57 -0
- data/docs/patterns/inline_edit.md +59 -0
- data/docs/patterns/queryable_collection.md +187 -0
- data/docs/patterns/resource_form.md +126 -0
- data/docs/rails_conventions.md +95 -0
- data/docs/rails_integration.md +514 -0
- data/lib/generators/nitro_kit/install_generator.rb +48 -0
- data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
- data/lib/nitro_kit/engine.rb +20 -0
- data/lib/nitro_kit/installation.rb +671 -0
- data/lib/nitro_kit/migration_inventory.rb +345 -0
- data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
- data/lib/nitro_kit/version.rb +1 -1
- data/lib/nitro_kit.rb +5 -42
- data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
- data/lib/tasks/nitro_kit_tasks.rake +79 -4
- data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
- data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
- data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
- data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
- data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
- data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
- data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
- data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
- data/src/stylesheets/nitro_kit/components/alert.css +82 -0
- data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
- data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
- data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
- data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
- data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
- data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
- data/src/stylesheets/nitro_kit/components/badge.css +76 -0
- data/src/stylesheets/nitro_kit/components/button.css +283 -0
- data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
- data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
- data/src/stylesheets/nitro_kit/components/card.css +70 -0
- data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
- data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
- data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
- data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
- data/src/stylesheets/nitro_kit/components/container.css +25 -0
- data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
- data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
- data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
- data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
- data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
- data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
- data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
- data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
- data/src/stylesheets/nitro_kit/components/field.css +79 -0
- data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
- data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
- data/src/stylesheets/nitro_kit/components/flex.css +455 -0
- data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
- data/src/stylesheets/nitro_kit/components/grid.css +308 -0
- data/src/stylesheets/nitro_kit/components/icon.css +26 -0
- data/src/stylesheets/nitro_kit/components/input.css +80 -0
- data/src/stylesheets/nitro_kit/components/label.css +9 -0
- data/src/stylesheets/nitro_kit/components/layout.css +440 -0
- data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
- data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
- data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
- data/src/stylesheets/nitro_kit/components/palette.css +113 -0
- data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
- data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
- data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
- data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
- data/src/stylesheets/nitro_kit/components/select.css +70 -0
- data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
- data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
- data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
- data/src/stylesheets/nitro_kit/components/switch.css +152 -0
- data/src/stylesheets/nitro_kit/components/table.css +116 -0
- data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
- data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
- data/src/stylesheets/nitro_kit/components/toast.css +131 -0
- data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
- data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
- data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
- data/src/stylesheets/nitro_kit/layers.css +7 -0
- data/src/stylesheets/nitro_kit/reset.css +192 -0
- data/src/stylesheets/nitro_kit/tokens.css +198 -0
- metadata +182 -51
- data/Rakefile +0 -8
- data/app/assets/tailwind/application.css +0 -49
- data/app/components/nitro_kit/datepicker.rb +0 -9
- data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
- data/app/helpers/nitro_kit/alert_helper.rb +0 -11
- data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
- data/app/helpers/nitro_kit/badge_helper.rb +0 -13
- data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
- data/app/helpers/nitro_kit/button_helper.rb +0 -49
- data/app/helpers/nitro_kit/card_helper.rb +0 -9
- data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
- data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
- data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
- data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
- data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
- data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
- data/app/helpers/nitro_kit/field_helper.rb +0 -9
- data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
- data/app/helpers/nitro_kit/form_helper.rb +0 -13
- data/app/helpers/nitro_kit/icon_helper.rb +0 -9
- data/app/helpers/nitro_kit/input_helper.rb +0 -37
- data/app/helpers/nitro_kit/label_helper.rb +0 -19
- data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
- data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
- data/app/helpers/nitro_kit/select_helper.rb +0 -24
- data/app/helpers/nitro_kit/switch_helper.rb +0 -9
- data/app/helpers/nitro_kit/table_helper.rb +0 -9
- data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
- data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
- data/app/helpers/nitro_kit/toast_helper.rb +0 -25
- data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
- data/app/javascript/controllers/nk/accordion_controller.js +0 -18
- data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
- data/app/javascript/controllers/nk/switch_controller.js +0 -32
- data/lib/generators/nitro_kit/component_generator.rb +0 -84
- data/lib/nitro_kit/schema_builder.rb +0 -129
- data/lib/nitro_kit/variants.rb +0 -21
|
@@ -2,22 +2,168 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class AvatarStack < Component
|
|
5
|
-
|
|
6
|
-
@size = size
|
|
5
|
+
SIZES = Avatar::SIZES
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
Overflow = ::Data.define(:count, :label, :html, :aria, :data, :css_class)
|
|
8
|
+
private_constant :Overflow
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
label:,
|
|
12
|
+
size: :md,
|
|
13
|
+
max: nil,
|
|
14
|
+
id: nil,
|
|
15
|
+
html: {},
|
|
16
|
+
aria: {},
|
|
17
|
+
data: {},
|
|
18
|
+
desperately_need_a_class: nil
|
|
19
|
+
)
|
|
20
|
+
unless label.is_a?(String) && label.present?
|
|
21
|
+
raise ArgumentError, "AvatarStack label: must be a non-blank String"
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
|
|
24
|
+
if aria.keys.any? { |key| key.to_s.downcase.tr("_", "-") == "label" }
|
|
25
|
+
raise ArgumentError, "AvatarStack group label is owned by label:"
|
|
26
|
+
end
|
|
27
|
+
unless max.nil? || (max.is_a?(Integer) && max.positive?)
|
|
28
|
+
raise ArgumentError, "max must be a positive Integer or nil"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@size = validate_choice!(:size, size, SIZES)
|
|
32
|
+
@max = max
|
|
33
|
+
@avatars = []
|
|
34
|
+
@overflow = nil
|
|
35
|
+
|
|
36
|
+
super(
|
|
37
|
+
component: :avatar_stack,
|
|
38
|
+
attributes: { id:, role: "group" },
|
|
39
|
+
html:,
|
|
40
|
+
aria: aria.merge(label:),
|
|
41
|
+
data:,
|
|
42
|
+
size:,
|
|
43
|
+
desperately_need_a_class:
|
|
44
|
+
)
|
|
9
45
|
end
|
|
10
46
|
|
|
11
|
-
attr_reader :size
|
|
47
|
+
attr_reader :size, :max
|
|
48
|
+
|
|
49
|
+
def view_template(&block)
|
|
50
|
+
collect_declarations(&block)
|
|
51
|
+
|
|
52
|
+
visible, hidden = partition_avatars
|
|
12
53
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
54
|
+
span(**root_attributes) do
|
|
55
|
+
visible.each { |avatar| render_in_slot(avatar, :avatar) }
|
|
56
|
+
render_overflow(overflow_declaration(hidden))
|
|
16
57
|
end
|
|
17
58
|
end
|
|
18
59
|
|
|
19
|
-
def avatar(
|
|
20
|
-
|
|
60
|
+
def avatar(
|
|
61
|
+
src: nil,
|
|
62
|
+
alt: "",
|
|
63
|
+
fallback: nil,
|
|
64
|
+
loading: "lazy",
|
|
65
|
+
decoding: "async",
|
|
66
|
+
id: nil,
|
|
67
|
+
html: {},
|
|
68
|
+
aria: {},
|
|
69
|
+
data: {},
|
|
70
|
+
desperately_need_a_class: nil
|
|
71
|
+
)
|
|
72
|
+
ensure_collecting!
|
|
73
|
+
|
|
74
|
+
@avatars << Avatar.new(
|
|
75
|
+
src:,
|
|
76
|
+
alt:,
|
|
77
|
+
fallback:,
|
|
78
|
+
size:,
|
|
79
|
+
loading:,
|
|
80
|
+
decoding:,
|
|
81
|
+
id:,
|
|
82
|
+
html:,
|
|
83
|
+
aria:,
|
|
84
|
+
data:,
|
|
85
|
+
desperately_need_a_class:
|
|
86
|
+
)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def overflow(
|
|
91
|
+
count,
|
|
92
|
+
label: nil,
|
|
93
|
+
html: {},
|
|
94
|
+
aria: {},
|
|
95
|
+
data: {},
|
|
96
|
+
desperately_need_a_class: nil
|
|
97
|
+
)
|
|
98
|
+
ensure_collecting!
|
|
99
|
+
raise ArgumentError, "AvatarStack accepts at most one overflow" if @overflow
|
|
100
|
+
if max
|
|
101
|
+
raise ArgumentError, "AvatarStack max: already owns the overflow indicator"
|
|
102
|
+
end
|
|
103
|
+
unless count.is_a?(Integer) && count.positive?
|
|
104
|
+
raise ArgumentError, "count must be a positive Integer"
|
|
105
|
+
end
|
|
106
|
+
raise ArgumentError, "label must be a String or nil" unless label.nil? || label.is_a?(String)
|
|
107
|
+
raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
|
|
108
|
+
if aria.keys.any? { |key| key.to_s.downcase.tr("_", "-") == "label" }
|
|
109
|
+
raise ArgumentError, "AvatarStack overflow label is owned by label:"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@overflow = Overflow.new(
|
|
113
|
+
count:,
|
|
114
|
+
label:,
|
|
115
|
+
html:,
|
|
116
|
+
aria:,
|
|
117
|
+
data:,
|
|
118
|
+
css_class: desperately_need_a_class
|
|
119
|
+
)
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def collect_declarations
|
|
126
|
+
return unless block_given?
|
|
127
|
+
|
|
128
|
+
@collecting = true
|
|
129
|
+
yield(self)
|
|
130
|
+
ensure
|
|
131
|
+
@collecting = false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ensure_collecting!
|
|
135
|
+
return if @collecting
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "AvatarStack items must be declared inside the render block"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def partition_avatars
|
|
141
|
+
return [ @avatars, [] ] unless max
|
|
142
|
+
|
|
143
|
+
[ @avatars.first(max), @avatars.drop(max) ]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def overflow_declaration(hidden)
|
|
147
|
+
return @overflow unless max
|
|
148
|
+
return nil if hidden.empty?
|
|
149
|
+
|
|
150
|
+
Overflow.new(count: hidden.size, label: nil, html: {}, aria: {}, data: {}, css_class: nil)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def render_overflow(declaration)
|
|
154
|
+
return unless declaration
|
|
155
|
+
|
|
156
|
+
span(
|
|
157
|
+
**slot_attributes(
|
|
158
|
+
:overflow,
|
|
159
|
+
html: declaration.html,
|
|
160
|
+
aria: declaration.aria.merge(
|
|
161
|
+
label: declaration.label || I18n.t("nitro_kit.avatar_stack.overflow", count: declaration.count)
|
|
162
|
+
),
|
|
163
|
+
data: declaration.data,
|
|
164
|
+
desperately_need_a_class: declaration.css_class
|
|
165
|
+
)
|
|
166
|
+
) { "+#{declaration.count}" }
|
|
21
167
|
end
|
|
22
168
|
end
|
|
23
169
|
end
|
|
@@ -2,100 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Badge < Component
|
|
5
|
-
VARIANTS = %i[default outline]
|
|
5
|
+
VARIANTS = %i[default outline].freeze
|
|
6
|
+
SIZES = %i[xs sm md].freeze
|
|
7
|
+
COLORS = %i[
|
|
8
|
+
zinc red orange amber yellow lime green emerald teal cyan sky blue indigo
|
|
9
|
+
violet purple fuchsia pink rose neutral info success warning danger
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
text = nil,
|
|
14
|
+
variant: :default,
|
|
15
|
+
size: :md,
|
|
16
|
+
color: :zinc,
|
|
17
|
+
id: nil,
|
|
18
|
+
html: {},
|
|
19
|
+
aria: {},
|
|
20
|
+
data: {},
|
|
21
|
+
desperately_need_a_class: nil
|
|
22
|
+
)
|
|
23
|
+
if !text.nil? && text.to_s.strip.empty?
|
|
24
|
+
raise ArgumentError, "Badge label content is required"
|
|
25
|
+
end
|
|
6
26
|
|
|
7
|
-
def initialize(text = nil, variant: :default, size: :md, color: :gray, **attrs)
|
|
8
27
|
@text = text
|
|
9
|
-
@variant = variant
|
|
10
|
-
@size = size
|
|
11
|
-
@color = color
|
|
28
|
+
@variant = validate_choice!(:variant, variant, VARIANTS)
|
|
29
|
+
@size = validate_choice!(:size, size, SIZES)
|
|
30
|
+
@color = validate_choice!(:color, color, COLORS)
|
|
12
31
|
|
|
13
32
|
super(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
component: :badge,
|
|
34
|
+
attributes: {
|
|
35
|
+
id:,
|
|
36
|
+
data: { color: }
|
|
37
|
+
},
|
|
38
|
+
html:,
|
|
39
|
+
aria:,
|
|
40
|
+
data:,
|
|
41
|
+
variant:,
|
|
42
|
+
size:,
|
|
43
|
+
desperately_need_a_class:
|
|
20
44
|
)
|
|
21
45
|
end
|
|
22
46
|
|
|
23
47
|
attr_reader :text, :variant, :size, :color
|
|
24
48
|
|
|
25
49
|
def view_template(&block)
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
if block && !text.nil?
|
|
51
|
+
raise ArgumentError, "Badge accepts label text or block content, not both"
|
|
28
52
|
end
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def base_class
|
|
34
|
-
"inline-flex items-center gap-x-1.5 rounded-md font-medium whitespace-nowrap"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def variant_class
|
|
38
|
-
case variant
|
|
39
|
-
when :default
|
|
40
|
-
color_class
|
|
41
|
-
when :outline
|
|
42
|
-
"border"
|
|
43
|
-
else
|
|
44
|
-
raise ArgumentError, "Invalid variant: #{variant}"
|
|
53
|
+
if !block && text.nil?
|
|
54
|
+
raise ArgumentError, "Badge label content is required"
|
|
45
55
|
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def size_class
|
|
49
|
-
case size
|
|
50
|
-
when :sm
|
|
51
|
-
"text-xs px-1.5 py-0.5"
|
|
52
|
-
when :md
|
|
53
|
-
"text-sm px-2 py-0.5"
|
|
54
|
-
else
|
|
55
|
-
raise ArgumentError, "Invalid size: #{size}"
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
when :red
|
|
64
|
-
"text-red-900 dark:text-red-200 bg-red-300/50 dark:bg-red-400/40"
|
|
65
|
-
when :orange
|
|
66
|
-
"text-orange-700 dark:text-orange-200 bg-orange-400/20 dark:bg-orange-400/40"
|
|
67
|
-
when :amber
|
|
68
|
-
"text-amber-700 dark:text-amber-200 bg-amber-400/20 dark:bg-amber-400/40"
|
|
69
|
-
when :yellow
|
|
70
|
-
"text-yellow-900 dark:text-yellow-200 bg-yellow-400/40 dark:bg-yellow-400/40"
|
|
71
|
-
when :lime
|
|
72
|
-
"text-lime-700 dark:text-lime-200 bg-lime-400/20 dark:bg-lime-400/40"
|
|
73
|
-
when :green
|
|
74
|
-
"text-green-800 dark:text-green-200 bg-green-500/20 dark:bg-green-400/40"
|
|
75
|
-
when :emerald
|
|
76
|
-
"text-emerald-700 dark:text-emerald-200 bg-emerald-400/20 dark:bg-emerald-400/40"
|
|
77
|
-
when :teal
|
|
78
|
-
"text-teal-700 dark:text-teal-200 bg-teal-400/20 dark:bg-teal-400/40"
|
|
79
|
-
when :cyan
|
|
80
|
-
"text-cyan-700 dark:text-cyan-200 bg-cyan-400/20 dark:bg-cyan-400/40"
|
|
81
|
-
when :sky
|
|
82
|
-
"text-sky-700 dark:text-sky-200 bg-sky-400/20 dark:bg-sky-400/40"
|
|
83
|
-
when :blue
|
|
84
|
-
"text-blue-700 dark:text-blue-200 bg-blue-400/20 dark:bg-blue-400/40"
|
|
85
|
-
when :indigo
|
|
86
|
-
"text-indigo-700 dark:text-indigo-200 bg-indigo-400/20 dark:bg-indigo-400/40"
|
|
87
|
-
when :violet
|
|
88
|
-
"text-violet-700 dark:text-violet-200 bg-violet-400/20 dark:bg-violet-400/40"
|
|
89
|
-
when :purple
|
|
90
|
-
"text-purple-700 dark:text-purple-200 bg-purple-400/20 dark:bg-purple-400/40"
|
|
91
|
-
when :fuchsia
|
|
92
|
-
"text-fuchsia-700 dark:text-fuchsia-200 bg-fuchsia-400/20 dark:bg-fuchsia-400/40"
|
|
93
|
-
when :pink
|
|
94
|
-
"text-pink-700 dark:text-pink-200 bg-pink-400/20 dark:bg-pink-400/40"
|
|
95
|
-
when :rose
|
|
96
|
-
"text-rose-700 dark:text-rose-200 bg-rose-400/20 dark:bg-rose-400/40"
|
|
97
|
-
else
|
|
98
|
-
raise ArgumentError, "Unknown color `#{color}'"
|
|
57
|
+
span(**root_attributes) do
|
|
58
|
+
span(**slot_attributes(:label)) do
|
|
59
|
+
block ? text_or_block(nil, &block) : plain(text.to_s)
|
|
60
|
+
end
|
|
99
61
|
end
|
|
100
62
|
end
|
|
101
63
|
end
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Button < Component
|
|
5
|
-
VARIANTS = %i[default primary destructive ghost]
|
|
5
|
+
VARIANTS = %i[default primary destructive ghost].freeze
|
|
6
|
+
SIZES = %i[xs sm md lg xl].freeze
|
|
7
|
+
TYPES = %i[button submit reset].freeze
|
|
8
|
+
SUBMISSION_INDICATORS = %i[spinner].freeze
|
|
9
|
+
DEFAULT_TYPE = :button
|
|
10
|
+
UNSET_TYPE = Object.new.freeze
|
|
11
|
+
private_constant :UNSET_TYPE
|
|
6
12
|
|
|
7
13
|
def initialize(
|
|
8
14
|
text = nil,
|
|
@@ -10,137 +16,208 @@ module NitroKit
|
|
|
10
16
|
variant: :default,
|
|
11
17
|
size: :md,
|
|
12
18
|
icon: nil,
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
icon_end: nil,
|
|
20
|
+
label: nil,
|
|
21
|
+
id: nil,
|
|
22
|
+
type: UNSET_TYPE,
|
|
23
|
+
name: nil,
|
|
24
|
+
value: nil,
|
|
25
|
+
form: nil,
|
|
26
|
+
target: nil,
|
|
27
|
+
rel: nil,
|
|
28
|
+
download: nil,
|
|
29
|
+
disabled: false,
|
|
30
|
+
loading: false,
|
|
31
|
+
submission_indicator: nil,
|
|
32
|
+
html: {},
|
|
33
|
+
aria: {},
|
|
34
|
+
data: {},
|
|
35
|
+
desperately_need_a_class: nil
|
|
15
36
|
)
|
|
37
|
+
if !href.nil? && (!href.is_a?(String) || href.empty?)
|
|
38
|
+
raise ArgumentError, "Button href must be nil or a non-blank String"
|
|
39
|
+
end
|
|
40
|
+
if href && !type.equal?(UNSET_TYPE)
|
|
41
|
+
raise ArgumentError, "Link Buttons do not accept type; type: applies to native button elements"
|
|
42
|
+
end
|
|
43
|
+
if href && [ name, value, form ].any? { |option| !option.nil? }
|
|
44
|
+
raise ArgumentError, "Link Buttons do not accept name, value, or form"
|
|
45
|
+
end
|
|
46
|
+
if !href && [ target, rel, download ].any? { |option| !option.nil? }
|
|
47
|
+
raise ArgumentError, "Button elements do not accept target, rel, or download"
|
|
48
|
+
end
|
|
49
|
+
if !label.nil? && (!label.is_a?(String) || label.strip.empty?)
|
|
50
|
+
raise ArgumentError, "Button label: must be a non-blank String"
|
|
51
|
+
end
|
|
52
|
+
if text.is_a?(String) && text.strip.empty?
|
|
53
|
+
raise ArgumentError, "Button text must be non-blank"
|
|
54
|
+
end
|
|
55
|
+
|
|
16
56
|
@text = text
|
|
17
57
|
@href = href
|
|
18
58
|
@icon = icon
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@variant = variant
|
|
59
|
+
@icon_end = icon_end
|
|
60
|
+
@label = label
|
|
61
|
+
@variant = validate_choice!(:variant, variant, VARIANTS)
|
|
62
|
+
@size = validate_choice!(:size, size, SIZES)
|
|
63
|
+
@type = validate_choice!(:type, resolved_type(type), TYPES)
|
|
64
|
+
@loading = validate_boolean!(:loading, loading)
|
|
65
|
+
@disabled = validate_boolean!(:disabled, disabled) || @loading
|
|
66
|
+
@aria = aria
|
|
67
|
+
@submission_feedback = submission_feedback(data)
|
|
68
|
+
@submission_indicator = if submission_indicator.nil?
|
|
69
|
+
nil
|
|
70
|
+
else
|
|
71
|
+
validate_choice!(:submission_indicator, submission_indicator, SUBMISSION_INDICATORS)
|
|
72
|
+
end
|
|
73
|
+
if @submission_indicator && (href || @type != :submit)
|
|
74
|
+
raise ArgumentError, "submission_indicator: applies only to native submit Buttons"
|
|
75
|
+
end
|
|
76
|
+
if @submission_indicator && @loading
|
|
77
|
+
raise ArgumentError, "submission_indicator: and loading: cannot be combined"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
owned_aria = {
|
|
81
|
+
label: @label,
|
|
82
|
+
busy: @loading ? true : nil
|
|
83
|
+
}.compact
|
|
84
|
+
|
|
85
|
+
native_attributes = if href
|
|
86
|
+
{
|
|
87
|
+
id:,
|
|
88
|
+
href: @disabled ? nil : href,
|
|
89
|
+
target:,
|
|
90
|
+
rel:,
|
|
91
|
+
download:,
|
|
92
|
+
tabindex: @disabled ? -1 : nil,
|
|
93
|
+
aria: owned_aria
|
|
94
|
+
}.compact.tap do |attributes|
|
|
95
|
+
if @disabled
|
|
96
|
+
attributes[:href] = nil
|
|
97
|
+
attributes[:aria] = attributes[:aria].merge(disabled: true)
|
|
98
|
+
end
|
|
99
|
+
attributes.delete(:aria) if attributes[:aria].empty?
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
{
|
|
103
|
+
id:,
|
|
104
|
+
type: @type,
|
|
105
|
+
name:,
|
|
106
|
+
value:,
|
|
107
|
+
form:,
|
|
108
|
+
disabled: @disabled,
|
|
109
|
+
aria: owned_aria.presence,
|
|
110
|
+
data: submission_data
|
|
111
|
+
}.compact
|
|
112
|
+
end
|
|
22
113
|
|
|
23
114
|
super(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
115
|
+
component: :button,
|
|
116
|
+
attributes: native_attributes,
|
|
117
|
+
html:,
|
|
118
|
+
aria:,
|
|
119
|
+
data:,
|
|
120
|
+
variant:,
|
|
121
|
+
size:,
|
|
122
|
+
desperately_need_a_class:
|
|
30
123
|
)
|
|
31
124
|
end
|
|
32
125
|
|
|
33
|
-
attr_reader
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
:icon,
|
|
37
|
-
:icon_right,
|
|
38
|
-
:size,
|
|
39
|
-
:variant
|
|
40
|
-
)
|
|
126
|
+
attr_reader :text, :href, :icon, :icon_end, :label, :size, :variant
|
|
127
|
+
|
|
128
|
+
def loading? = @loading
|
|
41
129
|
|
|
42
130
|
def view_template(&block)
|
|
43
|
-
if
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
end
|
|
131
|
+
raise ArgumentError, "Button accepts text or a block, not both" if !text.nil? && block
|
|
132
|
+
unless !text.nil? || block || icon || icon_end
|
|
133
|
+
raise ArgumentError, "Button requires text, a block, or an icon"
|
|
134
|
+
end
|
|
135
|
+
# Only render time knows whether a block supplies the visible label.
|
|
136
|
+
if text.nil? && !block && !accessible_label?
|
|
137
|
+
raise ArgumentError, "Icon-only Button requires label:, aria: { label: }, or aria: { labelledby: }"
|
|
51
138
|
end
|
|
139
|
+
|
|
140
|
+
tag = href ? :a : :button
|
|
141
|
+
public_send(tag, **root_attributes) { contents(&block) }
|
|
52
142
|
end
|
|
53
143
|
|
|
54
144
|
private
|
|
55
145
|
|
|
56
|
-
def
|
|
57
|
-
|
|
146
|
+
def resolved_type(type)
|
|
147
|
+
return DEFAULT_TYPE if type.equal?(UNSET_TYPE)
|
|
148
|
+
|
|
149
|
+
type.to_s.to_sym
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def submission_data
|
|
153
|
+
return unless @submission_feedback || @submission_indicator
|
|
58
154
|
|
|
59
|
-
|
|
60
|
-
|
|
155
|
+
{
|
|
156
|
+
controller: "nk--button",
|
|
157
|
+
action: [
|
|
158
|
+
"submit@document->nk--button#submit",
|
|
159
|
+
"turbo:submit-end@document->nk--button#reset",
|
|
160
|
+
"turbo:before-cache@document->nk--button#reset"
|
|
161
|
+
].join(" ")
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def submission_feedback(data)
|
|
166
|
+
data.each do |key, value|
|
|
167
|
+
next unless normalized_data_attribute(key) == "turbo-submits-with"
|
|
168
|
+
return if value == false || value.nil?
|
|
169
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
170
|
+
|
|
171
|
+
raise ArgumentError, "data-turbo-submits-with must be a non-blank String"
|
|
61
172
|
end
|
|
62
173
|
|
|
63
|
-
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
64
176
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
177
|
+
def contents(&block)
|
|
178
|
+
icon_only = text.nil? && !block
|
|
179
|
+
submission_spinner if @submission_indicator == :spinner
|
|
180
|
+
spinner(icon_only:) if loading?
|
|
181
|
+
icon_slot(icon, :icon_start, icon_only:) if icon && !loading?
|
|
182
|
+
|
|
183
|
+
if block || !text.nil?
|
|
184
|
+
span(**slot_attributes(:label)) do
|
|
185
|
+
block ? yield : plain(text.to_s)
|
|
186
|
+
end
|
|
69
187
|
end
|
|
70
188
|
|
|
71
|
-
|
|
189
|
+
icon_slot(icon_end, :icon_end, icon_only:) if icon_end
|
|
72
190
|
end
|
|
73
191
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
"disabled:opacity-70 disabled:pointer-events-none",
|
|
79
|
-
# Focus
|
|
80
|
-
"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-ring ring-offset-background",
|
|
81
|
-
# Icon
|
|
82
|
-
"[&_svg]:pointer-events-none [&_svg]:shrink-0"
|
|
83
|
-
]
|
|
192
|
+
def submission_spinner
|
|
193
|
+
span(**slot_attributes(:submission_spinner, attributes: { aria: { hidden: true } })) do
|
|
194
|
+
render(Icon.new(:"loader-circle", size: icon_size))
|
|
195
|
+
end
|
|
84
196
|
end
|
|
85
197
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
[
|
|
90
|
-
"bg-background text-foreground border-border",
|
|
91
|
-
"hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
|
92
|
-
]
|
|
93
|
-
when :primary
|
|
94
|
-
[
|
|
95
|
-
"bg-primary text-primary-foreground border-primary",
|
|
96
|
-
"hover:bg-primary/90 dark:hover:bg-primary/90"
|
|
97
|
-
]
|
|
98
|
-
when :destructive
|
|
99
|
-
[
|
|
100
|
-
"bg-destructive text-destructive-foreground border-destructive",
|
|
101
|
-
"hover:bg-destructive/90 dark:hover:bg-destructive/90",
|
|
102
|
-
"disabled:text-destructive-foreground/80"
|
|
103
|
-
]
|
|
104
|
-
when :ghost
|
|
105
|
-
[
|
|
106
|
-
"bg-transparent text-foreground border-transparent",
|
|
107
|
-
"hover:bg-zinc-200/50 dark:hover:bg-zinc-900",
|
|
108
|
-
"disabled:text-muted-content"
|
|
109
|
-
]
|
|
110
|
-
else
|
|
111
|
-
raise ArgumentError, "Unknown variant `#{variant}'"
|
|
198
|
+
def spinner(icon_only:)
|
|
199
|
+
span(**slot_attributes(:spinner, attributes: { aria: { hidden: true } })) do
|
|
200
|
+
render(Icon.new(:"loader-circle", size: icon_only ? :md : icon_size))
|
|
112
201
|
end
|
|
113
202
|
end
|
|
114
203
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"px-5 h-11 text-lg [&_svg]:size-5",
|
|
133
|
-
# If icon only, make square
|
|
134
|
-
"[&_svg:first-child:last-child]:-mx-2"
|
|
135
|
-
]
|
|
136
|
-
when :xl
|
|
137
|
-
[
|
|
138
|
-
"px-8 h-14 text-2xl [&_svg]:size-5 gap-x-4",
|
|
139
|
-
# If icon only, make square
|
|
140
|
-
"[&_svg:first-child:last-child]:-mx-8"
|
|
141
|
-
]
|
|
142
|
-
else
|
|
143
|
-
raise ArgumentError, "Unknown size `#{size}'"
|
|
204
|
+
def icon_slot(name, slot, icon_only:)
|
|
205
|
+
span(**slot_attributes(slot)) do
|
|
206
|
+
render(Icon.new(name, size: icon_only ? :md : icon_size))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def icon_size
|
|
211
|
+
{ xs: :xs, sm: :sm, md: :sm, lg: :md, xl: :md }.fetch(size)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def accessible_label?
|
|
215
|
+
return true if @label
|
|
216
|
+
|
|
217
|
+
%w[label labelledby].any? do |name|
|
|
218
|
+
key = @aria.keys.find { |candidate| candidate.to_s.downcase.delete("_-") == name }
|
|
219
|
+
value = key && @aria[key]
|
|
220
|
+
value.is_a?(String) && !value.strip.empty?
|
|
144
221
|
end
|
|
145
222
|
end
|
|
146
223
|
end
|