keystone_ui 0.6.0
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 +7 -0
- data/app/assets/javascripts/keystone_ui/accordion_controller.js +21 -0
- data/app/assets/javascripts/keystone_ui/auto_submit_controller.js +7 -0
- data/app/assets/javascripts/keystone_ui/chartjs.js +24 -0
- data/app/assets/javascripts/keystone_ui/clipboard_controller.js +25 -0
- data/app/assets/javascripts/keystone_ui/color_picker_controller.js +158 -0
- data/app/assets/javascripts/keystone_ui/column_picker_controller.js +47 -0
- data/app/assets/javascripts/keystone_ui/dismiss_controller.js +7 -0
- data/app/assets/javascripts/keystone_ui/dropdown_controller.js +16 -0
- data/app/assets/javascripts/keystone_ui/file_upload_controller.js +61 -0
- data/app/assets/javascripts/keystone_ui/index.js +33 -0
- data/app/assets/javascripts/keystone_ui/line_chart_controller.js +38 -0
- data/app/assets/javascripts/keystone_ui/modal_controller.js +19 -0
- data/app/assets/javascripts/keystone_ui/multi_select_controller.js +35 -0
- data/app/assets/javascripts/keystone_ui/stat_card_info_controller.js +9 -0
- data/app/assets/javascripts/keystone_ui/swipe_deck_controller.js +123 -0
- data/app/assets/javascripts/keystone_ui/tab_switcher_controller.js +19 -0
- data/app/assets/tailwind/keystone_ui_engine/color_picker.css +31 -0
- data/app/assets/tailwind/keystone_ui_engine/engine.css +7 -0
- data/app/assets/tailwind/keystone_ui_engine/nav.css +175 -0
- data/app/assets/tailwind/keystone_ui_engine/theme.css +32 -0
- data/app/components/keystone/ui/accordion_component.html.erb +19 -0
- data/app/components/keystone/ui/accordion_component.rb +51 -0
- data/app/components/keystone/ui/alert_component.html.erb +15 -0
- data/app/components/keystone/ui/alert_component.rb +58 -0
- data/app/components/keystone/ui/badge_component.html.erb +1 -0
- data/app/components/keystone/ui/badge_component.rb +28 -0
- data/app/components/keystone/ui/bottom_nav_component.html.erb +3 -0
- data/app/components/keystone/ui/bottom_nav_component.rb +13 -0
- data/app/components/keystone/ui/bottom_nav_item_component.html.erb +4 -0
- data/app/components/keystone/ui/bottom_nav_item_component.rb +24 -0
- data/app/components/keystone/ui/button_component.html.erb +1 -0
- data/app/components/keystone/ui/button_component.rb +57 -0
- data/app/components/keystone/ui/card_component.html.erb +9 -0
- data/app/components/keystone/ui/card_component.rb +33 -0
- data/app/components/keystone/ui/card_link_component.html.erb +3 -0
- data/app/components/keystone/ui/card_link_component.rb +25 -0
- data/app/components/keystone/ui/chart_card_component.html.erb +6 -0
- data/app/components/keystone/ui/chart_card_component.rb +35 -0
- data/app/components/keystone/ui/code_component.html.erb +6 -0
- data/app/components/keystone/ui/code_component.rb +40 -0
- data/app/components/keystone/ui/color_picker_component.html.erb +37 -0
- data/app/components/keystone/ui/color_picker_component.rb +31 -0
- data/app/components/keystone/ui/column.rb +21 -0
- data/app/components/keystone/ui/column_picker_component.html.erb +20 -0
- data/app/components/keystone/ui/column_picker_component.rb +35 -0
- data/app/components/keystone/ui/copy_button_component.html.erb +11 -0
- data/app/components/keystone/ui/copy_button_component.rb +39 -0
- data/app/components/keystone/ui/cta_banner_component.html.erb +9 -0
- data/app/components/keystone/ui/cta_banner_component.rb +39 -0
- data/app/components/keystone/ui/data_table_component.html.erb +54 -0
- data/app/components/keystone/ui/data_table_component.rb +173 -0
- data/app/components/keystone/ui/disclosure_component.html.erb +9 -0
- data/app/components/keystone/ui/disclosure_component.rb +46 -0
- data/app/components/keystone/ui/feature_grid_component.html.erb +17 -0
- data/app/components/keystone/ui/feature_grid_component.rb +55 -0
- data/app/components/keystone/ui/file_upload_component.html.erb +18 -0
- data/app/components/keystone/ui/file_upload_component.rb +85 -0
- data/app/components/keystone/ui/form_component.html.erb +3 -0
- data/app/components/keystone/ui/form_component.rb +27 -0
- data/app/components/keystone/ui/form_field_component.html.erb +36 -0
- data/app/components/keystone/ui/form_field_component.rb +83 -0
- data/app/components/keystone/ui/form_page_component.html.erb +10 -0
- data/app/components/keystone/ui/form_page_component.rb +21 -0
- data/app/components/keystone/ui/funnel_component.html.erb +14 -0
- data/app/components/keystone/ui/funnel_component.rb +85 -0
- data/app/components/keystone/ui/grid_component.html.erb +3 -0
- data/app/components/keystone/ui/grid_component.rb +38 -0
- data/app/components/keystone/ui/hero_component.html.erb +27 -0
- data/app/components/keystone/ui/hero_component.rb +74 -0
- data/app/components/keystone/ui/input_component.html.erb +1 -0
- data/app/components/keystone/ui/input_component.rb +57 -0
- data/app/components/keystone/ui/line_chart_component.html.erb +3 -0
- data/app/components/keystone/ui/line_chart_component.rb +48 -0
- data/app/components/keystone/ui/mobile_actions_component.html.erb +11 -0
- data/app/components/keystone/ui/mobile_actions_component.rb +21 -0
- data/app/components/keystone/ui/mobile_header_component.html.erb +11 -0
- data/app/components/keystone/ui/mobile_header_component.rb +38 -0
- data/app/components/keystone/ui/modal_component.html.erb +13 -0
- data/app/components/keystone/ui/modal_component.rb +68 -0
- data/app/components/keystone/ui/multi_select_component.html.erb +21 -0
- data/app/components/keystone/ui/multi_select_component.rb +40 -0
- data/app/components/keystone/ui/nav_dropdown_component.html.erb +11 -0
- data/app/components/keystone/ui/nav_dropdown_component.rb +33 -0
- data/app/components/keystone/ui/nav_item_component.html.erb +1 -0
- data/app/components/keystone/ui/nav_item_component.rb +21 -0
- data/app/components/keystone/ui/navbar_component.html.erb +34 -0
- data/app/components/keystone/ui/navbar_component.rb +32 -0
- data/app/components/keystone/ui/option_card_component.html.erb +9 -0
- data/app/components/keystone/ui/option_card_component.rb +28 -0
- data/app/components/keystone/ui/page_component.html.erb +3 -0
- data/app/components/keystone/ui/page_component.rb +40 -0
- data/app/components/keystone/ui/page_header_component.html.erb +17 -0
- data/app/components/keystone/ui/page_header_component.rb +42 -0
- data/app/components/keystone/ui/panel_component.html.erb +3 -0
- data/app/components/keystone/ui/panel_component.rb +25 -0
- data/app/components/keystone/ui/pipeline_component.html.erb +34 -0
- data/app/components/keystone/ui/pipeline_component.rb +48 -0
- data/app/components/keystone/ui/progress_component.html.erb +8 -0
- data/app/components/keystone/ui/progress_component.rb +35 -0
- data/app/components/keystone/ui/radio_card_component.html.erb +9 -0
- data/app/components/keystone/ui/radio_card_component.rb +42 -0
- data/app/components/keystone/ui/section_component.html.erb +18 -0
- data/app/components/keystone/ui/section_component.rb +32 -0
- data/app/components/keystone/ui/select_component.html.erb +8 -0
- data/app/components/keystone/ui/select_component.rb +38 -0
- data/app/components/keystone/ui/settings_link_component.html.erb +4 -0
- data/app/components/keystone/ui/settings_link_component.rb +26 -0
- data/app/components/keystone/ui/show_page_component.html.erb +4 -0
- data/app/components/keystone/ui/show_page_component.rb +21 -0
- data/app/components/keystone/ui/stat_card_component.html.erb +22 -0
- data/app/components/keystone/ui/stat_card_component.rb +92 -0
- data/app/components/keystone/ui/swipe_deck_component.html.erb +39 -0
- data/app/components/keystone/ui/swipe_deck_component.rb +52 -0
- data/app/components/keystone/ui/tab_switcher_component.html.erb +14 -0
- data/app/components/keystone/ui/tab_switcher_component.rb +33 -0
- data/app/components/keystone/ui/textarea_component.html.erb +1 -0
- data/app/components/keystone/ui/textarea_component.rb +38 -0
- data/app/helpers/keystone_ui_helper.rb +195 -0
- data/config/importmap.rb +19 -0
- data/lib/generators/keystone/install_generator.rb +90 -0
- data/lib/keystone_ui/configuration.rb +24 -0
- data/lib/keystone_ui/engine.rb +47 -0
- data/lib/keystone_ui/safelist.rb +106 -0
- data/lib/keystone_ui/version.rb +5 -0
- data/lib/keystone_ui.rb +9 -0
- data/the_local/agents/keystone_ui-develop.md +297 -0
- data/the_local/agents/keystone_ui-info.md +81 -0
- data/the_local/agents/keystone_ui-install.md +137 -0
- data/the_local/interface.yml +110 -0
- metadata +203 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class FileUploadComponent < ViewComponent::Base
|
|
6
|
+
WRAPPER_CLASSES = "space-y-1"
|
|
7
|
+
LABEL_CLASSES = "block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
8
|
+
DROP_ZONE_CLASSES = "mt-1 flex justify-center rounded-md border-2 border-dashed border-gray-300 px-6 py-8 cursor-pointer dark:border-zinc-600 transition-colors"
|
|
9
|
+
DROP_ZONE_ACTIVE_CLASSES = "border-accent-500 bg-accent-50 dark:bg-accent-900/10"
|
|
10
|
+
DROP_ZONE_INNER_CLASSES = "space-y-2 text-center"
|
|
11
|
+
ICON_CLASSES = "mx-auto h-10 w-10 text-gray-400 dark:text-gray-500"
|
|
12
|
+
PROMPT_CLASSES = "text-sm text-gray-600 dark:text-gray-400"
|
|
13
|
+
BROWSE_CLASSES = "font-semibold text-accent-600 hover:text-accent-500 dark:text-accent-400 dark:hover:text-accent-300"
|
|
14
|
+
HINT_CLASSES = "mt-1 text-xs text-gray-500 dark:text-gray-400"
|
|
15
|
+
FILE_NAME_CLASSES = "mt-2 text-sm text-gray-700 dark:text-gray-300 truncate"
|
|
16
|
+
FILE_INPUT_CLASSES = "sr-only"
|
|
17
|
+
|
|
18
|
+
UPLOAD_ICON = <<~SVG.freeze
|
|
19
|
+
<svg class="#{ICON_CLASSES}" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
|
20
|
+
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
21
|
+
</svg>
|
|
22
|
+
SVG
|
|
23
|
+
|
|
24
|
+
def initialize(name:, label: nil, accept: nil, multiple: false, hint: nil)
|
|
25
|
+
@name = name
|
|
26
|
+
@label = label
|
|
27
|
+
@accept = accept
|
|
28
|
+
@multiple = multiple
|
|
29
|
+
@hint = hint
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def input_name
|
|
33
|
+
@name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def label_text
|
|
37
|
+
@label || "Choose file"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def accept
|
|
41
|
+
@accept
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def multiple?
|
|
45
|
+
@multiple
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def hint?
|
|
49
|
+
!@hint.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def hint_text
|
|
53
|
+
@hint
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def prompt_text
|
|
57
|
+
multiple? ? "Drop files here or" : "Drop file here or"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def wrapper_data
|
|
61
|
+
{ controller: "file-upload" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def drop_zone_data
|
|
65
|
+
{ "file-upload-target": "dropZone" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def input_data
|
|
69
|
+
{ "file-upload-target": "input", action: "change->file-upload#select" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tag_options
|
|
73
|
+
options = {
|
|
74
|
+
type: "file",
|
|
75
|
+
name: @name,
|
|
76
|
+
class: FILE_INPUT_CLASSES,
|
|
77
|
+
data: { "file-upload-target": "input", action: "change->file-upload#select" }
|
|
78
|
+
}
|
|
79
|
+
options[:accept] = @accept if @accept
|
|
80
|
+
options[:multiple] = true if @multiple
|
|
81
|
+
options
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class FormComponent < ViewComponent::Base
|
|
6
|
+
FORM_CLASSES = "space-y-6"
|
|
7
|
+
|
|
8
|
+
def initialize(action:, method: :post, multipart: false, data: nil)
|
|
9
|
+
@action = action
|
|
10
|
+
@method = method.to_sym
|
|
11
|
+
@multipart = multipart
|
|
12
|
+
@data = data
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def form_options
|
|
16
|
+
options = {
|
|
17
|
+
url: @action,
|
|
18
|
+
method: @method,
|
|
19
|
+
class: FORM_CLASSES,
|
|
20
|
+
multipart: @multipart
|
|
21
|
+
}
|
|
22
|
+
options[:data] = @data if @data
|
|
23
|
+
options
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div class="<%= WRAPPER_CLASSES %>">
|
|
2
|
+
<% unless checkbox? %>
|
|
3
|
+
<label class="<%= LABEL_CLASSES %>">
|
|
4
|
+
<%= label_text %><% if required? %><span class="<%= REQUIRED_CLASSES %>">*</span><% end %>
|
|
5
|
+
</label>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<% if checkbox? %>
|
|
9
|
+
<div class="<%= CHECKBOX_WRAPPER_CLASSES %>">
|
|
10
|
+
<input type="hidden" name="<%= @attribute %>" value="0">
|
|
11
|
+
<input type="checkbox" name="<%= @attribute %>" value="1" class="<%= CHECKBOX_CLASSES %>"<%= " checked" if @value.to_s == "1" %>>
|
|
12
|
+
<span class="text-sm text-gray-700 dark:text-gray-300"><%= label_text %></span>
|
|
13
|
+
</div>
|
|
14
|
+
<% elsif textarea? %>
|
|
15
|
+
<textarea name="<%= @attribute %>" rows="3" class="<%= Keystone::Ui::TextareaComponent::BASE_CLASSES %>"<% if @placeholder %> placeholder="<%= @placeholder %>"<% end %>><%= @value %></textarea>
|
|
16
|
+
<% elsif select? %>
|
|
17
|
+
<select name="<%= @attribute %>" class="<%= Keystone::Ui::SelectComponent::BASE_CLASSES %> <%= Keystone::Ui::SelectComponent::FOCUS_CLASSES %>">
|
|
18
|
+
<% unless required? %><option value=""></option><% end %>
|
|
19
|
+
<% select_options.each do |label, value| %>
|
|
20
|
+
<option value="<%= value %>" <%= "selected" if @value.to_s == value.to_s %>><%= label %></option>
|
|
21
|
+
<% end %>
|
|
22
|
+
</select>
|
|
23
|
+
<% else %>
|
|
24
|
+
<%= tag.input(**input_options) %>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<% if hint? %>
|
|
28
|
+
<p class="<%= HINT_CLASSES %>"><%= hint_text %></p>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if errors? %>
|
|
32
|
+
<% error_messages.each do |message| %>
|
|
33
|
+
<p class="<%= ERROR_CLASSES %>"><%= message %></p>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class FormFieldComponent < ViewComponent::Base
|
|
6
|
+
WRAPPER_CLASSES = "space-y-1"
|
|
7
|
+
LABEL_CLASSES = "block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
8
|
+
REQUIRED_CLASSES = "text-red-500 ml-0.5"
|
|
9
|
+
HINT_CLASSES = "mt-1 text-sm text-gray-500 dark:text-gray-400"
|
|
10
|
+
ERROR_CLASSES = "mt-1 text-sm text-red-600 dark:text-red-400"
|
|
11
|
+
CHECKBOX_CLASSES = "rounded border-gray-300 text-accent-600 focus:ring-accent-500 dark:border-zinc-600 dark:bg-zinc-900"
|
|
12
|
+
CHECKBOX_WRAPPER_CLASSES = "flex items-center gap-2"
|
|
13
|
+
|
|
14
|
+
def initialize(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [])
|
|
15
|
+
@attribute = attribute
|
|
16
|
+
@label = label
|
|
17
|
+
@type = type
|
|
18
|
+
@required = required
|
|
19
|
+
@hint = hint
|
|
20
|
+
@placeholder = placeholder
|
|
21
|
+
@min = min
|
|
22
|
+
@max = max
|
|
23
|
+
@step = step
|
|
24
|
+
@value = value
|
|
25
|
+
@options = options
|
|
26
|
+
@errors = Array(errors)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def label_text
|
|
30
|
+
@label || @attribute.to_s.tr("_", " ").capitalize
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def required?
|
|
34
|
+
@required
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def hint?
|
|
38
|
+
!@hint.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def hint_text
|
|
42
|
+
@hint
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def errors?
|
|
46
|
+
@errors.any?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def error_messages
|
|
50
|
+
@errors
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def textarea?
|
|
54
|
+
@type == :textarea
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def checkbox?
|
|
58
|
+
@type == :checkbox
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def select?
|
|
62
|
+
@type == :select
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def select_options
|
|
66
|
+
@options
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def input_options
|
|
70
|
+
options = { name: @attribute.to_s, class: Keystone::Ui::InputComponent::BASE_CLASSES }
|
|
71
|
+
unless textarea?
|
|
72
|
+
options[:type] = Keystone::Ui::InputComponent::TYPE_MAP.fetch(@type)
|
|
73
|
+
end
|
|
74
|
+
options[:placeholder] = @placeholder unless @placeholder.nil?
|
|
75
|
+
options[:value] = @value unless @value.nil?
|
|
76
|
+
options[:min] = @min unless @min.nil?
|
|
77
|
+
options[:max] = @max unless @max.nil?
|
|
78
|
+
options[:step] = @step unless @step.nil?
|
|
79
|
+
options
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% content_for :form_page, true %>
|
|
2
|
+
<% content_for :form_page_title, @title %>
|
|
3
|
+
<% content_for :form_page_back_url, @back_url %>
|
|
4
|
+
|
|
5
|
+
<div class="<%= DESKTOP_WRAPPER_CLASSES %>">
|
|
6
|
+
<h1 class="<%= TITLE_CLASSES %>"><%= @title %></h1>
|
|
7
|
+
<% if subtitle? %>
|
|
8
|
+
<p class="<%= SUBTITLE_CLASSES %>"><%= @subtitle %></p>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class FormPageComponent < ViewComponent::Base
|
|
6
|
+
DESKTOP_WRAPPER_CLASSES = "hidden md:block"
|
|
7
|
+
TITLE_CLASSES = "text-2xl font-semibold text-gray-900 dark:text-white"
|
|
8
|
+
SUBTITLE_CLASSES = "mt-1 text-sm text-gray-500 dark:text-gray-400"
|
|
9
|
+
|
|
10
|
+
def initialize(title:, back_url:, subtitle: nil)
|
|
11
|
+
@title = title
|
|
12
|
+
@back_url = back_url
|
|
13
|
+
@subtitle = subtitle
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def subtitle?
|
|
17
|
+
!@subtitle.nil?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<div class="<%= container_classes %>">
|
|
2
|
+
<% layers.each do |layer| %>
|
|
3
|
+
<% if layer.conversion_percent %>
|
|
4
|
+
<div class="<%= transition_classes %>">↓ <%= layer.conversion_percent %>%</div>
|
|
5
|
+
<% end %>
|
|
6
|
+
<div class="<%= layer_classes %>">
|
|
7
|
+
<div class="<%= row_classes %>">
|
|
8
|
+
<span class="<%= label_classes %>"><%= layer.label %></span>
|
|
9
|
+
<span class="<%= value_classes %>"><%= layer.value %></span>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="<%= bar_classes %>" style="width: <%= layer.width_percent %>%"></div>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class FunnelComponent < ViewComponent::Base
|
|
6
|
+
Layer = Struct.new(:label, :value, :width_percent, :conversion_percent, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
CONTAINER_CLASSES = "space-y-2"
|
|
9
|
+
LAYER_CLASSES = "space-y-1"
|
|
10
|
+
ROW_CLASSES = "flex items-baseline justify-between gap-3"
|
|
11
|
+
LABEL_CLASSES = "text-sm font-medium text-surface-700 truncate"
|
|
12
|
+
VALUE_CLASSES = "text-sm font-semibold text-surface-900 tabular-nums"
|
|
13
|
+
BAR_CLASSES = "h-8 rounded-md bg-accent-500 transition-all"
|
|
14
|
+
TRANSITION_CLASSES = "py-1 text-center text-xs text-surface-500"
|
|
15
|
+
|
|
16
|
+
attr_reader :steps
|
|
17
|
+
|
|
18
|
+
def initialize(steps:)
|
|
19
|
+
@steps = steps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def layers
|
|
23
|
+
previous = nil
|
|
24
|
+
|
|
25
|
+
steps.map do |step|
|
|
26
|
+
layer = Layer.new(
|
|
27
|
+
label: step[:label],
|
|
28
|
+
value: step[:value],
|
|
29
|
+
width_percent: width_percent(step[:value]),
|
|
30
|
+
conversion_percent: conversion_percent(step[:value], previous)
|
|
31
|
+
)
|
|
32
|
+
previous = step[:value]
|
|
33
|
+
layer
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def container_classes
|
|
38
|
+
CONTAINER_CLASSES
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def layer_classes
|
|
42
|
+
LAYER_CLASSES
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def row_classes
|
|
46
|
+
ROW_CLASSES
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bar_classes
|
|
50
|
+
BAR_CLASSES
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def label_classes
|
|
54
|
+
LABEL_CLASSES
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def value_classes
|
|
58
|
+
VALUE_CLASSES
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def transition_classes
|
|
62
|
+
TRANSITION_CLASSES
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def conversion_percent(value, previous)
|
|
68
|
+
return nil if previous.nil?
|
|
69
|
+
return 0 if previous.zero?
|
|
70
|
+
|
|
71
|
+
(value.to_f / previous * 100).round
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def top_value
|
|
75
|
+
steps.first[:value].to_f
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def width_percent(value)
|
|
79
|
+
return 0 if top_value.zero?
|
|
80
|
+
|
|
81
|
+
(value.to_f / top_value * 100).round
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class GridComponent < ViewComponent::Base
|
|
6
|
+
GAP_CLASSES = { sm: "gap-3", md: "gap-4", lg: "gap-6", xl: "gap-8" }.freeze
|
|
7
|
+
GAP_X_CLASSES = { sm: "gap-x-3", md: "gap-x-4", lg: "gap-x-6", xl: "gap-x-8" }.freeze
|
|
8
|
+
GAP_Y_CLASSES = { sm: "gap-y-3", md: "gap-y-4", lg: "gap-y-6", xl: "gap-y-8" }.freeze
|
|
9
|
+
COL_CLASSES = {
|
|
10
|
+
default: { 1 => "grid-cols-1", 2 => "grid-cols-2", 3 => "grid-cols-3", 4 => "grid-cols-4", 5 => "grid-cols-5", 6 => "grid-cols-6", 7 => "grid-cols-7", 8 => "grid-cols-8", 9 => "grid-cols-9", 10 => "grid-cols-10", 11 => "grid-cols-11", 12 => "grid-cols-12" }.freeze,
|
|
11
|
+
sm: { 1 => "sm:grid-cols-1", 2 => "sm:grid-cols-2", 3 => "sm:grid-cols-3", 4 => "sm:grid-cols-4", 5 => "sm:grid-cols-5", 6 => "sm:grid-cols-6", 7 => "sm:grid-cols-7", 8 => "sm:grid-cols-8", 9 => "sm:grid-cols-9", 10 => "sm:grid-cols-10", 11 => "sm:grid-cols-11", 12 => "sm:grid-cols-12" }.freeze,
|
|
12
|
+
md: { 1 => "md:grid-cols-1", 2 => "md:grid-cols-2", 3 => "md:grid-cols-3", 4 => "md:grid-cols-4", 5 => "md:grid-cols-5", 6 => "md:grid-cols-6", 7 => "md:grid-cols-7", 8 => "md:grid-cols-8", 9 => "md:grid-cols-9", 10 => "md:grid-cols-10", 11 => "md:grid-cols-11", 12 => "md:grid-cols-12" }.freeze,
|
|
13
|
+
lg: { 1 => "lg:grid-cols-1", 2 => "lg:grid-cols-2", 3 => "lg:grid-cols-3", 4 => "lg:grid-cols-4", 5 => "lg:grid-cols-5", 6 => "lg:grid-cols-6", 7 => "lg:grid-cols-7", 8 => "lg:grid-cols-8", 9 => "lg:grid-cols-9", 10 => "lg:grid-cols-10", 11 => "lg:grid-cols-11", 12 => "lg:grid-cols-12" }.freeze
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(cols: { default: 1 }, gap: :md, gap_x: nil, gap_y: nil)
|
|
17
|
+
@cols = cols
|
|
18
|
+
@gap = gap
|
|
19
|
+
@gap_x = gap_x
|
|
20
|
+
@gap_y = gap_y
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def classes
|
|
24
|
+
tokens = [ "grid" ]
|
|
25
|
+
if @gap_x || @gap_y
|
|
26
|
+
tokens << GAP_X_CLASSES.fetch(@gap_x) if @gap_x
|
|
27
|
+
tokens << GAP_Y_CLASSES.fetch(@gap_y) if @gap_y
|
|
28
|
+
else
|
|
29
|
+
tokens << GAP_CLASSES.fetch(@gap)
|
|
30
|
+
end
|
|
31
|
+
@cols.each do |breakpoint, count|
|
|
32
|
+
tokens << COL_CLASSES.fetch(breakpoint).fetch(count)
|
|
33
|
+
end
|
|
34
|
+
tokens.join(" ")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<section class="<%= classes %>">
|
|
2
|
+
<div class="<%= inner_classes %>">
|
|
3
|
+
<div class="<%= content_classes %>">
|
|
4
|
+
<div class="<%= inner_content_classes %>">
|
|
5
|
+
<% if badge? %>
|
|
6
|
+
<span class="<%= badge_classes %>"><%= badge %></span>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<h1 class="<%= title_classes %>"><%= title %></h1>
|
|
10
|
+
|
|
11
|
+
<% if subtitle? %>
|
|
12
|
+
<p class="<%= subtitle_classes %>"><%= subtitle %></p>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<div class="<%= actions_classes %>">
|
|
16
|
+
<%= content %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<% if aside? %>
|
|
21
|
+
<div class="min-w-0">
|
|
22
|
+
<%= aside %>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</section>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class HeroComponent < ViewComponent::Base
|
|
6
|
+
WRAPPER_CLASSES = "relative min-h-screen pt-24"
|
|
7
|
+
INNER_CLASSES = "mx-auto max-w-6xl px-6 py-24 lg:py-32"
|
|
8
|
+
|
|
9
|
+
CONTENT_COLUMN_CLASSES = "flex flex-col gap-8"
|
|
10
|
+
CENTERED_COLUMN_CLASSES = "items-center"
|
|
11
|
+
|
|
12
|
+
SPLIT_CLASSES = "grid gap-12 lg:grid-cols-2 lg:gap-16 items-center"
|
|
13
|
+
CENTERED_CLASSES = "flex flex-col items-center text-center"
|
|
14
|
+
|
|
15
|
+
TITLE_BASE_CLASSES = "text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl"
|
|
16
|
+
SUBTITLE_BASE_CLASSES = "max-w-lg text-lg"
|
|
17
|
+
BADGE_BASE_CLASSES = "inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1.5 text-sm"
|
|
18
|
+
ACTIONS_CLASSES = "flex flex-wrap gap-4"
|
|
19
|
+
|
|
20
|
+
renders_one :aside
|
|
21
|
+
|
|
22
|
+
attr_reader :title, :subtitle, :badge
|
|
23
|
+
|
|
24
|
+
def initialize(title:, subtitle: nil, badge: nil, layout: :split)
|
|
25
|
+
@title = title
|
|
26
|
+
@subtitle = subtitle
|
|
27
|
+
@badge = badge
|
|
28
|
+
@layout = layout
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def classes
|
|
32
|
+
WRAPPER_CLASSES
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inner_classes
|
|
36
|
+
INNER_CLASSES
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def content_classes
|
|
40
|
+
@layout == :centered ? CENTERED_CLASSES : SPLIT_CLASSES
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inner_content_classes
|
|
44
|
+
return CONTENT_COLUMN_CLASSES unless @layout == :centered
|
|
45
|
+
|
|
46
|
+
"#{CONTENT_COLUMN_CLASSES} #{CENTERED_COLUMN_CLASSES}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def title_classes
|
|
50
|
+
"#{TITLE_BASE_CLASSES} text-surface-900 dark:text-white"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def subtitle_classes
|
|
54
|
+
"#{SUBTITLE_BASE_CLASSES} text-surface-500 dark:text-surface-400"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def subtitle?
|
|
58
|
+
!@subtitle.nil?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def badge_classes
|
|
62
|
+
"#{BADGE_BASE_CLASSES} border-accent-500/20 bg-accent-500/10 text-accent-600 dark:text-accent-400"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def badge?
|
|
66
|
+
!@badge.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def actions_classes
|
|
70
|
+
ACTIONS_CLASSES
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= tag.input(**tag_options) %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class InputComponent < ViewComponent::Base
|
|
6
|
+
BASE_CLASSES = "block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-1 dark:bg-zinc-900 dark:border-zinc-700 dark:text-white dark:placeholder:text-gray-500"
|
|
7
|
+
|
|
8
|
+
DISABLED_CLASSES = "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-zinc-800 dark:text-gray-400"
|
|
9
|
+
|
|
10
|
+
TYPE_MAP = {
|
|
11
|
+
text: "text",
|
|
12
|
+
number: "number",
|
|
13
|
+
email: "email",
|
|
14
|
+
password: "password",
|
|
15
|
+
date: "date"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(name:, type: :text, value: nil, placeholder: nil, disabled: false, min: nil, max: nil, step: nil)
|
|
19
|
+
@name = name
|
|
20
|
+
@type = type
|
|
21
|
+
@value = value
|
|
22
|
+
@placeholder = placeholder
|
|
23
|
+
@disabled = disabled
|
|
24
|
+
@min = min
|
|
25
|
+
@max = max
|
|
26
|
+
@step = step
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
FOCUS_CLASSES = "focus:border-accent-500 focus:ring-accent-500 dark:focus:border-accent-400 dark:focus:ring-accent-400"
|
|
30
|
+
|
|
31
|
+
def classes
|
|
32
|
+
tokens = [ BASE_CLASSES, FOCUS_CLASSES ]
|
|
33
|
+
tokens << DISABLED_CLASSES if @disabled
|
|
34
|
+
tokens.join(" ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def input_type
|
|
38
|
+
TYPE_MAP.fetch(@type)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tag_options
|
|
42
|
+
options = {
|
|
43
|
+
type: input_type,
|
|
44
|
+
name: @name,
|
|
45
|
+
class: classes
|
|
46
|
+
}
|
|
47
|
+
options[:value] = @value unless @value.nil?
|
|
48
|
+
options[:placeholder] = @placeholder unless @placeholder.nil?
|
|
49
|
+
options[:disabled] = true if @disabled
|
|
50
|
+
options[:min] = @min unless @min.nil?
|
|
51
|
+
options[:max] = @max unless @max.nil?
|
|
52
|
+
options[:step] = @step unless @step.nil?
|
|
53
|
+
options
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Keystone
|
|
6
|
+
module Ui
|
|
7
|
+
class LineChartComponent < ViewComponent::Base
|
|
8
|
+
HEIGHT_CLASSES = {
|
|
9
|
+
sm: "h-48",
|
|
10
|
+
md: "h-64",
|
|
11
|
+
lg: "h-96"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
DASH_PATTERN = [ 6, 6 ].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(series:, labels:, height: :md)
|
|
17
|
+
@series = series
|
|
18
|
+
@labels = labels
|
|
19
|
+
@height = height
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chart_data
|
|
23
|
+
{ labels: @labels, datasets: @series.map { |s| dataset_for(s) } }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def chart_data_json
|
|
27
|
+
chart_data.to_json
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def height_class
|
|
31
|
+
HEIGHT_CLASSES.fetch(@height)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def container_classes
|
|
35
|
+
"#{height_class} relative w-full min-w-0"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def dataset_for(series)
|
|
41
|
+
dataset = { label: series[:name], data: series[:data] }
|
|
42
|
+
dataset[:borderColor] = series[:color] if series[:color]
|
|
43
|
+
dataset[:borderDash] = DASH_PATTERN if series[:dashed]
|
|
44
|
+
dataset
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="<%= WRAPPER_CLASSES %>" data-controller="dropdown">
|
|
2
|
+
<button type="button"
|
|
3
|
+
data-action="click->dropdown#toggle click@window->dropdown#hide"
|
|
4
|
+
class="<%= BUTTON_CLASSES %>"
|
|
5
|
+
aria-label="Actions">
|
|
6
|
+
<%= ELLIPSIS_ICON.html_safe %>
|
|
7
|
+
</button>
|
|
8
|
+
<div data-dropdown-target="menu" class="<%= DROPDOWN_CLASSES %>">
|
|
9
|
+
<%= content %>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keystone
|
|
4
|
+
module Ui
|
|
5
|
+
class MobileActionsComponent < ViewComponent::Base
|
|
6
|
+
ELLIPSIS_ICON = <<~SVG.freeze
|
|
7
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
8
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
|
9
|
+
</svg>
|
|
10
|
+
SVG
|
|
11
|
+
|
|
12
|
+
WRAPPER_CLASSES = "relative lg:hidden"
|
|
13
|
+
BUTTON_CLASSES = "text-gray-500 dark:text-gray-400"
|
|
14
|
+
DROPDOWN_CLASSES = "hidden absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 dark:bg-zinc-800 dark:ring-white/10"
|
|
15
|
+
|
|
16
|
+
def wrapper_data
|
|
17
|
+
{ controller: "dropdown" }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="<%= WRAPPER_CLASSES %>">
|
|
2
|
+
<%= link_to @back_url, class: BACK_LINK_CLASSES, aria: { label: "Back" } do %>
|
|
3
|
+
<%= BACK_ICON.html_safe %>
|
|
4
|
+
<% end %>
|
|
5
|
+
</div>
|
|
6
|
+
<span class="<%= TITLE_CLASSES %>">
|
|
7
|
+
<%= @title %>
|
|
8
|
+
<% if @subtitle.present? %>
|
|
9
|
+
<span class="<%= SUBTITLE_CLASSES %>"><%= @subtitle %></span>
|
|
10
|
+
<% end %>
|
|
11
|
+
</span>
|