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,95 +2,160 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Select < Component
|
|
5
|
-
def initialize(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
def initialize(
|
|
6
|
+
options: [],
|
|
7
|
+
option_tags: nil,
|
|
8
|
+
id: nil,
|
|
9
|
+
name: nil,
|
|
10
|
+
value: nil,
|
|
11
|
+
include_blank: nil,
|
|
12
|
+
prompt: nil,
|
|
13
|
+
disabled: false,
|
|
14
|
+
required: false,
|
|
15
|
+
multiple: false,
|
|
16
|
+
autocomplete: nil,
|
|
17
|
+
html: {},
|
|
18
|
+
aria: {},
|
|
19
|
+
data: {},
|
|
20
|
+
control_html: {},
|
|
21
|
+
control_aria: {},
|
|
22
|
+
control_data: {},
|
|
23
|
+
desperately_need_a_class: nil
|
|
24
|
+
)
|
|
25
|
+
@options = Array(options).map { |choice| Choice.coerce(choice) }.freeze
|
|
26
|
+
@option_tags = validate_option_tags!(option_tags)
|
|
27
|
+
@value = value
|
|
28
|
+
@include_blank = validate_blank_option!(include_blank)
|
|
29
|
+
@prompt = validate_optional_text!(:prompt, prompt)
|
|
30
|
+
if @include_blank && @prompt
|
|
31
|
+
raise ArgumentError, "include_blank: and prompt: are mutually exclusive"
|
|
32
|
+
end
|
|
33
|
+
@disabled = validate_boolean!(:disabled, disabled)
|
|
34
|
+
@required = validate_boolean!(:required, required)
|
|
35
|
+
@multiple = validate_boolean!(:multiple, multiple)
|
|
36
|
+
@name = multiple_name(name)
|
|
37
|
+
@id = id
|
|
38
|
+
@autocomplete = autocomplete
|
|
39
|
+
@control_html = control_html
|
|
40
|
+
@control_aria = control_aria
|
|
41
|
+
@control_data = control_data
|
|
42
|
+
|
|
43
|
+
if @option_tags && @options.any?
|
|
44
|
+
raise ArgumentError, "options and option_tags are mutually exclusive"
|
|
45
|
+
end
|
|
11
46
|
|
|
12
47
|
super(
|
|
13
|
-
|
|
14
|
-
|
|
48
|
+
component: :select,
|
|
49
|
+
html:,
|
|
50
|
+
aria:,
|
|
51
|
+
data:,
|
|
52
|
+
desperately_need_a_class:
|
|
15
53
|
)
|
|
16
54
|
end
|
|
17
55
|
|
|
18
|
-
attr_reader :
|
|
56
|
+
attr_reader :options, :value, :include_blank, :prompt, :name, :id
|
|
19
57
|
|
|
20
58
|
def view_template
|
|
21
|
-
span(
|
|
22
|
-
select(**
|
|
23
|
-
if
|
|
24
|
-
|
|
25
|
-
include_empty
|
|
26
|
-
elsif @prompt
|
|
27
|
-
@prompt
|
|
28
|
-
else
|
|
29
|
-
""
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
html_option(value: "", selected: @value == "") { blank_text }
|
|
33
|
-
end
|
|
59
|
+
span(**root_attributes) do
|
|
60
|
+
select(**control_attributes) do
|
|
61
|
+
render_blank_option if include_blank
|
|
62
|
+
render_prompt_option if prompt && selected_values.empty?
|
|
34
63
|
|
|
35
|
-
if
|
|
36
|
-
|
|
37
|
-
if opt.is_a?(Array) && opt.length >= 2
|
|
38
|
-
html_option(value: opt[1], selected: @value == opt[1].to_s) { opt[0] }
|
|
39
|
-
else
|
|
40
|
-
# Handle simple strings - use as both label and value
|
|
41
|
-
html_option(value: opt.to_s, selected: @value == opt.to_s) { opt.to_s }
|
|
42
|
-
end
|
|
43
|
-
end
|
|
64
|
+
if @option_tags
|
|
65
|
+
raw safe(@option_tags)
|
|
44
66
|
else
|
|
45
|
-
|
|
67
|
+
options.each { |choice| render_option(choice) }
|
|
46
68
|
end
|
|
47
69
|
end
|
|
48
70
|
|
|
49
|
-
|
|
71
|
+
render_toggle_icon
|
|
50
72
|
end
|
|
51
73
|
end
|
|
52
74
|
|
|
53
|
-
|
|
75
|
+
private
|
|
54
76
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
value ||= text
|
|
77
|
+
def render_toggle_icon
|
|
78
|
+
return if @multiple
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
svg(
|
|
81
|
+
**slot_attributes(
|
|
82
|
+
:icon,
|
|
83
|
+
attributes: {
|
|
84
|
+
viewbox: "0 0 20 20",
|
|
85
|
+
fill: "none",
|
|
86
|
+
stroke: "currentColor",
|
|
87
|
+
stroke_width: 1.5,
|
|
88
|
+
aria: { hidden: true }
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
) do |icon|
|
|
92
|
+
icon.path(d: "m6.5 8 3.5 3.5L13.5 8", stroke_linecap: "round", stroke_linejoin: "round")
|
|
66
93
|
end
|
|
67
94
|
end
|
|
68
95
|
|
|
69
|
-
|
|
96
|
+
def control_attributes
|
|
97
|
+
slot_attributes(
|
|
98
|
+
:control,
|
|
99
|
+
attributes: {
|
|
100
|
+
id:,
|
|
101
|
+
name:,
|
|
102
|
+
disabled: @disabled,
|
|
103
|
+
required: @required,
|
|
104
|
+
multiple: @multiple,
|
|
105
|
+
autocomplete: @autocomplete
|
|
106
|
+
}.compact,
|
|
107
|
+
html: @control_html,
|
|
108
|
+
aria: @control_aria,
|
|
109
|
+
data: @control_data
|
|
110
|
+
)
|
|
111
|
+
end
|
|
70
112
|
|
|
71
|
-
def
|
|
72
|
-
"
|
|
113
|
+
def render_blank_option
|
|
114
|
+
text = include_blank == true ? "" : include_blank
|
|
115
|
+
option(value: "", selected: selected_values.empty?) { plain(text.to_s) }
|
|
73
116
|
end
|
|
74
117
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
"appearance-none bg-background text-foreground rounded-md border px-3 py-2 pr-10 w-full",
|
|
78
|
-
# Focus
|
|
79
|
-
"focus:outline-none ring-ring ring-offset-2 ring-offset-background focus-visible:ring-2"
|
|
80
|
-
]
|
|
118
|
+
def render_prompt_option
|
|
119
|
+
option(value: "", selected: true) { plain(prompt) }
|
|
81
120
|
end
|
|
82
121
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
122
|
+
def render_option(choice)
|
|
123
|
+
option(
|
|
124
|
+
id: choice.id,
|
|
125
|
+
value: choice.value,
|
|
126
|
+
selected: selected_values.include?(choice.value.to_s),
|
|
127
|
+
disabled: choice.disabled
|
|
128
|
+
) { plain(choice.label.to_s) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def selected_values
|
|
132
|
+
@selected_values ||= Array(value).compact.map(&:to_s)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def multiple_name(name)
|
|
136
|
+
return name unless @multiple && name && !name.end_with?("[]")
|
|
137
|
+
|
|
138
|
+
"#{name}[]"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_option_tags!(option_tags)
|
|
142
|
+
return if option_tags.nil?
|
|
143
|
+
return option_tags if option_tags.is_a?(ActiveSupport::SafeBuffer)
|
|
144
|
+
|
|
145
|
+
raise ArgumentError, "option_tags must be an ActiveSupport::SafeBuffer"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_blank_option!(value)
|
|
149
|
+
return value if value.nil? || value == true || value == false || value.is_a?(String)
|
|
150
|
+
|
|
151
|
+
raise ArgumentError, "include_blank must be true, false, nil, or a String"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_optional_text!(name, value)
|
|
155
|
+
return if value.nil? || value == false
|
|
156
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
157
|
+
|
|
158
|
+
raise ArgumentError, "#{name} must be a non-blank String, false, or nil"
|
|
94
159
|
end
|
|
95
160
|
end
|
|
96
161
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class SettingsLayout < Component
|
|
5
|
+
Item = ::Data.define(:text, :href, :icon, :current)
|
|
6
|
+
|
|
7
|
+
def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
|
|
8
|
+
@label = nil
|
|
9
|
+
@items = []
|
|
10
|
+
@content = nil
|
|
11
|
+
@phase = nil
|
|
12
|
+
@navigation_declared = false
|
|
13
|
+
@current_item = false
|
|
14
|
+
|
|
15
|
+
super(
|
|
16
|
+
component: :settings_layout,
|
|
17
|
+
attributes: { id: }.compact,
|
|
18
|
+
html:,
|
|
19
|
+
aria:,
|
|
20
|
+
data:,
|
|
21
|
+
desperately_need_a_class:
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def view_template(&declarations)
|
|
26
|
+
collect_regions(&declarations)
|
|
27
|
+
|
|
28
|
+
div(**root_attributes) do
|
|
29
|
+
render_navigation
|
|
30
|
+
render_content
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def navigation(label:, &declarations)
|
|
35
|
+
ensure_phase!(:structure, :navigation)
|
|
36
|
+
raise ArgumentError, "SettingsLayout accepts exactly one navigation region" if @navigation_declared
|
|
37
|
+
raise ArgumentError, "SettingsLayout navigation requires a block" unless declarations
|
|
38
|
+
|
|
39
|
+
@label = validate_text!(:label, label)
|
|
40
|
+
@navigation_declared = true
|
|
41
|
+
collect_items(&declarations)
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def item(text, href:, icon: nil, current: false)
|
|
46
|
+
ensure_phase!(:navigation, :item)
|
|
47
|
+
text = validate_text!(:text, text)
|
|
48
|
+
href = validate_text!(:href, href)
|
|
49
|
+
icon = item_icon(icon)
|
|
50
|
+
current = validate_boolean!(:current, current)
|
|
51
|
+
|
|
52
|
+
if current && @current_item
|
|
53
|
+
raise ArgumentError, "SettingsLayout accepts at most one current item"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@current_item = true if current
|
|
57
|
+
@items << Item.new(text:, href:, icon:, current:)
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def content(&block)
|
|
62
|
+
ensure_phase!(:structure, :content)
|
|
63
|
+
raise ArgumentError, "SettingsLayout accepts exactly one content region" if @content
|
|
64
|
+
raise ArgumentError, "SettingsLayout content requires a block" unless block
|
|
65
|
+
|
|
66
|
+
@content = block
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def collect_regions
|
|
73
|
+
raise ArgumentError, "SettingsLayout requires a declaration block" unless block_given?
|
|
74
|
+
|
|
75
|
+
@phase = :structure
|
|
76
|
+
output = capture(self) { |layout| yield layout }
|
|
77
|
+
reject_rendered_output!(:structure, output)
|
|
78
|
+
raise ArgumentError, "SettingsLayout requires one navigation region" unless @navigation_declared
|
|
79
|
+
raise ArgumentError, "SettingsLayout requires one content region" unless @content
|
|
80
|
+
ensure
|
|
81
|
+
@phase = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def collect_items(&declarations)
|
|
85
|
+
@phase = :navigation
|
|
86
|
+
output = capture(self, &declarations)
|
|
87
|
+
reject_rendered_output!(:navigation, output)
|
|
88
|
+
raise ArgumentError, "SettingsLayout navigation requires at least one item" if @items.empty?
|
|
89
|
+
ensure
|
|
90
|
+
@phase = :structure
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render_navigation
|
|
94
|
+
nav(**slot_attributes(:navigation, attributes: { aria: { label: @label } })) do
|
|
95
|
+
ul(**slot_attributes(:items)) do
|
|
96
|
+
@items.each { |item| render_item(item) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_item(item)
|
|
102
|
+
li(**slot_attributes(:item)) do
|
|
103
|
+
a(
|
|
104
|
+
**slot_attributes(
|
|
105
|
+
:item_link,
|
|
106
|
+
attributes: {
|
|
107
|
+
href: item.href,
|
|
108
|
+
aria: { current: item.current ? "page" : nil },
|
|
109
|
+
data: { state: item.current ? "current" : "default" }
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
) do
|
|
113
|
+
render_in_slot(item.icon, :item_icon) if item.icon
|
|
114
|
+
plain(item.text)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render_content
|
|
120
|
+
div(**slot_attributes(:content)) { render(@content) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def ensure_phase!(expected, declaration)
|
|
124
|
+
return if @phase == expected
|
|
125
|
+
|
|
126
|
+
location = expected == :structure ? "the render block" : "the navigation block"
|
|
127
|
+
raise ArgumentError, "SettingsLayout #{declaration} must be declared directly inside #{location}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def reject_rendered_output!(location, output)
|
|
131
|
+
return if output.empty?
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "SettingsLayout #{location} accepts declarations, not rendered content"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def item_icon(value)
|
|
137
|
+
return if value.nil?
|
|
138
|
+
|
|
139
|
+
unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
|
|
140
|
+
raise ArgumentError, "SettingsLayout icon must be a non-blank String or Symbol"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
Icon.new(value, size: :sm)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_text!(name, value)
|
|
147
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
148
|
+
|
|
149
|
+
raise ArgumentError, "SettingsLayout #{name} must be a non-blank String"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class Sheet < Component
|
|
5
|
+
SIDES = %i[left right].freeze
|
|
6
|
+
SIZES = %i[sm md lg].freeze
|
|
7
|
+
|
|
8
|
+
Trigger = Data.define(:text, :variant, :size, :icon, :icon_end, :label, :disabled, :html, :aria, :data, :content)
|
|
9
|
+
private_constant :Trigger
|
|
10
|
+
|
|
11
|
+
Panel = Data.define(:title, :description, :content)
|
|
12
|
+
private_constant :Panel
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
id:,
|
|
16
|
+
side: :right,
|
|
17
|
+
size: :md,
|
|
18
|
+
close_label: I18n.t("nitro_kit.sheet.close"),
|
|
19
|
+
html: {},
|
|
20
|
+
aria: {},
|
|
21
|
+
data: {},
|
|
22
|
+
desperately_need_a_class: nil
|
|
23
|
+
)
|
|
24
|
+
unless id.is_a?(String) && id.present? && !id.match?(/\s/)
|
|
25
|
+
raise ArgumentError, "Sheet id: must be a non-blank String without whitespace"
|
|
26
|
+
end
|
|
27
|
+
unless close_label.is_a?(String) && close_label.present?
|
|
28
|
+
raise ArgumentError, "Sheet close_label: must be a non-blank String"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@id = id
|
|
32
|
+
@side = validate_choice!(:side, side, SIDES)
|
|
33
|
+
@size = validate_choice!(:size, size, SIZES)
|
|
34
|
+
@close_label = close_label
|
|
35
|
+
|
|
36
|
+
super(
|
|
37
|
+
component: :sheet,
|
|
38
|
+
attributes: {
|
|
39
|
+
id:,
|
|
40
|
+
data: {
|
|
41
|
+
controller: "nk--dialog",
|
|
42
|
+
side: @side,
|
|
43
|
+
nk__dialog_dismissible_value: true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
html:,
|
|
47
|
+
aria:,
|
|
48
|
+
data:,
|
|
49
|
+
size:,
|
|
50
|
+
desperately_need_a_class:
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :id, :side, :size
|
|
55
|
+
|
|
56
|
+
def view_template(&declarations)
|
|
57
|
+
collect_declarations(&declarations)
|
|
58
|
+
|
|
59
|
+
div(**root_attributes) do
|
|
60
|
+
render_trigger
|
|
61
|
+
render_panel
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def trigger(
|
|
66
|
+
text = nil,
|
|
67
|
+
variant: :default,
|
|
68
|
+
size: :md,
|
|
69
|
+
icon: nil,
|
|
70
|
+
icon_end: nil,
|
|
71
|
+
label: nil,
|
|
72
|
+
disabled: false,
|
|
73
|
+
html: {},
|
|
74
|
+
aria: {},
|
|
75
|
+
data: {},
|
|
76
|
+
&content
|
|
77
|
+
)
|
|
78
|
+
ensure_collecting!
|
|
79
|
+
raise ArgumentError, "Sheet accepts exactly one trigger" if @trigger
|
|
80
|
+
|
|
81
|
+
@trigger = Trigger.new(
|
|
82
|
+
text:,
|
|
83
|
+
variant:,
|
|
84
|
+
size:,
|
|
85
|
+
icon:,
|
|
86
|
+
icon_end:,
|
|
87
|
+
label:,
|
|
88
|
+
disabled: validate_boolean!(:disabled, disabled),
|
|
89
|
+
html:,
|
|
90
|
+
aria:,
|
|
91
|
+
data:,
|
|
92
|
+
content:
|
|
93
|
+
)
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def panel(title:, description: nil, &content)
|
|
98
|
+
ensure_collecting!
|
|
99
|
+
raise ArgumentError, "Sheet accepts exactly one panel" if @panel
|
|
100
|
+
unless title.is_a?(String) && title.present?
|
|
101
|
+
raise ArgumentError, "Sheet title: must be a non-blank String"
|
|
102
|
+
end
|
|
103
|
+
unless description.nil? || (description.is_a?(String) && description.present?)
|
|
104
|
+
raise ArgumentError, "Sheet description: must be nil or a non-blank String"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@panel = Panel.new(title:, description:, content:)
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def collect_declarations
|
|
114
|
+
raise ArgumentError, "Sheet requires a declaration block" unless block_given?
|
|
115
|
+
|
|
116
|
+
@collecting = true
|
|
117
|
+
yield(self)
|
|
118
|
+
raise ArgumentError, "Sheet requires exactly one trigger" unless @trigger
|
|
119
|
+
raise ArgumentError, "Sheet requires exactly one panel" unless @panel
|
|
120
|
+
ensure
|
|
121
|
+
@collecting = false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_trigger
|
|
125
|
+
button = Button.new(
|
|
126
|
+
@trigger.text,
|
|
127
|
+
variant: @trigger.variant,
|
|
128
|
+
size: @trigger.size,
|
|
129
|
+
icon: @trigger.icon,
|
|
130
|
+
icon_end: @trigger.icon_end,
|
|
131
|
+
label: @trigger.label,
|
|
132
|
+
disabled: @trigger.disabled,
|
|
133
|
+
html: command_attributes(@trigger.html, "show-modal", disabled: @trigger.disabled),
|
|
134
|
+
aria: @trigger.aria.merge(haspopup: "dialog"),
|
|
135
|
+
data: @trigger.data
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
render_in_slot(button, :trigger, &@trigger.content)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def render_panel
|
|
142
|
+
dialog(
|
|
143
|
+
**slot_attributes(
|
|
144
|
+
:panel,
|
|
145
|
+
attributes: {
|
|
146
|
+
id: panel_id,
|
|
147
|
+
closedby: "any",
|
|
148
|
+
aria: {
|
|
149
|
+
labelledby: title_id,
|
|
150
|
+
describedby: @panel.description ? description_id : nil
|
|
151
|
+
}.compact,
|
|
152
|
+
data: {
|
|
153
|
+
nk__dialog_target: "panel",
|
|
154
|
+
action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel"
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
) do
|
|
159
|
+
render_close
|
|
160
|
+
h2(**slot_attributes(:title, attributes: { id: title_id })) { @panel.title }
|
|
161
|
+
if @panel.description
|
|
162
|
+
p(**slot_attributes(:description, attributes: { id: description_id })) { @panel.description }
|
|
163
|
+
end
|
|
164
|
+
div(**slot_attributes(:body)) { render(@panel.content) } if @panel.content
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def render_close
|
|
169
|
+
render_in_slot(
|
|
170
|
+
Button.new(
|
|
171
|
+
icon: :x,
|
|
172
|
+
label: @close_label,
|
|
173
|
+
variant: :ghost,
|
|
174
|
+
size: :sm,
|
|
175
|
+
html: command_attributes({}, "close")
|
|
176
|
+
),
|
|
177
|
+
:close
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def command_attributes(html, command, disabled: false)
|
|
182
|
+
unless html.is_a?(Hash)
|
|
183
|
+
raise ArgumentError, "html must be a Hash"
|
|
184
|
+
end
|
|
185
|
+
if html.keys.any? { |key| %w[command commandfor].include?(key.to_s.downcase) }
|
|
186
|
+
raise ArgumentError, "command and commandfor are owned by Sheet"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
disabled ? html : html.merge(command:, commandfor: panel_id)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def panel_id = "#{id}-panel"
|
|
193
|
+
def title_id = "#{id}-title"
|
|
194
|
+
def description_id = "#{id}-description"
|
|
195
|
+
|
|
196
|
+
def ensure_collecting!
|
|
197
|
+
return if @collecting
|
|
198
|
+
|
|
199
|
+
raise ArgumentError, "Sheet declarations must be inside the render block"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class StatGrid < Component
|
|
5
|
+
DEFAULT_COLUMNS = "1 sm:2 lg:3"
|
|
6
|
+
Stat = Data.define(:key, :label, :value, :detail)
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
cols: DEFAULT_COLUMNS,
|
|
10
|
+
gap: 4,
|
|
11
|
+
id: nil,
|
|
12
|
+
html: {},
|
|
13
|
+
aria: {},
|
|
14
|
+
data: {},
|
|
15
|
+
desperately_need_a_class: nil
|
|
16
|
+
)
|
|
17
|
+
@stats = []
|
|
18
|
+
@grid = Grid.new(cols:, gap:)
|
|
19
|
+
|
|
20
|
+
super(
|
|
21
|
+
component: :stat_grid,
|
|
22
|
+
attributes: { id: }.compact,
|
|
23
|
+
html:,
|
|
24
|
+
aria:,
|
|
25
|
+
data:,
|
|
26
|
+
desperately_need_a_class:
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def view_template
|
|
31
|
+
yield self if block_given?
|
|
32
|
+
raise ArgumentError, "StatGrid requires at least one stat" if @stats.empty?
|
|
33
|
+
|
|
34
|
+
div(**root_attributes) do
|
|
35
|
+
render_in_slot(@grid, :grid) do
|
|
36
|
+
html_dl(**slot_attributes(:list)) do
|
|
37
|
+
@stats.each { |stat| render_stat(stat) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stat(key:, label:, value:, detail: nil)
|
|
44
|
+
key = normalize_identity(key, name: "stat key")
|
|
45
|
+
raise ArgumentError, "StatGrid stat keys must be unique: #{key.inspect}" if @stats.any? { |stat| stat.key == key }
|
|
46
|
+
|
|
47
|
+
@stats << Stat.new(
|
|
48
|
+
key:,
|
|
49
|
+
label: present_text!(:label, label),
|
|
50
|
+
value: present_text!(:value, value),
|
|
51
|
+
detail: detail.nil? ? nil : present_text!(:detail, detail)
|
|
52
|
+
)
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
alias :html_dl :dl
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def render_stat(stat)
|
|
61
|
+
div(**slot_attributes(:stat, attributes: { data: { key: stat.key } })) do
|
|
62
|
+
dt(**slot_attributes(:label)) { plain(stat.label) }
|
|
63
|
+
dd(**slot_attributes(:value)) { plain(stat.value) }
|
|
64
|
+
dd(**slot_attributes(:detail)) { plain(stat.detail) } if stat.detail
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def present_text!(name, value)
|
|
69
|
+
text = value.to_s
|
|
70
|
+
raise ArgumentError, "StatGrid #{name} must not be blank" if text.strip.empty?
|
|
71
|
+
|
|
72
|
+
text
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|