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.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/javascripts/keystone_ui/accordion_controller.js +21 -0
  3. data/app/assets/javascripts/keystone_ui/auto_submit_controller.js +7 -0
  4. data/app/assets/javascripts/keystone_ui/chartjs.js +24 -0
  5. data/app/assets/javascripts/keystone_ui/clipboard_controller.js +25 -0
  6. data/app/assets/javascripts/keystone_ui/color_picker_controller.js +158 -0
  7. data/app/assets/javascripts/keystone_ui/column_picker_controller.js +47 -0
  8. data/app/assets/javascripts/keystone_ui/dismiss_controller.js +7 -0
  9. data/app/assets/javascripts/keystone_ui/dropdown_controller.js +16 -0
  10. data/app/assets/javascripts/keystone_ui/file_upload_controller.js +61 -0
  11. data/app/assets/javascripts/keystone_ui/index.js +33 -0
  12. data/app/assets/javascripts/keystone_ui/line_chart_controller.js +38 -0
  13. data/app/assets/javascripts/keystone_ui/modal_controller.js +19 -0
  14. data/app/assets/javascripts/keystone_ui/multi_select_controller.js +35 -0
  15. data/app/assets/javascripts/keystone_ui/stat_card_info_controller.js +9 -0
  16. data/app/assets/javascripts/keystone_ui/swipe_deck_controller.js +123 -0
  17. data/app/assets/javascripts/keystone_ui/tab_switcher_controller.js +19 -0
  18. data/app/assets/tailwind/keystone_ui_engine/color_picker.css +31 -0
  19. data/app/assets/tailwind/keystone_ui_engine/engine.css +7 -0
  20. data/app/assets/tailwind/keystone_ui_engine/nav.css +175 -0
  21. data/app/assets/tailwind/keystone_ui_engine/theme.css +32 -0
  22. data/app/components/keystone/ui/accordion_component.html.erb +19 -0
  23. data/app/components/keystone/ui/accordion_component.rb +51 -0
  24. data/app/components/keystone/ui/alert_component.html.erb +15 -0
  25. data/app/components/keystone/ui/alert_component.rb +58 -0
  26. data/app/components/keystone/ui/badge_component.html.erb +1 -0
  27. data/app/components/keystone/ui/badge_component.rb +28 -0
  28. data/app/components/keystone/ui/bottom_nav_component.html.erb +3 -0
  29. data/app/components/keystone/ui/bottom_nav_component.rb +13 -0
  30. data/app/components/keystone/ui/bottom_nav_item_component.html.erb +4 -0
  31. data/app/components/keystone/ui/bottom_nav_item_component.rb +24 -0
  32. data/app/components/keystone/ui/button_component.html.erb +1 -0
  33. data/app/components/keystone/ui/button_component.rb +57 -0
  34. data/app/components/keystone/ui/card_component.html.erb +9 -0
  35. data/app/components/keystone/ui/card_component.rb +33 -0
  36. data/app/components/keystone/ui/card_link_component.html.erb +3 -0
  37. data/app/components/keystone/ui/card_link_component.rb +25 -0
  38. data/app/components/keystone/ui/chart_card_component.html.erb +6 -0
  39. data/app/components/keystone/ui/chart_card_component.rb +35 -0
  40. data/app/components/keystone/ui/code_component.html.erb +6 -0
  41. data/app/components/keystone/ui/code_component.rb +40 -0
  42. data/app/components/keystone/ui/color_picker_component.html.erb +37 -0
  43. data/app/components/keystone/ui/color_picker_component.rb +31 -0
  44. data/app/components/keystone/ui/column.rb +21 -0
  45. data/app/components/keystone/ui/column_picker_component.html.erb +20 -0
  46. data/app/components/keystone/ui/column_picker_component.rb +35 -0
  47. data/app/components/keystone/ui/copy_button_component.html.erb +11 -0
  48. data/app/components/keystone/ui/copy_button_component.rb +39 -0
  49. data/app/components/keystone/ui/cta_banner_component.html.erb +9 -0
  50. data/app/components/keystone/ui/cta_banner_component.rb +39 -0
  51. data/app/components/keystone/ui/data_table_component.html.erb +54 -0
  52. data/app/components/keystone/ui/data_table_component.rb +173 -0
  53. data/app/components/keystone/ui/disclosure_component.html.erb +9 -0
  54. data/app/components/keystone/ui/disclosure_component.rb +46 -0
  55. data/app/components/keystone/ui/feature_grid_component.html.erb +17 -0
  56. data/app/components/keystone/ui/feature_grid_component.rb +55 -0
  57. data/app/components/keystone/ui/file_upload_component.html.erb +18 -0
  58. data/app/components/keystone/ui/file_upload_component.rb +85 -0
  59. data/app/components/keystone/ui/form_component.html.erb +3 -0
  60. data/app/components/keystone/ui/form_component.rb +27 -0
  61. data/app/components/keystone/ui/form_field_component.html.erb +36 -0
  62. data/app/components/keystone/ui/form_field_component.rb +83 -0
  63. data/app/components/keystone/ui/form_page_component.html.erb +10 -0
  64. data/app/components/keystone/ui/form_page_component.rb +21 -0
  65. data/app/components/keystone/ui/funnel_component.html.erb +14 -0
  66. data/app/components/keystone/ui/funnel_component.rb +85 -0
  67. data/app/components/keystone/ui/grid_component.html.erb +3 -0
  68. data/app/components/keystone/ui/grid_component.rb +38 -0
  69. data/app/components/keystone/ui/hero_component.html.erb +27 -0
  70. data/app/components/keystone/ui/hero_component.rb +74 -0
  71. data/app/components/keystone/ui/input_component.html.erb +1 -0
  72. data/app/components/keystone/ui/input_component.rb +57 -0
  73. data/app/components/keystone/ui/line_chart_component.html.erb +3 -0
  74. data/app/components/keystone/ui/line_chart_component.rb +48 -0
  75. data/app/components/keystone/ui/mobile_actions_component.html.erb +11 -0
  76. data/app/components/keystone/ui/mobile_actions_component.rb +21 -0
  77. data/app/components/keystone/ui/mobile_header_component.html.erb +11 -0
  78. data/app/components/keystone/ui/mobile_header_component.rb +38 -0
  79. data/app/components/keystone/ui/modal_component.html.erb +13 -0
  80. data/app/components/keystone/ui/modal_component.rb +68 -0
  81. data/app/components/keystone/ui/multi_select_component.html.erb +21 -0
  82. data/app/components/keystone/ui/multi_select_component.rb +40 -0
  83. data/app/components/keystone/ui/nav_dropdown_component.html.erb +11 -0
  84. data/app/components/keystone/ui/nav_dropdown_component.rb +33 -0
  85. data/app/components/keystone/ui/nav_item_component.html.erb +1 -0
  86. data/app/components/keystone/ui/nav_item_component.rb +21 -0
  87. data/app/components/keystone/ui/navbar_component.html.erb +34 -0
  88. data/app/components/keystone/ui/navbar_component.rb +32 -0
  89. data/app/components/keystone/ui/option_card_component.html.erb +9 -0
  90. data/app/components/keystone/ui/option_card_component.rb +28 -0
  91. data/app/components/keystone/ui/page_component.html.erb +3 -0
  92. data/app/components/keystone/ui/page_component.rb +40 -0
  93. data/app/components/keystone/ui/page_header_component.html.erb +17 -0
  94. data/app/components/keystone/ui/page_header_component.rb +42 -0
  95. data/app/components/keystone/ui/panel_component.html.erb +3 -0
  96. data/app/components/keystone/ui/panel_component.rb +25 -0
  97. data/app/components/keystone/ui/pipeline_component.html.erb +34 -0
  98. data/app/components/keystone/ui/pipeline_component.rb +48 -0
  99. data/app/components/keystone/ui/progress_component.html.erb +8 -0
  100. data/app/components/keystone/ui/progress_component.rb +35 -0
  101. data/app/components/keystone/ui/radio_card_component.html.erb +9 -0
  102. data/app/components/keystone/ui/radio_card_component.rb +42 -0
  103. data/app/components/keystone/ui/section_component.html.erb +18 -0
  104. data/app/components/keystone/ui/section_component.rb +32 -0
  105. data/app/components/keystone/ui/select_component.html.erb +8 -0
  106. data/app/components/keystone/ui/select_component.rb +38 -0
  107. data/app/components/keystone/ui/settings_link_component.html.erb +4 -0
  108. data/app/components/keystone/ui/settings_link_component.rb +26 -0
  109. data/app/components/keystone/ui/show_page_component.html.erb +4 -0
  110. data/app/components/keystone/ui/show_page_component.rb +21 -0
  111. data/app/components/keystone/ui/stat_card_component.html.erb +22 -0
  112. data/app/components/keystone/ui/stat_card_component.rb +92 -0
  113. data/app/components/keystone/ui/swipe_deck_component.html.erb +39 -0
  114. data/app/components/keystone/ui/swipe_deck_component.rb +52 -0
  115. data/app/components/keystone/ui/tab_switcher_component.html.erb +14 -0
  116. data/app/components/keystone/ui/tab_switcher_component.rb +33 -0
  117. data/app/components/keystone/ui/textarea_component.html.erb +1 -0
  118. data/app/components/keystone/ui/textarea_component.rb +38 -0
  119. data/app/helpers/keystone_ui_helper.rb +195 -0
  120. data/config/importmap.rb +19 -0
  121. data/lib/generators/keystone/install_generator.rb +90 -0
  122. data/lib/keystone_ui/configuration.rb +24 -0
  123. data/lib/keystone_ui/engine.rb +47 -0
  124. data/lib/keystone_ui/safelist.rb +106 -0
  125. data/lib/keystone_ui/version.rb +5 -0
  126. data/lib/keystone_ui.rb +9 -0
  127. data/the_local/agents/keystone_ui-develop.md +297 -0
  128. data/the_local/agents/keystone_ui-info.md +81 -0
  129. data/the_local/agents/keystone_ui-install.md +137 -0
  130. data/the_local/interface.yml +110 -0
  131. 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,3 @@
1
+ <%= form_with(**form_options) do %>
2
+ <%= content %>
3
+ <% 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,3 @@
1
+ <div class="<%= classes %>">
2
+ <%= content %>
3
+ </div>
@@ -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,3 @@
1
+ <div class="<%= container_classes %>" data-controller="line-chart" data-line-chart-data-value="<%= chart_data_json %>">
2
+ <canvas class="max-w-full" data-line-chart-target="canvas"></canvas>
3
+ </div>
@@ -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>