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,72 +2,314 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Component < Phlex::HTML
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
DeferredContent = Data.define(:text, :block)
|
|
6
|
+
private_constant :DeferredContent
|
|
7
|
+
# Data keys a component sets for itself through `attributes:`.
|
|
8
|
+
COMPONENT_OWNED_DATA_ATTRIBUTES = %w[
|
|
9
|
+
state disabled required orientation presentation placement layout side field-type
|
|
10
|
+
].freeze
|
|
11
|
+
# Every data key Nitro owns. Applications may not pass any of them through `data:`.
|
|
12
|
+
RESERVED_DATA_ATTRIBUTES = (
|
|
13
|
+
%w[nk slot variant size nk-escape enhanced] + COMPONENT_OWNED_DATA_ATTRIBUTES
|
|
14
|
+
).freeze
|
|
15
|
+
ADDITIVE_DATA_ATTRIBUTES = %w[action controller].freeze
|
|
16
|
+
FORBIDDEN_ATTRIBUTES = %w[class style].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
component:,
|
|
20
|
+
attributes: {},
|
|
21
|
+
html: {},
|
|
22
|
+
aria: {},
|
|
23
|
+
data: {},
|
|
24
|
+
variant: nil,
|
|
25
|
+
size: nil,
|
|
26
|
+
desperately_need_a_class: nil
|
|
27
|
+
)
|
|
28
|
+
@component_name = normalize_identity(component, name: "component")
|
|
29
|
+
@attrs = owned_attributes(
|
|
30
|
+
attributes:,
|
|
31
|
+
html:,
|
|
32
|
+
aria:,
|
|
33
|
+
data:,
|
|
34
|
+
owned_data: {
|
|
35
|
+
nk: @component_name,
|
|
36
|
+
variant: variant && normalize_identity(variant, name: "variant"),
|
|
37
|
+
size: size && normalize_identity(size, name: "size")
|
|
38
|
+
}.compact,
|
|
39
|
+
desperately_need_a_class:
|
|
40
|
+
)
|
|
7
41
|
end
|
|
8
42
|
|
|
9
|
-
|
|
43
|
+
private
|
|
10
44
|
|
|
11
|
-
def
|
|
12
|
-
|
|
45
|
+
def root_attributes
|
|
46
|
+
@attrs
|
|
13
47
|
end
|
|
14
48
|
|
|
15
|
-
def
|
|
16
|
-
|
|
49
|
+
def slot_attributes(
|
|
50
|
+
slot,
|
|
51
|
+
attributes: {},
|
|
52
|
+
html: {},
|
|
53
|
+
aria: {},
|
|
54
|
+
data: {},
|
|
55
|
+
variant: nil,
|
|
56
|
+
desperately_need_a_class: nil
|
|
57
|
+
)
|
|
58
|
+
raise ArgumentError, "Slots require a component identity" unless @component_name
|
|
59
|
+
|
|
60
|
+
owned_attributes(
|
|
61
|
+
attributes:,
|
|
62
|
+
html:,
|
|
63
|
+
aria:,
|
|
64
|
+
data:,
|
|
65
|
+
owned_data: {
|
|
66
|
+
slot: qualified_slot(slot),
|
|
67
|
+
variant: variant && normalize_identity(variant, name: "variant")
|
|
68
|
+
}.compact,
|
|
69
|
+
desperately_need_a_class:
|
|
70
|
+
)
|
|
17
71
|
end
|
|
18
72
|
|
|
19
|
-
|
|
73
|
+
def render_in_slot(component, slot, &block)
|
|
74
|
+
unless component.is_a?(NitroKit::Component)
|
|
75
|
+
raise ArgumentError, "Slots can only contain NitroKit::Component instances"
|
|
76
|
+
end
|
|
20
77
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
"[DEPRECATION] builder_method is deprecated. Please migrate to using the builder(&) pattern. See https://github.com/mikker/nitro_kit/issues/35 for details."
|
|
28
|
-
)
|
|
78
|
+
component.__send__(:attach_to_slot!, qualified_slot(slot))
|
|
79
|
+
render(component, &block)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_choice!(name, value, choices)
|
|
83
|
+
return value if choices.include?(value)
|
|
29
84
|
|
|
30
|
-
|
|
85
|
+
raise ArgumentError, "Unknown #{name} #{value.inspect}; expected one of: #{choices.map(&:inspect).join(", ")}"
|
|
31
86
|
end
|
|
32
87
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
def validate_boolean!(name, value, allow_nil: false)
|
|
89
|
+
return value if value == true || value == false
|
|
90
|
+
return value if allow_nil && value.nil?
|
|
91
|
+
|
|
92
|
+
expected = allow_nil ? "true, false, or nil" : "true or false"
|
|
93
|
+
raise ArgumentError, "#{name} must be #{expected}; received #{value.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def owned_attributes(attributes:, html:, aria:, data:, owned_data:, desperately_need_a_class:)
|
|
97
|
+
attributes = attribute_hash(attributes, name: "attributes")
|
|
98
|
+
html = attribute_hash(html, name: "html")
|
|
99
|
+
aria = attribute_hash(aria, name: "aria")
|
|
100
|
+
data = attribute_hash(data, name: "data")
|
|
101
|
+
|
|
102
|
+
internal_aria = extract_nested_attributes!(attributes, :aria)
|
|
103
|
+
internal_data = extract_nested_attributes!(attributes, :data)
|
|
104
|
+
internal_aria = normalize_aria_values(internal_aria)
|
|
105
|
+
aria = normalize_aria_values(aria)
|
|
106
|
+
|
|
107
|
+
validate_html_attributes!(attributes, source: "attributes")
|
|
108
|
+
validate_html_attributes!(html, source: "html")
|
|
109
|
+
validate_public_data!(data)
|
|
110
|
+
validate_internal_data!(internal_data)
|
|
111
|
+
|
|
112
|
+
merged = merge_distinct_attributes(attributes, html)
|
|
113
|
+
merged_aria = merge_distinct_attributes(internal_aria, aria, name: "ARIA")
|
|
114
|
+
merged_data = merge_owned_data(owned_data, internal_data, data)
|
|
115
|
+
|
|
116
|
+
merged[:aria] = merged_aria if merged_aria.any?
|
|
117
|
+
merged[:data] = merged_data if merged_data.any?
|
|
118
|
+
|
|
119
|
+
unless desperately_need_a_class.nil?
|
|
120
|
+
validate_class_escape!(desperately_need_a_class)
|
|
121
|
+
merged[:class] = desperately_need_a_class
|
|
122
|
+
merged[:data] = merge_owned_data(merged.fetch(:data, {}), { nk_escape: "class" })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
merged
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def attribute_hash(value, name:)
|
|
129
|
+
return value.dup if value.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
raise ArgumentError, "#{name} must be a Hash"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_nested_attributes!(attributes, name)
|
|
135
|
+
key = attributes.keys.find { |candidate| normalized_attribute(candidate) == name.to_s }
|
|
136
|
+
return {} unless key
|
|
137
|
+
|
|
138
|
+
attribute_hash(attributes.delete(key), name:)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_html_attributes!(attributes, source:)
|
|
142
|
+
attributes.each_key do |key|
|
|
143
|
+
normalized = normalized_attribute(key)
|
|
144
|
+
|
|
145
|
+
if FORBIDDEN_ATTRIBUTES.include?(normalized)
|
|
146
|
+
raise ArgumentError, "#{normalized}: is not allowed; use desperately_need_a_class: for classes"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if normalized == "data" || normalized == "aria" || normalized.start_with?("data-") || normalized.start_with?("aria-")
|
|
150
|
+
boundary = normalized.start_with?("aria") ? "aria" : "data"
|
|
151
|
+
raise ArgumentError, "Pass #{normalized} through #{boundary}:, not #{source}:"
|
|
47
152
|
end
|
|
153
|
+
end
|
|
48
154
|
end
|
|
49
155
|
|
|
50
|
-
|
|
156
|
+
def validate_public_data!(data)
|
|
157
|
+
data.each_key do |key|
|
|
158
|
+
normalized = normalized_data_attribute(key)
|
|
159
|
+
next unless RESERVED_DATA_ATTRIBUTES.include?(normalized)
|
|
51
160
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@@merger.merge(args)
|
|
161
|
+
raise ArgumentError, "data-#{normalized} is reserved by Nitro Kit"
|
|
162
|
+
end
|
|
55
163
|
end
|
|
56
164
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
165
|
+
def validate_internal_data!(data)
|
|
166
|
+
base_owned = RESERVED_DATA_ATTRIBUTES - COMPONENT_OWNED_DATA_ATTRIBUTES
|
|
167
|
+
|
|
168
|
+
data.each_key do |key|
|
|
169
|
+
normalized = normalized_data_attribute(key)
|
|
170
|
+
next unless base_owned.include?(normalized)
|
|
171
|
+
|
|
172
|
+
raise ArgumentError, "Use the component or slot API for data-#{normalized}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def merge_distinct_attributes(*hashes, name: "HTML")
|
|
177
|
+
hashes.compact.reduce({}) do |merged, attributes|
|
|
178
|
+
attributes.each do |key, value|
|
|
179
|
+
existing_key = merged.keys.find do |candidate|
|
|
180
|
+
normalized_attribute(candidate) == normalized_attribute(key)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if existing_key
|
|
184
|
+
raise ArgumentError, "Duplicate #{name} attribute #{normalized_attribute(key)}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
merged[key] = value
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
merged
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def merge_owned_data(*hashes)
|
|
195
|
+
hashes.compact.reduce({}) do |merged, attributes|
|
|
196
|
+
attributes.each do |key, value|
|
|
197
|
+
existing_key = merged.keys.find do |candidate|
|
|
198
|
+
normalized_data_attribute(candidate) == normalized_data_attribute(key)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if existing_key
|
|
202
|
+
normalized = normalized_data_attribute(key)
|
|
203
|
+
|
|
204
|
+
unless ADDITIVE_DATA_ATTRIBUTES.include?(normalized)
|
|
205
|
+
raise ArgumentError, "Duplicate data-#{normalized} attribute"
|
|
68
206
|
end
|
|
207
|
+
|
|
208
|
+
merged[existing_key] = [ merged[existing_key], value ].flatten.compact.join(" ")
|
|
209
|
+
else
|
|
210
|
+
merged[key] = value
|
|
69
211
|
end
|
|
70
212
|
end
|
|
213
|
+
|
|
214
|
+
merged
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def normalize_identity(value, name:)
|
|
219
|
+
normalized = value.to_s.strip.tr("_", "-")
|
|
220
|
+
raise ArgumentError, "#{name} cannot be blank" if normalized.empty?
|
|
221
|
+
unless normalized.match?(/\A[a-z0-9-]+\z/)
|
|
222
|
+
raise ArgumentError, "#{name} must contain only lowercase letters, numbers, and hyphens"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
normalized
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def qualified_slot(slot)
|
|
229
|
+
raise ArgumentError, "Slots require a component identity" unless @component_name
|
|
230
|
+
|
|
231
|
+
slot_name = normalize_identity(slot, name: "slot")
|
|
232
|
+
slot_name.start_with?("#{@component_name}-") ? slot_name : "#{@component_name}-#{slot_name}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def attach_to_slot!(slot)
|
|
236
|
+
raise ArgumentError, "Nested slots require a component identity" unless @component_name
|
|
237
|
+
|
|
238
|
+
data = @attrs.fetch(:data, {})
|
|
239
|
+
if data.keys.any? { |key| normalized_data_attribute(key) == "slot" }
|
|
240
|
+
raise ArgumentError, "Component is already attached to a slot"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
@attrs = @attrs.merge(data: merge_owned_data(data, { slot: }))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def normalized_attribute(key)
|
|
247
|
+
key.to_s.downcase.tr("_", "-")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def normalized_data_attribute(key)
|
|
251
|
+
normalized_attribute(key).delete_prefix("data-")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def normalized_aria_attribute(key)
|
|
255
|
+
normalized_attribute(key).delete_prefix("aria-")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Conventions shared by the labelled control components. They read the
|
|
259
|
+
# `@label`, `@description`, `@id`, `@control_aria`, and `@invalid` state
|
|
260
|
+
# those components already keep.
|
|
261
|
+
|
|
262
|
+
def description_id
|
|
263
|
+
"#{@id}-description" if @id && @description
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def control_aria
|
|
267
|
+
attributes = @control_aria.dup
|
|
268
|
+
key = attributes.keys.find { |candidate| normalized_aria_attribute(candidate) == "describedby" }
|
|
269
|
+
attributes[:describedby] = [ attributes.delete(key), description_id ].compact.join(" ").presence
|
|
270
|
+
attributes[:invalid] = "true" if @invalid
|
|
271
|
+
attributes
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def require_accessible_name!(&block)
|
|
275
|
+
labelled = !@label.nil? || !block.nil?
|
|
276
|
+
|
|
277
|
+
unless labelled || accessible_name?
|
|
278
|
+
raise ArgumentError, "#{component_words} requires a label, block, or accessible control name"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
if @description && !labelled
|
|
282
|
+
raise ArgumentError, "#{component_words} description requires label text or block content"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def accessible_name?
|
|
287
|
+
@control_aria.any? do |key, value|
|
|
288
|
+
%w[label labelledby].include?(normalized_aria_attribute(key)) && value.to_s.present?
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def component_words
|
|
293
|
+
@component_name.tr("-", " ")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def validate_optional_text!(name, text)
|
|
297
|
+
return if text.nil?
|
|
298
|
+
return text if text.is_a?(String) && !text.strip.empty?
|
|
299
|
+
|
|
300
|
+
raise ArgumentError, "#{name} must be a non-blank String or nil"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def normalize_aria_values(attributes)
|
|
304
|
+
attributes.transform_values do |value|
|
|
305
|
+
value == true || value == false ? value.to_s : value
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_class_escape!(value)
|
|
310
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
311
|
+
|
|
312
|
+
raise ArgumentError, "desperately_need_a_class: must be a non-blank String"
|
|
71
313
|
end
|
|
72
314
|
|
|
73
315
|
def text_or_block(text = nil, &block)
|
|
@@ -81,5 +323,39 @@ module NitroKit
|
|
|
81
323
|
nil
|
|
82
324
|
end
|
|
83
325
|
end
|
|
326
|
+
|
|
327
|
+
def content_from_keyword(name, text)
|
|
328
|
+
return if text.nil?
|
|
329
|
+
|
|
330
|
+
validate_content_text!(name, text)
|
|
331
|
+
DeferredContent.new(text:, block: nil)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def declare_content(name, current, text = nil, &block)
|
|
335
|
+
raise ArgumentError, "#{name} has already been provided" if current
|
|
336
|
+
raise ArgumentError, "#{name} accepts text or a block, not both" if !text.nil? && block
|
|
337
|
+
|
|
338
|
+
unless block
|
|
339
|
+
validate_content_text!(name, text)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
DeferredContent.new(text:, block:)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def require_content!(component, name, content)
|
|
346
|
+
return content if content
|
|
347
|
+
|
|
348
|
+
raise ArgumentError, "#{component} requires #{name} through the constructor or compound method"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def render_deferred_content(content)
|
|
352
|
+
text_or_block(content.text, &content.block)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def validate_content_text!(name, text)
|
|
356
|
+
return text if text.is_a?(String) && !text.strip.empty?
|
|
357
|
+
|
|
358
|
+
raise ArgumentError, "#{name} must be a non-blank String or a block"
|
|
359
|
+
end
|
|
84
360
|
end
|
|
85
361
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class Container < Component
|
|
5
|
+
SIZES = %i[sm md lg xl].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
size:,
|
|
9
|
+
id: nil,
|
|
10
|
+
html: {},
|
|
11
|
+
aria: {},
|
|
12
|
+
data: {},
|
|
13
|
+
desperately_need_a_class: nil
|
|
14
|
+
)
|
|
15
|
+
size = validate_choice!(:size, size, SIZES)
|
|
16
|
+
|
|
17
|
+
super(
|
|
18
|
+
component: :container,
|
|
19
|
+
attributes: { id: }.compact,
|
|
20
|
+
html:,
|
|
21
|
+
aria:,
|
|
22
|
+
data:,
|
|
23
|
+
size:,
|
|
24
|
+
desperately_need_a_class:
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def view_template
|
|
29
|
+
div(**root_attributes) { yield if block_given? }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class ControlGroup < Component
|
|
5
|
+
def initialize(label: nil, id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
|
|
6
|
+
unless label.nil? || (label.is_a?(String) && label.present?)
|
|
7
|
+
raise ArgumentError, "ControlGroup label: must be nil or a non-blank String"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
super(
|
|
11
|
+
component: :control_group,
|
|
12
|
+
attributes: {
|
|
13
|
+
id:,
|
|
14
|
+
role: label ? "group" : nil,
|
|
15
|
+
aria: { label: }.compact
|
|
16
|
+
},
|
|
17
|
+
html:,
|
|
18
|
+
aria:,
|
|
19
|
+
data:,
|
|
20
|
+
desperately_need_a_class:
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def view_template
|
|
25
|
+
raise ArgumentError, "ControlGroup requires a block" unless block_given?
|
|
26
|
+
|
|
27
|
+
@rendering = true
|
|
28
|
+
div(**root_attributes) { yield(self) }
|
|
29
|
+
ensure
|
|
30
|
+
@rendering = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def addon(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
|
|
34
|
+
raise ArgumentError, "ControlGroup addon must be declared inside the render block" unless @rendering
|
|
35
|
+
raise ArgumentError, "ControlGroup addon accepts text or a block, not both" if text && block
|
|
36
|
+
unless block || (text.is_a?(String) && text.present?)
|
|
37
|
+
raise ArgumentError, "ControlGroup addon requires non-blank text or a block"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
span(**slot_attributes(:addon, html:, aria:, data:, desperately_need_a_class:)) do
|
|
41
|
+
text_or_block(text, &block)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class DangerZone < Component
|
|
5
|
+
Child = Data.define(:component, :content)
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
title: nil,
|
|
9
|
+
description: nil,
|
|
10
|
+
id: nil,
|
|
11
|
+
html: {},
|
|
12
|
+
aria: {},
|
|
13
|
+
data: {},
|
|
14
|
+
desperately_need_a_class: nil
|
|
15
|
+
)
|
|
16
|
+
@title_content = content_from_keyword(:title, title)
|
|
17
|
+
@description_content = content_from_keyword(:description, description)
|
|
18
|
+
@confirmation = nil
|
|
19
|
+
@escape = nil
|
|
20
|
+
|
|
21
|
+
super(
|
|
22
|
+
component: :danger_zone,
|
|
23
|
+
attributes: { id: }.compact,
|
|
24
|
+
html:,
|
|
25
|
+
aria:,
|
|
26
|
+
data:,
|
|
27
|
+
desperately_need_a_class:
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def view_template
|
|
32
|
+
yield self if block_given?
|
|
33
|
+
require_content!("DangerZone", :title, @title_content)
|
|
34
|
+
require_content!("DangerZone", :description, @description_content)
|
|
35
|
+
raise ArgumentError, "DangerZone requires exactly one confirmation" unless @confirmation
|
|
36
|
+
|
|
37
|
+
section(**root_attributes) do
|
|
38
|
+
header(**slot_attributes(:header)) do
|
|
39
|
+
h2(**slot_attributes(:title)) { render_deferred_content(@title_content) }
|
|
40
|
+
p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
|
|
41
|
+
end
|
|
42
|
+
div(**slot_attributes(:confirmation), &@confirmation)
|
|
43
|
+
render_in_slot(@escape.component, :escape, &@escape.content) if @escape
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def title(text = nil, &block)
|
|
48
|
+
@title_content = declare_content(:title, @title_content, text, &block)
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def description(text = nil, &block)
|
|
53
|
+
@description_content = declare_content(:description, @description_content, text, &block)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def confirmation(&content)
|
|
58
|
+
raise ArgumentError, "DangerZone confirmation requires a block" unless content
|
|
59
|
+
raise ArgumentError, "DangerZone accepts exactly one confirmation" if @confirmation
|
|
60
|
+
|
|
61
|
+
@confirmation = content
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def escape(component, &content)
|
|
66
|
+
unless component.is_a?(NitroKit::Button)
|
|
67
|
+
raise ArgumentError, "DangerZone escape must be a NitroKit::Button"
|
|
68
|
+
end
|
|
69
|
+
if component.variant == :destructive
|
|
70
|
+
raise ArgumentError, "DangerZone escape cannot use the destructive Button variant"
|
|
71
|
+
end
|
|
72
|
+
raise ArgumentError, "DangerZone accepts at most one safe escape action" if @escape
|
|
73
|
+
|
|
74
|
+
@escape = Child.new(component:, content:)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class DataSection < Component
|
|
5
|
+
Child = Data.define(:component, :content)
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
title: nil,
|
|
9
|
+
description: nil,
|
|
10
|
+
id: nil,
|
|
11
|
+
html: {},
|
|
12
|
+
aria: {},
|
|
13
|
+
data: {},
|
|
14
|
+
desperately_need_a_class: nil
|
|
15
|
+
)
|
|
16
|
+
@title_content = content_from_keyword(:title, title)
|
|
17
|
+
@description_content = content_from_keyword(:description, description)
|
|
18
|
+
@actions = nil
|
|
19
|
+
@content = nil
|
|
20
|
+
|
|
21
|
+
super(
|
|
22
|
+
component: :data_section,
|
|
23
|
+
attributes: { id: }.compact,
|
|
24
|
+
html:,
|
|
25
|
+
aria:,
|
|
26
|
+
data:,
|
|
27
|
+
desperately_need_a_class:
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def view_template
|
|
32
|
+
yield self if block_given?
|
|
33
|
+
require_content!("DataSection", :title, @title_content)
|
|
34
|
+
raise ArgumentError, "DataSection requires exactly one table or EmptyState" unless @content
|
|
35
|
+
|
|
36
|
+
section(**root_attributes) do
|
|
37
|
+
header(**slot_attributes(:header)) do
|
|
38
|
+
div(**slot_attributes(:heading)) do
|
|
39
|
+
h2(**slot_attributes(:title)) { render_deferred_content(@title_content) }
|
|
40
|
+
if @description_content
|
|
41
|
+
p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
render_in_slot(@actions.component, :actions, &@actions.content) if @actions
|
|
45
|
+
end
|
|
46
|
+
render_in_slot(@content.component, content_slot, &@content.content)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def title(text = nil, &block)
|
|
51
|
+
@title_content = declare_content(:title, @title_content, text, &block)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def description(text = nil, &block)
|
|
56
|
+
@description_content = declare_content(:description, @description_content, text, &block)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
alias :html_table :table
|
|
61
|
+
|
|
62
|
+
def actions(component, &content)
|
|
63
|
+
unless component.is_a?(NitroKit::ButtonGroup) || component.is_a?(NitroKit::Button)
|
|
64
|
+
raise ArgumentError, "DataSection actions must be a NitroKit::ButtonGroup or NitroKit::Button"
|
|
65
|
+
end
|
|
66
|
+
raise ArgumentError, "DataSection accepts at most one actions group" if @actions
|
|
67
|
+
|
|
68
|
+
@actions = Child.new(component:, content:)
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def table(component, &content)
|
|
73
|
+
unless table_component?(component)
|
|
74
|
+
raise ArgumentError, "DataSection table must be a NitroKit::Table or NitroKit::DetailsTable"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
assign_content(component, &content)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def empty_state(component, &content)
|
|
81
|
+
unless component.is_a?(NitroKit::EmptyState)
|
|
82
|
+
raise ArgumentError, "DataSection empty_state must be a NitroKit::EmptyState"
|
|
83
|
+
end
|
|
84
|
+
raise ArgumentError, "DataSection EmptyState must use level: 3" if component.level != 3
|
|
85
|
+
|
|
86
|
+
assign_content(component, &content)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def table_component?(component)
|
|
92
|
+
component.is_a?(NitroKit::Table) || component.is_a?(NitroKit::DetailsTable)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def assign_content(component, &content)
|
|
96
|
+
raise ArgumentError, "DataSection accepts exactly one table or EmptyState" if @content
|
|
97
|
+
|
|
98
|
+
@content = Child.new(component:, content:)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def content_slot
|
|
103
|
+
table_component?(@content.component) ? :table : :empty_state
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|