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,101 +2,161 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Accordion < Component
|
|
5
|
-
|
|
5
|
+
MODES = %i[multiple single].freeze
|
|
6
|
+
Item = ::Data.define(:key, :title, :expanded, :content)
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
id:,
|
|
10
|
+
mode: :multiple,
|
|
11
|
+
html: {},
|
|
12
|
+
aria: {},
|
|
13
|
+
data: {},
|
|
14
|
+
desperately_need_a_class: nil
|
|
15
|
+
)
|
|
16
|
+
@identifier = component_id(id)
|
|
17
|
+
@mode = validate_choice!(:mode, mode, MODES)
|
|
18
|
+
@items = []
|
|
19
|
+
|
|
6
20
|
super(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
21
|
+
component: :accordion,
|
|
22
|
+
attributes: {
|
|
23
|
+
id: @identifier,
|
|
24
|
+
data: { mode: @mode }
|
|
25
|
+
},
|
|
26
|
+
html:,
|
|
27
|
+
aria:,
|
|
28
|
+
data:,
|
|
29
|
+
desperately_need_a_class:
|
|
10
30
|
)
|
|
11
31
|
end
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
attr_reader :identifier, :mode
|
|
34
|
+
|
|
35
|
+
def view_template(&block)
|
|
36
|
+
collect_items(&block)
|
|
37
|
+
|
|
38
|
+
div(**root_attributes) do
|
|
39
|
+
@items.each { |item| render_item(item) }
|
|
16
40
|
end
|
|
17
41
|
end
|
|
18
42
|
|
|
19
|
-
def item(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
def item(key, title:, expanded: false, &content)
|
|
44
|
+
ensure_collecting!
|
|
45
|
+
|
|
46
|
+
key = normalize_identity(key, name: "accordion item key")
|
|
47
|
+
title = validate_title!(title)
|
|
48
|
+
expanded = validate_boolean!(:expanded, expanded)
|
|
49
|
+
raise ArgumentError, "Accordion item #{key.inspect} requires content" unless content
|
|
50
|
+
raise ArgumentError, "Duplicate accordion item key #{key.inspect}" if @items.any? { |item| item.key == key }
|
|
51
|
+
|
|
52
|
+
if mode == :single && expanded && @items.any?(&:expanded)
|
|
53
|
+
raise ArgumentError, "Single accordion mode accepts only one expanded item"
|
|
24
54
|
end
|
|
55
|
+
|
|
56
|
+
@items << Item.new(key:, title:, expanded:, content:)
|
|
57
|
+
nil
|
|
25
58
|
end
|
|
26
59
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def collect_items
|
|
63
|
+
raise ArgumentError, "Accordion requires an item declaration block" unless block_given?
|
|
64
|
+
|
|
65
|
+
@collecting = true
|
|
66
|
+
yield(self)
|
|
67
|
+
raise ArgumentError, "Accordion requires at least one item" if @items.empty?
|
|
68
|
+
ensure
|
|
69
|
+
@collecting = false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_item(item)
|
|
73
|
+
details(
|
|
74
|
+
**slot_attributes(
|
|
75
|
+
:item,
|
|
76
|
+
attributes: {
|
|
77
|
+
name: mode == :single ? identifier : nil,
|
|
78
|
+
open: item.expanded,
|
|
79
|
+
data: { key: item.key }
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
) do
|
|
83
|
+
summary(
|
|
84
|
+
**slot_attributes(
|
|
85
|
+
:trigger,
|
|
86
|
+
attributes: {
|
|
87
|
+
id: trigger_id(item)
|
|
88
|
+
}
|
|
39
89
|
)
|
|
40
90
|
) do
|
|
41
|
-
|
|
42
|
-
|
|
91
|
+
span(**slot_attributes(:label)) { item.title }
|
|
92
|
+
chevron
|
|
43
93
|
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
94
|
|
|
47
|
-
def content(**attrs)
|
|
48
|
-
builder do
|
|
49
95
|
div(
|
|
50
|
-
**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
},
|
|
56
|
-
aria: { hidden: "true" }
|
|
96
|
+
**slot_attributes(
|
|
97
|
+
:content,
|
|
98
|
+
attributes: {
|
|
99
|
+
id: content_id(item)
|
|
100
|
+
}
|
|
57
101
|
)
|
|
58
102
|
) do
|
|
59
|
-
|
|
103
|
+
render(item.content)
|
|
60
104
|
end
|
|
61
105
|
end
|
|
62
106
|
end
|
|
63
107
|
|
|
64
|
-
|
|
108
|
+
def chevron
|
|
109
|
+
svg(
|
|
110
|
+
**slot_attributes(
|
|
111
|
+
:icon,
|
|
112
|
+
attributes: {
|
|
113
|
+
viewbox: "0 0 16 16",
|
|
114
|
+
width: 16,
|
|
115
|
+
height: 16,
|
|
116
|
+
fill: "none",
|
|
117
|
+
stroke: "currentColor",
|
|
118
|
+
stroke_width: 1.5,
|
|
119
|
+
stroke_linecap: "round",
|
|
120
|
+
stroke_linejoin: "round",
|
|
121
|
+
focusable: "false",
|
|
122
|
+
aria: { hidden: true }
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
) do |svg|
|
|
126
|
+
svg.path(d: "m4 6 4 4 4-4")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def trigger_id(item)
|
|
131
|
+
"#{identifier}-#{item.key}-trigger"
|
|
132
|
+
end
|
|
65
133
|
|
|
66
|
-
def
|
|
67
|
-
"
|
|
134
|
+
def content_id(item)
|
|
135
|
+
"#{identifier}-#{item.key}-content"
|
|
68
136
|
end
|
|
69
137
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"[&[aria-expanded='true']>svg]:rotate-180",
|
|
75
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
76
|
-
]
|
|
138
|
+
def ensure_collecting!
|
|
139
|
+
return if @collecting
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, "Accordion items must be declared inside the render block"
|
|
77
142
|
end
|
|
78
143
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
]
|
|
144
|
+
def validate_title!(title)
|
|
145
|
+
return title if title.is_a?(String) && title.present?
|
|
146
|
+
|
|
147
|
+
raise ArgumentError, "Accordion item title must be a non-blank String"
|
|
84
148
|
end
|
|
85
149
|
|
|
86
|
-
def
|
|
87
|
-
|
|
150
|
+
def validate_boolean!(name, value)
|
|
151
|
+
return value if value == true || value == false
|
|
152
|
+
|
|
153
|
+
raise ArgumentError, "Accordion #{name} must be true or false"
|
|
88
154
|
end
|
|
89
155
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
fill: "none",
|
|
95
|
-
stroke: "currentColor",
|
|
96
|
-
stroke_width: 1
|
|
97
|
-
) do |svg|
|
|
98
|
-
svg.path(d: "m6 9 6 6 6-6")
|
|
99
|
-
end
|
|
156
|
+
def component_id(value)
|
|
157
|
+
return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
|
|
158
|
+
|
|
159
|
+
raise ArgumentError, "Accordion id must be a non-blank String without whitespace"
|
|
100
160
|
end
|
|
101
161
|
end
|
|
102
162
|
end
|
|
@@ -2,72 +2,109 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Alert < Component
|
|
5
|
-
VARIANTS = %i[default warning error
|
|
5
|
+
VARIANTS = %i[default info success warning error].freeze
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
# The palette family each semantic variant borrows in `palette.css`.
|
|
8
|
+
# Alert owns this mapping; it is not the Badge color axis.
|
|
9
|
+
VARIANT_PALETTE = {
|
|
10
|
+
default: :zinc,
|
|
11
|
+
info: :blue,
|
|
12
|
+
success: :green,
|
|
13
|
+
warning: :amber,
|
|
14
|
+
error: :red
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
LIVE_MODES = %i[off polite assertive].freeze
|
|
18
|
+
|
|
19
|
+
Child = ::Data.define(:component, :content)
|
|
20
|
+
private_constant :Child
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
variant: :default,
|
|
24
|
+
title: nil,
|
|
25
|
+
description: nil,
|
|
26
|
+
live: :off,
|
|
27
|
+
id: nil,
|
|
28
|
+
html: {},
|
|
29
|
+
aria: {},
|
|
30
|
+
data: {},
|
|
31
|
+
desperately_need_a_class: nil
|
|
32
|
+
)
|
|
33
|
+
@variant = validate_choice!(:variant, variant, VARIANTS)
|
|
34
|
+
@live = validate_choice!(:live, live, LIVE_MODES)
|
|
35
|
+
@title_content = content_from_keyword(:title, title)
|
|
36
|
+
@description_content = content_from_keyword(:description, description)
|
|
37
|
+
@icon = nil
|
|
9
38
|
|
|
10
39
|
super(
|
|
11
|
-
|
|
12
|
-
role:
|
|
13
|
-
|
|
40
|
+
component: :alert,
|
|
41
|
+
attributes: { id:, role: live_role },
|
|
42
|
+
html:,
|
|
43
|
+
aria:,
|
|
44
|
+
data:,
|
|
45
|
+
variant:,
|
|
46
|
+
desperately_need_a_class:
|
|
14
47
|
)
|
|
15
48
|
end
|
|
16
49
|
|
|
17
50
|
attr_reader :variant
|
|
18
51
|
|
|
19
|
-
def view_template
|
|
20
|
-
|
|
21
|
-
yield
|
|
22
|
-
end
|
|
23
|
-
end
|
|
52
|
+
def view_template(&block)
|
|
53
|
+
collect_declarations(&block)
|
|
24
54
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
55
|
+
div(**root_attributes) do
|
|
56
|
+
render_in_slot(@icon.component, :icon, &@icon.content) if @icon
|
|
57
|
+
if @title_content
|
|
58
|
+
div(**slot_attributes(:title)) { render_deferred_content(@title_content) }
|
|
59
|
+
end
|
|
60
|
+
if @description_content
|
|
61
|
+
div(**slot_attributes(:description)) { render_deferred_content(@description_content) }
|
|
29
62
|
end
|
|
30
63
|
end
|
|
31
64
|
end
|
|
32
65
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
66
|
+
def icon(component, &content)
|
|
67
|
+
ensure_collecting!
|
|
68
|
+
unless component.is_a?(NitroKit::Icon)
|
|
69
|
+
raise ArgumentError, "Alert icon must be a NitroKit::Icon"
|
|
38
70
|
end
|
|
71
|
+
raise ArgumentError, "Alert accepts at most one icon" if @icon
|
|
72
|
+
|
|
73
|
+
@icon = Child.new(component:, content:)
|
|
74
|
+
nil
|
|
39
75
|
end
|
|
40
76
|
|
|
41
|
-
|
|
77
|
+
def title(text = nil, &block)
|
|
78
|
+
ensure_collecting!
|
|
79
|
+
@title_content = declare_content(:title, @title_content, text, &block)
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
42
82
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
]
|
|
83
|
+
def description(text = nil, &block)
|
|
84
|
+
ensure_collecting!
|
|
85
|
+
@description_content = declare_content(:description, @description_content, text, &block)
|
|
86
|
+
nil
|
|
48
87
|
end
|
|
49
88
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"bg-red-300/20 dark:bg-red-300/20 text-red-900 dark:text-red-100 border-red-400/80 dark:border-red-400/50"
|
|
60
|
-
else
|
|
61
|
-
raise ArgumentError, "Invalid variant: #{variant}"
|
|
62
|
-
end
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def collect_declarations
|
|
92
|
+
return unless block_given?
|
|
93
|
+
|
|
94
|
+
@collecting = true
|
|
95
|
+
yield(self)
|
|
96
|
+
ensure
|
|
97
|
+
@collecting = false
|
|
63
98
|
end
|
|
64
99
|
|
|
65
|
-
def
|
|
66
|
-
|
|
100
|
+
def ensure_collecting!
|
|
101
|
+
return if @collecting
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "Alert declarations must be inside the render block"
|
|
67
104
|
end
|
|
68
105
|
|
|
69
|
-
def
|
|
70
|
-
""
|
|
106
|
+
def live_role
|
|
107
|
+
{ off: nil, polite: "status", assertive: "alert" }.fetch(@live)
|
|
71
108
|
end
|
|
72
109
|
end
|
|
73
110
|
end
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class AppNavigation < Component
|
|
5
|
+
alias_method :html_header, :header
|
|
6
|
+
alias_method :html_footer, :footer
|
|
7
|
+
|
|
8
|
+
Item = ::Data.define(:text, :href, :icon, :badge, :current, :html, :aria, :data, :css_class)
|
|
9
|
+
Section = ::Data.define(:label, :entries)
|
|
10
|
+
Entry = ::Data.define(:kind)
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
label:,
|
|
14
|
+
id: nil,
|
|
15
|
+
html: {},
|
|
16
|
+
aria: {},
|
|
17
|
+
data: {},
|
|
18
|
+
desperately_need_a_class: nil
|
|
19
|
+
)
|
|
20
|
+
@label = validate_text!(:label, label)
|
|
21
|
+
@entries = []
|
|
22
|
+
@entry_target = nil
|
|
23
|
+
@phase = nil
|
|
24
|
+
@header = nil
|
|
25
|
+
@body = nil
|
|
26
|
+
@footer = nil
|
|
27
|
+
@spacer = false
|
|
28
|
+
@current_item = false
|
|
29
|
+
@item_count = 0
|
|
30
|
+
|
|
31
|
+
super(
|
|
32
|
+
component: :app_navigation,
|
|
33
|
+
attributes: { id:, aria: { label: @label } }.compact,
|
|
34
|
+
html:,
|
|
35
|
+
aria:,
|
|
36
|
+
data:,
|
|
37
|
+
desperately_need_a_class:
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :label
|
|
42
|
+
|
|
43
|
+
def view_template(&declarations)
|
|
44
|
+
collect_structure(&declarations)
|
|
45
|
+
collect_body
|
|
46
|
+
|
|
47
|
+
nav(**root_attributes) do
|
|
48
|
+
render_header
|
|
49
|
+
render_body
|
|
50
|
+
render_footer
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def header(&content)
|
|
55
|
+
ensure_phase!(:structure, :header)
|
|
56
|
+
raise ArgumentError, "AppNavigation accepts at most one header" if @header
|
|
57
|
+
raise ArgumentError, "AppNavigation header requires a block" unless content
|
|
58
|
+
|
|
59
|
+
@header = content
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def body(&content)
|
|
64
|
+
ensure_phase!(:structure, :body)
|
|
65
|
+
raise ArgumentError, "AppNavigation accepts exactly one body" if @body
|
|
66
|
+
raise ArgumentError, "AppNavigation body requires a block" unless content
|
|
67
|
+
|
|
68
|
+
@body = content
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def footer(&content)
|
|
73
|
+
ensure_phase!(:structure, :footer)
|
|
74
|
+
raise ArgumentError, "AppNavigation accepts at most one footer" if @footer
|
|
75
|
+
raise ArgumentError, "AppNavigation footer requires a block" unless content
|
|
76
|
+
|
|
77
|
+
@footer = content
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def section(label: nil, &content)
|
|
82
|
+
ensure_phase!(:body, :section)
|
|
83
|
+
raise ArgumentError, "AppNavigation section requires a block" unless content
|
|
84
|
+
|
|
85
|
+
entries = []
|
|
86
|
+
previous_entries = @entry_target
|
|
87
|
+
previous_phase = @phase
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
@entry_target = entries
|
|
91
|
+
@phase = :section
|
|
92
|
+
output = capture(self, &content)
|
|
93
|
+
reject_rendered_output!(:section, output)
|
|
94
|
+
unless entries.any?(Item)
|
|
95
|
+
raise ArgumentError, "AppNavigation section requires at least one item"
|
|
96
|
+
end
|
|
97
|
+
previous_entries << Section.new(label: validate_optional_text!(:label, label), entries:)
|
|
98
|
+
nil
|
|
99
|
+
ensure
|
|
100
|
+
@entry_target = previous_entries
|
|
101
|
+
@phase = previous_phase
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def item(
|
|
106
|
+
text,
|
|
107
|
+
href:,
|
|
108
|
+
icon: nil,
|
|
109
|
+
badge: nil,
|
|
110
|
+
badge_color: :neutral,
|
|
111
|
+
current: false,
|
|
112
|
+
html: {},
|
|
113
|
+
aria: {},
|
|
114
|
+
data: {},
|
|
115
|
+
desperately_need_a_class: nil
|
|
116
|
+
)
|
|
117
|
+
ensure_entry_phase!(:item)
|
|
118
|
+
text = validate_text!(:text, text)
|
|
119
|
+
href = validate_text!(:href, href)
|
|
120
|
+
icon = item_icon(icon)
|
|
121
|
+
badge = item_badge(badge, badge_color)
|
|
122
|
+
current = validate_boolean!(:current, current)
|
|
123
|
+
|
|
124
|
+
if current && @current_item
|
|
125
|
+
raise ArgumentError, "AppNavigation accepts at most one current item"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@current_item = true if current
|
|
129
|
+
@entry_target << Item.new(
|
|
130
|
+
text:,
|
|
131
|
+
href:,
|
|
132
|
+
icon:,
|
|
133
|
+
badge:,
|
|
134
|
+
current:,
|
|
135
|
+
html:,
|
|
136
|
+
aria:,
|
|
137
|
+
data:,
|
|
138
|
+
css_class: desperately_need_a_class
|
|
139
|
+
)
|
|
140
|
+
@item_count += 1
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def divider
|
|
145
|
+
ensure_entry_phase!(:divider)
|
|
146
|
+
@entry_target << Entry.new(kind: :divider)
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def spacer
|
|
151
|
+
ensure_phase!(:body, :spacer)
|
|
152
|
+
raise ArgumentError, "AppNavigation accepts at most one spacer" if @spacer
|
|
153
|
+
|
|
154
|
+
@spacer = true
|
|
155
|
+
@entry_target << Entry.new(kind: :spacer)
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def collect_structure
|
|
162
|
+
raise ArgumentError, "AppNavigation requires a declaration block" unless block_given?
|
|
163
|
+
|
|
164
|
+
@phase = :structure
|
|
165
|
+
output = capture(self) { |navigation| yield navigation }
|
|
166
|
+
reject_rendered_output!(:structure, output)
|
|
167
|
+
raise ArgumentError, "AppNavigation requires exactly one body" unless @body
|
|
168
|
+
ensure
|
|
169
|
+
@phase = nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def collect_body
|
|
173
|
+
@phase = :body
|
|
174
|
+
@entry_target = @entries
|
|
175
|
+
@item_count = 0
|
|
176
|
+
output = capture(self, &@body)
|
|
177
|
+
reject_rendered_output!(:body, output)
|
|
178
|
+
raise ArgumentError, "AppNavigation body requires at least one item" if @item_count.zero?
|
|
179
|
+
ensure
|
|
180
|
+
@entry_target = nil
|
|
181
|
+
@phase = nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render_header
|
|
185
|
+
html_header(**slot_attributes(:header)) { render(@header) } if @header
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_body
|
|
189
|
+
ul(**slot_attributes(:body)) do
|
|
190
|
+
@entries.each { |entry| render_entry(entry) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def render_footer
|
|
195
|
+
html_footer(**slot_attributes(:footer)) { render(@footer) } if @footer
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def render_entry(entry)
|
|
199
|
+
case entry
|
|
200
|
+
when Item then render_item(entry)
|
|
201
|
+
when Section then render_section(entry)
|
|
202
|
+
else entry.kind == :divider ? render_divider : render_spacer
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_section(entry)
|
|
207
|
+
li(**slot_attributes(:section)) do
|
|
208
|
+
span(**slot_attributes(:section_label)) { plain(entry.label) } if entry.label
|
|
209
|
+
ul(**slot_attributes(:section_list, attributes: section_list_attributes(entry))) do
|
|
210
|
+
entry.entries.each { |child| render_entry(child) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def section_list_attributes(entry)
|
|
216
|
+
entry.label ? { aria: { label: entry.label } } : {}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def render_item(entry)
|
|
220
|
+
li(**slot_attributes(:item)) do
|
|
221
|
+
a(
|
|
222
|
+
**slot_attributes(
|
|
223
|
+
:item_link,
|
|
224
|
+
attributes: {
|
|
225
|
+
href: entry.href,
|
|
226
|
+
aria: { current: entry.current ? "page" : nil },
|
|
227
|
+
data: { state: entry.current ? "current" : "default" }
|
|
228
|
+
},
|
|
229
|
+
html: entry.html,
|
|
230
|
+
aria: entry.aria,
|
|
231
|
+
data: entry.data,
|
|
232
|
+
desperately_need_a_class: entry.css_class
|
|
233
|
+
)
|
|
234
|
+
) do
|
|
235
|
+
render_in_slot(entry.icon, :item_icon) if entry.icon
|
|
236
|
+
span(**slot_attributes(:item_label)) { plain(entry.text) }
|
|
237
|
+
render_in_slot(entry.badge, :item_badge) if entry.badge
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def render_divider
|
|
243
|
+
li(**slot_attributes(:divider, aria: { hidden: true }))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def render_spacer
|
|
247
|
+
li(**slot_attributes(:spacer, aria: { hidden: true }))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def ensure_phase!(expected, declaration)
|
|
251
|
+
return if @phase == expected
|
|
252
|
+
|
|
253
|
+
location = expected == :structure ? "the render block" : "the body"
|
|
254
|
+
raise ArgumentError, "AppNavigation #{declaration} must be declared directly inside #{location}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def ensure_entry_phase!(declaration)
|
|
258
|
+
return if %i[body section].include?(@phase)
|
|
259
|
+
|
|
260
|
+
raise ArgumentError, "AppNavigation #{declaration} must be declared inside the body or a section"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def reject_rendered_output!(location, output)
|
|
264
|
+
return if output.empty?
|
|
265
|
+
|
|
266
|
+
raise ArgumentError, "AppNavigation #{location} accepts declarations, not rendered content"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def validate_text!(name, value)
|
|
270
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
271
|
+
|
|
272
|
+
raise ArgumentError, "#{name} must be a non-blank String"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def validate_optional_text!(name, value)
|
|
276
|
+
return if value.nil?
|
|
277
|
+
|
|
278
|
+
validate_text!(name, value)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def item_icon(value)
|
|
282
|
+
return if value.nil?
|
|
283
|
+
unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
|
|
284
|
+
raise ArgumentError, "icon must be a non-blank String or Symbol"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
Icon.new(value, size: :sm)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def item_badge(value, color)
|
|
291
|
+
if value.nil?
|
|
292
|
+
return if color == :neutral
|
|
293
|
+
|
|
294
|
+
raise ArgumentError, "badge_color requires a badge"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
Badge.new(validate_badge_text!(value), size: :sm, color:)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def validate_badge_text!(value)
|
|
301
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
302
|
+
return value.to_s if value.is_a?(Numeric)
|
|
303
|
+
|
|
304
|
+
raise ArgumentError, "badge must be a non-blank String or Numeric"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|