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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NitroKit
|
|
4
|
+
class DetailsTable < Component
|
|
5
|
+
include Phlex::Rails::Helpers::Routes
|
|
6
|
+
|
|
7
|
+
UNSET = Object.new.freeze
|
|
8
|
+
private_constant :UNSET
|
|
9
|
+
Field = Data.define(:attribute, :label, :value, :content)
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
record,
|
|
13
|
+
caption: nil,
|
|
14
|
+
label: nil,
|
|
15
|
+
empty_text: nil,
|
|
16
|
+
boolean_labels: nil,
|
|
17
|
+
route_base: nil,
|
|
18
|
+
id: nil,
|
|
19
|
+
html: {},
|
|
20
|
+
aria: {},
|
|
21
|
+
data: {},
|
|
22
|
+
desperately_need_a_class: nil
|
|
23
|
+
)
|
|
24
|
+
@record = record
|
|
25
|
+
@route_base = route_base
|
|
26
|
+
@caption = caption.nil? ? nil : validate_text!("caption", caption)
|
|
27
|
+
@empty_text = validate_text!("empty_text", empty_text || I18n.t("nitro_kit.details_table.empty"))
|
|
28
|
+
@boolean_labels = validate_boolean_labels!(boolean_labels || default_boolean_labels)
|
|
29
|
+
@fields = []
|
|
30
|
+
@table = Table.new(
|
|
31
|
+
table_aria: label.nil? ? {} : { label: validate_text!("label", label) }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
super(
|
|
35
|
+
component: :details_table,
|
|
36
|
+
attributes: { id: }.compact,
|
|
37
|
+
html:,
|
|
38
|
+
aria:,
|
|
39
|
+
data:,
|
|
40
|
+
desperately_need_a_class:
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :record, :route_base
|
|
45
|
+
|
|
46
|
+
def view_template
|
|
47
|
+
yield self if block_given?
|
|
48
|
+
raise ArgumentError, "DetailsTable requires at least one field" if @fields.empty?
|
|
49
|
+
|
|
50
|
+
div(**root_attributes) do
|
|
51
|
+
render_in_slot(@table, :table) do
|
|
52
|
+
@table.caption(@caption) if @caption
|
|
53
|
+
@table.tbody do
|
|
54
|
+
@fields.each { |field| render_field(field) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fields(*attributes)
|
|
61
|
+
raise ArgumentError, "DetailsTable fields requires at least one attribute" if attributes.empty?
|
|
62
|
+
|
|
63
|
+
attributes.each { |attribute| field(attribute) }
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def field(attribute, label: nil, value: UNSET, &content)
|
|
68
|
+
attribute = validate_attribute!(attribute)
|
|
69
|
+
if @fields.any? { |field| field.attribute.to_s == attribute.to_s }
|
|
70
|
+
raise ArgumentError, "DetailsTable field keys must be unique: #{attribute.inspect}"
|
|
71
|
+
end
|
|
72
|
+
label = label.nil? ? attribute.to_s.humanize : validate_text!("field label", label)
|
|
73
|
+
resolved_value = value.equal?(UNSET) ? resolve_attribute(attribute) : value
|
|
74
|
+
|
|
75
|
+
@fields << Field.new(attribute:, label:, value: resolved_value, content:)
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def render_field(field)
|
|
82
|
+
@table.tr do
|
|
83
|
+
@table.th(field.label, scope: :row)
|
|
84
|
+
@table.td do
|
|
85
|
+
if field.content
|
|
86
|
+
field.content.call(field.value)
|
|
87
|
+
else
|
|
88
|
+
render_value(field.value)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render_value(value)
|
|
95
|
+
case value
|
|
96
|
+
when nil
|
|
97
|
+
em(**slot_attributes(:empty)) { plain(@empty_text) }
|
|
98
|
+
when true, false
|
|
99
|
+
span(**slot_attributes(:boolean)) { plain(@boolean_labels.fetch(value)) }
|
|
100
|
+
when Date, Time, ActiveSupport::TimeWithZone
|
|
101
|
+
time(**slot_attributes(:time, attributes: { datetime: value.iso8601 })) do
|
|
102
|
+
plain(I18n.localize(value, format: :long).to_s)
|
|
103
|
+
end
|
|
104
|
+
when Numeric
|
|
105
|
+
span(**slot_attributes(:number)) { plain(value.to_s) }
|
|
106
|
+
when Symbol
|
|
107
|
+
plain(value.to_s.humanize)
|
|
108
|
+
when String
|
|
109
|
+
render_string(value)
|
|
110
|
+
else
|
|
111
|
+
render_special_value(value)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_string(value)
|
|
116
|
+
if value.start_with?("https://", "http://")
|
|
117
|
+
a(
|
|
118
|
+
**slot_attributes(
|
|
119
|
+
:link,
|
|
120
|
+
attributes: { href: value, target: "_blank", rel: "noopener noreferrer" }
|
|
121
|
+
)
|
|
122
|
+
) { plain(value) }
|
|
123
|
+
else
|
|
124
|
+
plain(value)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def render_special_value(value)
|
|
129
|
+
if active_storage_attachment?(value)
|
|
130
|
+
render_attachment(value)
|
|
131
|
+
elsif active_record?(value)
|
|
132
|
+
render_record(value)
|
|
133
|
+
elsif collection?(value)
|
|
134
|
+
render_collection(value)
|
|
135
|
+
else
|
|
136
|
+
plain(value.to_s)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render_attachment(attachment)
|
|
141
|
+
return render_value(nil) unless attachment.attached?
|
|
142
|
+
|
|
143
|
+
if attachment.blob.image? && attachment.blob.variable?
|
|
144
|
+
render_in_slot(
|
|
145
|
+
ProgressiveImage.new(
|
|
146
|
+
attachment:,
|
|
147
|
+
alt: attachment.filename.to_s,
|
|
148
|
+
size: :sm
|
|
149
|
+
),
|
|
150
|
+
:attachment
|
|
151
|
+
)
|
|
152
|
+
else
|
|
153
|
+
code(**slot_attributes(:file)) { plain(attachment.filename.to_s) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def render_record(value)
|
|
158
|
+
route = route_base ? [ *Array(route_base), value ] : value
|
|
159
|
+
a(**slot_attributes(:record, attributes: { href: url_for(route) })) do
|
|
160
|
+
plain(record_name(value))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def render_collection(values)
|
|
165
|
+
span(**slot_attributes(:list)) do
|
|
166
|
+
values.each_with_index do |value, index|
|
|
167
|
+
plain(", ") if index.positive?
|
|
168
|
+
span(**slot_attributes(:list_item)) { render_value(value) }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def record_name(value)
|
|
174
|
+
label = value.to_label if value.respond_to?(:to_label)
|
|
175
|
+
label = value.name if label.blank? && value.respond_to?(:name)
|
|
176
|
+
label.presence || "#{value.class.model_name.human} ##{value.id}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def active_storage_attachment?(value)
|
|
180
|
+
defined?(ActiveStorage::Attached::One) && value.is_a?(ActiveStorage::Attached::One)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def active_record?(value)
|
|
184
|
+
defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def collection?(value)
|
|
188
|
+
value.is_a?(Array) || (
|
|
189
|
+
defined?(ActiveRecord::Associations::CollectionProxy) &&
|
|
190
|
+
value.is_a?(ActiveRecord::Associations::CollectionProxy)
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def resolve_attribute(attribute)
|
|
195
|
+
return record.public_send(attribute) if record.respond_to?(attribute)
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "#{record.class} does not expose #{attribute.inspect}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_attribute!(attribute)
|
|
201
|
+
return attribute if attribute.is_a?(Symbol)
|
|
202
|
+
return attribute if attribute.is_a?(String) && !attribute.strip.empty?
|
|
203
|
+
|
|
204
|
+
raise ArgumentError, "DetailsTable field attribute must be a Symbol or non-blank String"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_text!(name, value)
|
|
208
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
209
|
+
|
|
210
|
+
raise ArgumentError, "DetailsTable #{name} must be a non-blank String"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def default_boolean_labels
|
|
214
|
+
{
|
|
215
|
+
true => I18n.t("nitro_kit.details_table.boolean_true"),
|
|
216
|
+
false => I18n.t("nitro_kit.details_table.boolean_false")
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_boolean_labels!(labels)
|
|
221
|
+
unless labels.is_a?(Hash) && labels.keys.sort_by(&:to_s) == [ false, true ]
|
|
222
|
+
raise ArgumentError, "DetailsTable boolean_labels must be a Hash with true and false keys"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
labels.each { |key, value| validate_text!("boolean_labels[#{key}]", value) }
|
|
226
|
+
labels
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -2,117 +2,291 @@
|
|
|
2
2
|
|
|
3
3
|
module NitroKit
|
|
4
4
|
class Dialog < Component
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Trigger = ::Data.define(:text, :variant, :size, :disabled, :html, :aria, :data, :css_class, :content)
|
|
6
|
+
private_constant :Trigger
|
|
7
|
+
|
|
8
|
+
Panel = ::Data.define(:title, :description, :nonmodal, :html, :aria, :data, :css_class, :content)
|
|
9
|
+
private_constant :Panel
|
|
10
|
+
|
|
11
|
+
CloseButton = ::Data.define(:label, :html, :aria, :data, :css_class)
|
|
12
|
+
private_constant :CloseButton
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
id:,
|
|
16
|
+
dismissible: true,
|
|
17
|
+
html: {},
|
|
18
|
+
aria: {},
|
|
19
|
+
data: {},
|
|
20
|
+
desperately_need_a_class: nil
|
|
21
|
+
)
|
|
22
|
+
unless id.is_a?(String) && id.present? && !id.match?(/\s/)
|
|
23
|
+
raise ArgumentError, "Dialog id: must be a non-blank String without whitespace"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@id = id
|
|
27
|
+
@dismissible = validate_boolean!(:dismissible, dismissible)
|
|
7
28
|
|
|
8
29
|
super(
|
|
9
|
-
|
|
10
|
-
|
|
30
|
+
component: :dialog,
|
|
31
|
+
attributes: {
|
|
32
|
+
id:,
|
|
33
|
+
data: {
|
|
34
|
+
controller: "nk--dialog",
|
|
35
|
+
nk__dialog_dismissible_value: @dismissible
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
html:,
|
|
39
|
+
aria:,
|
|
40
|
+
data:,
|
|
41
|
+
desperately_need_a_class:
|
|
11
42
|
)
|
|
12
43
|
end
|
|
13
44
|
|
|
14
|
-
attr_reader :
|
|
45
|
+
attr_reader :id
|
|
15
46
|
|
|
16
|
-
def view_template
|
|
17
|
-
|
|
18
|
-
|
|
47
|
+
def view_template(&block)
|
|
48
|
+
collect_declarations(&block)
|
|
49
|
+
|
|
50
|
+
div(**root_attributes) do
|
|
51
|
+
render_trigger if @trigger
|
|
52
|
+
render_panel
|
|
19
53
|
end
|
|
20
54
|
end
|
|
21
55
|
|
|
22
|
-
def trigger(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
def trigger(
|
|
57
|
+
text = nil,
|
|
58
|
+
variant: :default,
|
|
59
|
+
size: :md,
|
|
60
|
+
disabled: false,
|
|
61
|
+
html: {},
|
|
62
|
+
aria: {},
|
|
63
|
+
data: {},
|
|
64
|
+
desperately_need_a_class: nil,
|
|
65
|
+
&content
|
|
66
|
+
)
|
|
67
|
+
ensure_collecting!
|
|
68
|
+
raise ArgumentError, "Dialog accepts at most one trigger" if @trigger
|
|
31
69
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
@trigger = Trigger.new(
|
|
71
|
+
text:,
|
|
72
|
+
variant:,
|
|
73
|
+
size:,
|
|
74
|
+
disabled: validate_boolean!(:disabled, disabled),
|
|
75
|
+
html:,
|
|
76
|
+
aria:,
|
|
77
|
+
data:,
|
|
78
|
+
css_class: desperately_need_a_class,
|
|
79
|
+
content:
|
|
80
|
+
)
|
|
81
|
+
nil
|
|
43
82
|
end
|
|
44
83
|
|
|
45
|
-
|
|
84
|
+
def panel(
|
|
85
|
+
title:,
|
|
86
|
+
description: nil,
|
|
87
|
+
nonmodal: false,
|
|
88
|
+
html: {},
|
|
89
|
+
aria: {},
|
|
90
|
+
data: {},
|
|
91
|
+
desperately_need_a_class: nil,
|
|
92
|
+
&content
|
|
93
|
+
)
|
|
94
|
+
ensure_collecting!
|
|
95
|
+
raise ArgumentError, "Dialog accepts exactly one panel" if @panel
|
|
46
96
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
html_dialog(
|
|
50
|
-
**mattr(
|
|
51
|
-
attrs,
|
|
52
|
-
class: dialog_class,
|
|
53
|
-
data: { nk__dialog_target: "dialog" },
|
|
54
|
-
aria: {
|
|
55
|
-
labelledby: id(:title),
|
|
56
|
-
describedby: id(:description)
|
|
57
|
-
}
|
|
58
|
-
)
|
|
59
|
-
) do
|
|
60
|
-
yield
|
|
61
|
-
end
|
|
97
|
+
unless title.is_a?(String) && title.present?
|
|
98
|
+
raise ArgumentError, "Dialog title: must be a non-blank String"
|
|
62
99
|
end
|
|
100
|
+
unless description.nil? || (description.is_a?(String) && description.present?)
|
|
101
|
+
raise ArgumentError, "Dialog description: must be nil or a non-blank String"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@panel = Panel.new(
|
|
105
|
+
title:,
|
|
106
|
+
description:,
|
|
107
|
+
nonmodal: validate_boolean!(:nonmodal, nonmodal),
|
|
108
|
+
html:,
|
|
109
|
+
aria:,
|
|
110
|
+
data:,
|
|
111
|
+
css_class: desperately_need_a_class,
|
|
112
|
+
content:
|
|
113
|
+
)
|
|
114
|
+
nil
|
|
63
115
|
end
|
|
64
116
|
|
|
65
|
-
def close_button(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
) do
|
|
78
|
-
render(Icon.new(:x))
|
|
79
|
-
end
|
|
117
|
+
def close_button(
|
|
118
|
+
label: I18n.t("nitro_kit.dialog.close"),
|
|
119
|
+
html: {},
|
|
120
|
+
aria: {},
|
|
121
|
+
data: {},
|
|
122
|
+
desperately_need_a_class: nil
|
|
123
|
+
)
|
|
124
|
+
ensure_rendering_panel!
|
|
125
|
+
raise ArgumentError, "Dialog accepts at most one close button" if @close_button
|
|
126
|
+
unless @dismissible
|
|
127
|
+
raise ArgumentError, "Dialog dismissible: false renders no close button"
|
|
80
128
|
end
|
|
129
|
+
|
|
130
|
+
@close_button = CloseButton.new(
|
|
131
|
+
label:,
|
|
132
|
+
html:,
|
|
133
|
+
aria:,
|
|
134
|
+
data:,
|
|
135
|
+
css_class: desperately_need_a_class
|
|
136
|
+
)
|
|
137
|
+
nil
|
|
81
138
|
end
|
|
82
139
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def collect_declarations
|
|
143
|
+
raise ArgumentError, "Dialog requires a declaration block" unless block_given?
|
|
144
|
+
|
|
145
|
+
@collecting = true
|
|
146
|
+
yield(self)
|
|
147
|
+
raise ArgumentError, "Dialog requires exactly one panel" unless @panel
|
|
148
|
+
|
|
149
|
+
if @panel.nonmodal && @trigger
|
|
150
|
+
raise ArgumentError,
|
|
151
|
+
"Dialog nonmodal: true cannot be combined with a trigger; the trigger opens the panel modally"
|
|
88
152
|
end
|
|
153
|
+
ensure
|
|
154
|
+
@collecting = false
|
|
89
155
|
end
|
|
90
156
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
157
|
+
def render_trigger
|
|
158
|
+
component = Button.new(
|
|
159
|
+
@trigger.text,
|
|
160
|
+
variant: @trigger.variant,
|
|
161
|
+
size: @trigger.size,
|
|
162
|
+
disabled: @trigger.disabled,
|
|
163
|
+
html: with_command(@trigger.html, "show-modal", disabled: @trigger.disabled),
|
|
164
|
+
aria: @trigger.aria.merge(haspopup: "dialog"),
|
|
165
|
+
data: @trigger.data,
|
|
166
|
+
desperately_need_a_class: @trigger.css_class
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if @trigger.content
|
|
170
|
+
render_in_slot(component, :trigger) { render(@trigger.content) }
|
|
171
|
+
else
|
|
172
|
+
render_in_slot(component, :trigger)
|
|
102
173
|
end
|
|
103
174
|
end
|
|
104
175
|
|
|
105
|
-
|
|
176
|
+
def render_panel
|
|
177
|
+
owned_aria = {
|
|
178
|
+
labelledby: element_id(:title),
|
|
179
|
+
describedby: @panel.description.nil? ? nil : element_id(:description)
|
|
180
|
+
}.compact
|
|
181
|
+
|
|
182
|
+
content = capture_panel_content
|
|
183
|
+
|
|
184
|
+
dialog(
|
|
185
|
+
**slot_attributes(
|
|
186
|
+
:panel,
|
|
187
|
+
attributes: {
|
|
188
|
+
id: element_id(:panel),
|
|
189
|
+
open: @panel.nonmodal,
|
|
190
|
+
closedby: @dismissible ? "any" : "none",
|
|
191
|
+
aria: owned_aria,
|
|
192
|
+
data: {
|
|
193
|
+
nk__dialog_target: "panel",
|
|
194
|
+
action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel"
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
html: @panel.html,
|
|
198
|
+
aria: @panel.aria,
|
|
199
|
+
data: @panel.data,
|
|
200
|
+
desperately_need_a_class: @panel.css_class
|
|
201
|
+
)
|
|
202
|
+
) do
|
|
203
|
+
render_close_button if @dismissible
|
|
204
|
+
render_title
|
|
205
|
+
render_description if @panel.description
|
|
206
|
+
raw(safe(content))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The panel content is captured first so Nitro owns the rendered order:
|
|
211
|
+
# close button, title, description, then application content. A sticky
|
|
212
|
+
# close button must precede scrolling content in the DOM.
|
|
213
|
+
def capture_panel_content
|
|
214
|
+
return "" unless @panel.content
|
|
215
|
+
|
|
216
|
+
@rendering_panel = true
|
|
217
|
+
capture { render(@panel.content) }
|
|
218
|
+
ensure
|
|
219
|
+
@rendering_panel = false
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def render_close_button
|
|
223
|
+
declaration = @close_button || CloseButton.new(
|
|
224
|
+
label: I18n.t("nitro_kit.dialog.close"),
|
|
225
|
+
html: {},
|
|
226
|
+
aria: {},
|
|
227
|
+
data: {},
|
|
228
|
+
css_class: nil
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
render_in_slot(
|
|
232
|
+
Button.new(
|
|
233
|
+
icon: :x,
|
|
234
|
+
variant: :ghost,
|
|
235
|
+
size: :sm,
|
|
236
|
+
html: with_command(declaration.html, "close"),
|
|
237
|
+
aria: declaration.aria.merge(label: declaration.label),
|
|
238
|
+
data: declaration.data,
|
|
239
|
+
desperately_need_a_class: declaration.css_class
|
|
240
|
+
),
|
|
241
|
+
:close
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def render_title
|
|
246
|
+
h2(
|
|
247
|
+
**slot_attributes(
|
|
248
|
+
:title,
|
|
249
|
+
attributes: { id: element_id(:title) }
|
|
250
|
+
)
|
|
251
|
+
) { @panel.title }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def render_description
|
|
255
|
+
div(
|
|
256
|
+
**slot_attributes(
|
|
257
|
+
:description,
|
|
258
|
+
attributes: { id: element_id(:description) }
|
|
259
|
+
)
|
|
260
|
+
) { @panel.description }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def element_id(suffix)
|
|
264
|
+
"#{id}-#{suffix}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def ensure_collecting!
|
|
268
|
+
return if @collecting
|
|
106
269
|
|
|
107
|
-
|
|
108
|
-
"nk-#{identifier}#{suffix ? "-#{suffix}" : ""}"
|
|
270
|
+
raise ArgumentError, "Dialog declarations must be inside the render block"
|
|
109
271
|
end
|
|
110
272
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
273
|
+
def ensure_rendering_panel!
|
|
274
|
+
return if @rendering_panel
|
|
275
|
+
|
|
276
|
+
raise ArgumentError, "Dialog close button must be declared inside the panel block"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def with_command(html, command, disabled: false)
|
|
280
|
+
unless html.is_a?(Hash)
|
|
281
|
+
raise ArgumentError, "html must be a Hash"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
collision = html.keys.find { |key| %w[command commandfor].include?(key.to_s.downcase) }
|
|
285
|
+
if collision
|
|
286
|
+
raise ArgumentError, "#{collision}: is owned by Dialog"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
disabled ? html : html.merge(command:, commandfor: element_id(:panel))
|
|
116
290
|
end
|
|
117
291
|
end
|
|
118
292
|
end
|