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,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class MobileHeaderComponent < ViewComponent::Base
6
+ BACK_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="M15.75 19.5 8.25 12l7.5-7.5" />
9
+ </svg>
10
+ SVG
11
+
12
+ ELLIPSIS_ICON = <<~SVG.freeze
13
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
14
+ <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" />
15
+ </svg>
16
+ SVG
17
+
18
+ WRAPPER_CLASSES = "lg:hidden"
19
+ BACK_LINK_CLASSES = "text-gray-500 dark:text-gray-400"
20
+ TITLE_CLASSES = "absolute left-1/2 -translate-x-1/2 font-semibold text-gray-900 dark:text-white lg:hidden truncate max-w-[60%]"
21
+ SUBTITLE_CLASSES = "block text-xs font-normal text-gray-500 dark:text-gray-400 truncate text-center"
22
+ 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"
23
+
24
+ # Renders the left-side and title of the mobile header.
25
+ # Call from the left side of your navbar.
26
+ #
27
+ # Usage in navbar:
28
+ # <% if content_for?(:form_page) %>
29
+ # <%= ui_mobile_header_left %>
30
+ # <% end %>
31
+ def initialize(title:, back_url:, subtitle: nil)
32
+ @title = title
33
+ @back_url = back_url
34
+ @subtitle = subtitle
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,13 @@
1
+ <div data-controller="modal" data-modal-target="backdrop" class="<%= backdrop_classes %>" data-action="click->modal#closeOnBackdrop">
2
+ <div class="<%= panel_classes %>">
3
+ <div class="<%= header_classes %>">
4
+ <h3 class="<%= title_classes %>"><%= title %></h3>
5
+ <button type="button" data-action="click->modal#close" class="<%= close_button_classes %>" aria-label="Close">
6
+ <%= close_icon.html_safe %>
7
+ </button>
8
+ </div>
9
+ <div class="<%= body_classes %>">
10
+ <%= content %>
11
+ </div>
12
+ </div>
13
+ </div>
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class ModalComponent < ViewComponent::Base
6
+ BACKDROP_CLASSES = "hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60"
7
+ PANEL_CLASSES = "rounded-xl border border-gray-200 bg-white p-6 w-full mx-4 max-h-[80vh] flex flex-col dark:border-zinc-700 dark:bg-zinc-800"
8
+ HEADER_CLASSES = "flex items-center justify-between mb-4"
9
+ TITLE_CLASSES = "text-lg font-semibold text-gray-900 dark:text-gray-200"
10
+ CLOSE_BUTTON_CLASSES = "text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
11
+ BODY_CLASSES = "overflow-auto flex-1"
12
+
13
+ SIZE_CLASSES = {
14
+ sm: "max-w-sm",
15
+ md: "max-w-lg",
16
+ lg: "max-w-2xl",
17
+ xl: "max-w-4xl"
18
+ }.freeze
19
+
20
+ CLOSE_ICON = <<~SVG.freeze
21
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
22
+ SVG
23
+
24
+ attr_reader :title
25
+
26
+ def initialize(title:, size: :md)
27
+ @title = title
28
+ @size = size
29
+ end
30
+
31
+ def backdrop_classes
32
+ BACKDROP_CLASSES
33
+ end
34
+
35
+ def panel_classes
36
+ [ PANEL_CLASSES, size_class ].join(" ")
37
+ end
38
+
39
+ def header_classes
40
+ HEADER_CLASSES
41
+ end
42
+
43
+ def title_classes
44
+ TITLE_CLASSES
45
+ end
46
+
47
+ def close_button_classes
48
+ CLOSE_BUTTON_CLASSES
49
+ end
50
+
51
+ def body_classes
52
+ BODY_CLASSES
53
+ end
54
+
55
+ def size_class
56
+ SIZE_CLASSES.fetch(@size)
57
+ end
58
+
59
+ def close_icon
60
+ CLOSE_ICON
61
+ end
62
+
63
+ def wrapper_data
64
+ { controller: "modal" }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,21 @@
1
+ <div class="<%= WRAPPER_CLASSES %>" data-controller="multi-select" data-multi-select-label-value="<%= @label %>">
2
+ <button type="button"
3
+ data-action="click->multi-select#toggle"
4
+ class="<%= TRIGGER_CLASSES %>">
5
+ <span data-multi-select-target="label"><%= display_text %></span>
6
+ <%= CARET_ICON.html_safe %>
7
+ </button>
8
+ <div data-multi-select-target="menu" class="<%= MENU_CLASSES %>">
9
+ <% @options.each do |opt_label, opt_value| %>
10
+ <label class="<%= OPTION_CLASSES %>">
11
+ <input type="checkbox"
12
+ name="<%= @name %>"
13
+ value="<%= opt_value %>"
14
+ <%= "checked" if selected?(opt_value) %>
15
+ data-action="change->multi-select#updateLabel"
16
+ class="<%= CHECKBOX_CLASSES %>">
17
+ <%= opt_label %>
18
+ </label>
19
+ <% end %>
20
+ </div>
21
+ </div>
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class MultiSelectComponent < ViewComponent::Base
6
+ WRAPPER_CLASSES = "relative inline-block"
7
+ TRIGGER_CLASSES = "inline-flex items-center gap-1 rounded-md border px-3 py-2 text-sm border-gray-300 bg-white text-gray-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-white"
8
+ MENU_CLASSES = "absolute z-10 mt-1 w-56 rounded-md border border-gray-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900 hidden"
9
+ OPTION_CLASSES = "flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-zinc-800 cursor-pointer"
10
+ CHECKBOX_CLASSES = "rounded border-gray-300 text-accent-600 focus:ring-accent-500 dark:border-zinc-600"
11
+ CARET_ICON = <<~SVG.freeze
12
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
13
+ <path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
14
+ </svg>
15
+ SVG
16
+
17
+ attr_reader :name, :label, :options, :selected
18
+
19
+ def initialize(name:, label:, options:, selected: [])
20
+ @name = name
21
+ @label = label
22
+ @options = options
23
+ @selected = Array(selected).map(&:to_s)
24
+ end
25
+
26
+ def selected?(value)
27
+ @selected.include?(value.to_s)
28
+ end
29
+
30
+ def display_text
31
+ checked = @selected.reject(&:empty?)
32
+ if checked.empty?
33
+ "All #{@label}"
34
+ else
35
+ "#{checked.length} selected"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ 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="<%= trigger_classes %>">
5
+ <%= @title %>
6
+ <%= CARET_ICON.html_safe %>
7
+ </button>
8
+ <nav data-dropdown-target="menu" class="<%= MENU_CLASSES %>">
9
+ <%= content %>
10
+ </nav>
11
+ </div>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class NavDropdownComponent < ViewComponent::Base
6
+ WRAPPER_CLASSES = "nav-dropdown"
7
+ MENU_CLASSES = "nav-dropdown-menu hidden"
8
+ TRIGGER_BASE = "nav-dropdown-trigger"
9
+ ACTIVE_CLASS = "active"
10
+ CARET_ICON = <<~SVG.freeze
11
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="nav-dropdown-caret">
12
+ <path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
13
+ </svg>
14
+ SVG
15
+
16
+ attr_reader :title, :area
17
+
18
+ def initialize(title:, area:, active: false)
19
+ @title = title
20
+ @area = area
21
+ @active = active
22
+ end
23
+
24
+ def trigger_classes
25
+ [ TRIGGER_BASE, (ACTIVE_CLASS if @active) ].compact.join(" ")
26
+ end
27
+
28
+ def wrapper_data
29
+ { controller: "dropdown" }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1 @@
1
+ <%= link_to @label, @href, class: link_classes %>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class NavItemComponent < ViewComponent::Base
6
+ attr_reader :label, :href
7
+
8
+ def initialize(label:, href:, active: false)
9
+ @label = label
10
+ @href = href
11
+ @active = active
12
+ end
13
+
14
+ ACTIVE_CLASS = "active"
15
+
16
+ def link_classes
17
+ @active ? ACTIVE_CLASS : ""
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ <nav class="<%= nav_classes %>">
2
+ <% if mobile_left? %>
3
+ <div class="<%= MOBILE_LEFT_CLASSES %>">
4
+ <%= mobile_left %>
5
+ </div>
6
+ <% end %>
7
+
8
+ <% if logo? %>
9
+ <div class="<%= LOGO_CLASSES %>">
10
+ <%= logo %>
11
+ </div>
12
+ <% end %>
13
+
14
+ <% if mobile_center? %>
15
+ <span class="<%= MOBILE_CENTER_CLASSES %>">
16
+ <%= mobile_center %>
17
+ </span>
18
+ <% end %>
19
+
20
+ <% if desktop_links? %>
21
+ <div class="<%= DESKTOP_LINKS_CLASSES %>">
22
+ <nav><%= desktop_links %></nav>
23
+ <% if desktop_right? %>
24
+ <nav><%= desktop_right %></nav>
25
+ <% end %>
26
+ </div>
27
+ <% end %>
28
+
29
+ <% if mobile_right? %>
30
+ <div class="<%= MOBILE_RIGHT_CLASSES %>">
31
+ <%= mobile_right %>
32
+ </div>
33
+ <% end %>
34
+ </nav>
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class NavbarComponent < ViewComponent::Base
6
+ NAV_BASE = "top-nav"
7
+ NAV_STICKY = "sticky top-0 z-40"
8
+ MOBILE_LEFT_CLASSES = "lg:hidden flex items-center"
9
+ LOGO_CLASSES = "logo"
10
+ MOBILE_CENTER_CLASSES = "absolute left-1/2 -translate-x-1/2 font-semibold text-gray-900 dark:text-white lg:hidden truncate max-w-[60%]"
11
+ DESKTOP_LINKS_CLASSES = "nav-container hidden lg:flex"
12
+ MOBILE_RIGHT_CLASSES = "nav-user-controls ml-auto"
13
+
14
+ renders_one :logo
15
+ renders_one :desktop_links
16
+ renders_one :desktop_right
17
+ renders_one :mobile_left
18
+ renders_one :mobile_center
19
+ renders_one :mobile_right
20
+
21
+ def initialize(sticky: true)
22
+ @sticky = sticky
23
+ end
24
+
25
+ def nav_classes
26
+ classes = [ NAV_BASE ]
27
+ classes << NAV_STICKY if @sticky
28
+ classes.join(" ")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,9 @@
1
+ <label class="relative cursor-pointer" <%= tag.attributes(label_data.transform_keys { |k| "data-#{k}" }) unless label_data.empty? %>>
2
+ <input type="radio" name="<%= name %>" value="<%= value %>"
3
+ <%= "checked" if selected? %>
4
+ class="sr-only"
5
+ <%= tag.attributes(input_data.transform_keys { |k| "data-#{k}" }) unless input_data.empty? %>>
6
+ <span class="<%= classes %>">
7
+ <%= content %>
8
+ </span>
9
+ </label>
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class OptionCardComponent < ViewComponent::Base
6
+ BASE_CLASSES = "inline-flex items-center gap-2 px-3 py-2 rounded-lg border-2 transition cursor-pointer"
7
+
8
+ attr_reader :name, :value, :input_data, :label_data
9
+
10
+ def initialize(name:, value:, selected: false, input_data: {}, label_data: {})
11
+ @name = name
12
+ @value = value
13
+ @selected = selected
14
+ @input_data = input_data
15
+ @label_data = label_data
16
+ end
17
+
18
+ def selected?
19
+ @selected
20
+ end
21
+
22
+ def classes
23
+ border = selected? ? "border-accent-500" : "border-transparent"
24
+ "#{BASE_CLASSES} #{border}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ <div class="<%= classes %>">
2
+ <%= content %>
3
+ </div>
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class PageComponent < ViewComponent::Base
6
+ MAX_WIDTH_CLASSES = {
7
+ sm: "max-w-2xl",
8
+ md: "max-w-4xl",
9
+ lg: "max-w-6xl",
10
+ xl: "max-w-7xl",
11
+ full: ""
12
+ }.freeze
13
+
14
+ PADDING_CLASSES = "px-4 py-4 sm:px-6 lg:px-8"
15
+
16
+ TOP_OFFSET_CLASSES = {
17
+ sm: "pt-12",
18
+ md: "pt-16",
19
+ lg: "pt-20",
20
+ xl: "pt-24"
21
+ }.freeze
22
+
23
+ def initialize(max_width: :full, padding: :standard, top_offset: nil)
24
+ @max_width = max_width
25
+ @padding = padding
26
+ @top_offset = top_offset
27
+ end
28
+
29
+ def classes
30
+ tokens = []
31
+ tokens << PADDING_CLASSES unless @padding == :none
32
+ tokens << TOP_OFFSET_CLASSES.fetch(@top_offset) if @top_offset
33
+ width_class = MAX_WIDTH_CLASSES.fetch(@max_width)
34
+ tokens << width_class unless width_class.empty?
35
+ tokens << "mx-auto" unless @max_width == :full
36
+ tokens.join(" ")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ <% if action_url %>
2
+ <% content_for :mobile_action_url, action_url %>
3
+ <% content_for :mobile_action_label, action_label %>
4
+ <% end %>
5
+ <div class="<%= WRAPPER_CLASSES %>">
6
+ <div>
7
+ <h1 class="<%= TITLE_CLASSES %>"><%= @title %></h1>
8
+ <% if subtitle? %>
9
+ <p class="<%= SUBTITLE_CLASSES %>"><%= subtitle_text %></p>
10
+ <% end %>
11
+ </div>
12
+ <% if action? %>
13
+ <div class="<%= ACTIONS_CLASSES %>">
14
+ <%= @action_block.call %>
15
+ </div>
16
+ <% end %>
17
+ </div>
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class PageHeaderComponent < ViewComponent::Base
6
+ WRAPPER_CLASSES = "hidden sm:block mb-6 sm:flex sm:items-center sm:justify-between"
7
+ TITLE_CLASSES = "text-2xl font-bold text-gray-900 dark:text-white"
8
+ SUBTITLE_CLASSES = "mt-1 text-sm text-gray-500 dark:text-gray-400"
9
+ ACTIONS_CLASSES = "page-header-actions hidden sm:block mt-4 sm:mt-0 sm:ml-4 flex-shrink-0"
10
+
11
+ attr_reader :title, :action_url, :action_label
12
+
13
+ def initialize(title:, subtitle: nil, action_url: nil, action_label: "Add new")
14
+ @title = title
15
+ @subtitle = subtitle
16
+ @action_url = action_url
17
+ @action_label = action_label
18
+ @action_block = nil
19
+ end
20
+
21
+ def before_render
22
+ content
23
+ end
24
+
25
+ def action(&block)
26
+ @action_block = block
27
+ end
28
+
29
+ def action?
30
+ !!@action_block
31
+ end
32
+
33
+ def subtitle?
34
+ !@subtitle.nil?
35
+ end
36
+
37
+ def subtitle_text
38
+ @subtitle
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ <div class="<%= classes %>">
2
+ <%= content %>
3
+ </div>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class PanelComponent < ViewComponent::Base
6
+ PADDING_CLASSES = { sm: "p-4", md: "p-5", lg: "p-6" }.freeze
7
+ RADIUS_CLASSES = { md: "rounded-lg", lg: "rounded-xl", xl: "rounded-2xl" }.freeze
8
+
9
+ def initialize(padding: :md, radius: :lg, shadow: true)
10
+ @padding = padding
11
+ @radius = radius
12
+ @shadow = shadow
13
+ end
14
+
15
+ BASE_CLASSES = "border border-gray-200 bg-white dark:bg-zinc-900 dark:border-zinc-700"
16
+ SHADOW_CLASS = "shadow-sm"
17
+
18
+ def classes
19
+ tokens = [ RADIUS_CLASSES.fetch(@radius), BASE_CLASSES, PADDING_CLASSES.fetch(@padding) ]
20
+ tokens << SHADOW_CLASS if @shadow
21
+ tokens.join(" ")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,34 @@
1
+ <div class="<%= CONTAINER_CLASSES %>">
2
+ <div class="<%= HEADER_CLASSES %>">
3
+ <h3 class="<%= TITLE_CLASSES %>"><%= title %></h3>
4
+ <% if subtitle %><p class="<%= SUBTITLE_CLASSES %>"><%= subtitle %></p><% end %>
5
+ </div>
6
+
7
+ <div class="<%= TRACK_CLASSES %>">
8
+ <% boxes.each_with_index do |box, i| %>
9
+ <div class="<%= BOX_CLASSES %>">
10
+ <span class="<%= BOX_LABEL_CLASSES %>"><%= box[:label] %></span>
11
+ <% unless box[:count].nil? %>
12
+ <span class="<%= count_class(box[:accent]) %>"><%= box[:count] %></span>
13
+ <% end %>
14
+ <% if box[:action] %>
15
+ <%= form_with url: box[:action][:url], method: :post do %>
16
+ <% box[:action].fetch(:params, {}).each do |k, v| %><%= hidden_field_tag k, v %><% end %>
17
+ <%= helpers.ui_button(label: box[:action][:label], type: :submit, variant: box[:action].fetch(:variant, :primary)) %>
18
+ <% end %>
19
+ <% end %>
20
+ </div>
21
+
22
+ <% if (link = link_after(i)) %>
23
+ <div class="<%= CONNECTOR_CLASSES %>">
24
+ <%= form_with url: link[:url], method: :post do %>
25
+ <% link.fetch(:params, {}).each do |k, v| %><%= hidden_field_tag k, v %><% end %>
26
+ <button type="submit" class="<%= link_classes(link) %>" title="<%= link[:broken] ? "Broken handoff — click to fix" : "Healthy handoff — click to break" %>">
27
+ <% if link[:broken] %>&#10007;<% else %>&#10003;<% end %>
28
+ </button>
29
+ <% end %>
30
+ </div>
31
+ <% end %>
32
+ <% end %>
33
+ </div>
34
+ </div>
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class PipelineComponent < ViewComponent::Base
6
+ CONTAINER_CLASSES = "rounded-xl border border-surface-700 bg-surface-800 p-6"
7
+ HEADER_CLASSES = "mb-4"
8
+ TITLE_CLASSES = "text-lg font-semibold text-white"
9
+ SUBTITLE_CLASSES = "mt-1 text-sm text-surface-400"
10
+ TRACK_CLASSES = "flex flex-col gap-3 sm:flex-row sm:items-stretch"
11
+ BOX_CLASSES = "flex flex-1 flex-col items-center gap-2 rounded-lg border border-surface-700 bg-surface-900 p-4 text-center"
12
+ BOX_LABEL_CLASSES = "text-xs uppercase tracking-wide text-surface-500"
13
+ CONNECTOR_CLASSES = "flex items-center justify-center"
14
+ COUNT_BASE_CLASSES = "text-3xl font-bold"
15
+ LINK_BASE_CLASSES = "link-toggle text-2xl leading-none"
16
+ LINK_HEALTHY_CLASSES = "text-accent-500"
17
+ LINK_BROKEN_CLASSES = "text-red-500"
18
+
19
+ COUNT_CLASSES = {
20
+ amber: "text-amber-400",
21
+ emerald: "text-accent-400",
22
+ danger: "text-red-400",
23
+ muted: "text-surface-500"
24
+ }.freeze
25
+
26
+ attr_reader :title, :boxes, :links, :subtitle
27
+
28
+ def initialize(title:, boxes:, links:, subtitle: nil)
29
+ @title = title
30
+ @boxes = boxes
31
+ @links = links
32
+ @subtitle = subtitle
33
+ end
34
+
35
+ def count_class(accent)
36
+ "#{COUNT_BASE_CLASSES} #{COUNT_CLASSES.fetch(accent, COUNT_CLASSES[:muted])}"
37
+ end
38
+
39
+ def link_after(index)
40
+ links[index]
41
+ end
42
+
43
+ def link_classes(link)
44
+ "#{LINK_BASE_CLASSES} #{link[:broken] ? LINK_BROKEN_CLASSES : LINK_HEALTHY_CLASSES}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,8 @@
1
+ <div>
2
+ <% if label %>
3
+ <div class="<%= label_classes %>"><%= label %></div>
4
+ <% end %>
5
+ <div class="<%= track_classes %>">
6
+ <div class="<%= bar_classes %>" style="width: <%= percent %>%"></div>
7
+ </div>
8
+ </div>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class ProgressComponent < ViewComponent::Base
6
+ TRACK_CLASSES = "w-full h-2 bg-surface-200 rounded-full overflow-hidden"
7
+ BAR_CLASSES = "h-full bg-accent-500 rounded-full transition-all"
8
+ LABEL_CLASSES = "mb-1 text-sm font-medium text-surface-700"
9
+
10
+ attr_reader :value, :max, :label
11
+
12
+ def initialize(value:, max:, label: nil)
13
+ @value = value
14
+ @max = max
15
+ @label = label
16
+ end
17
+
18
+ def percent
19
+ [ (value.to_f / max * 100).round, 100 ].min
20
+ end
21
+
22
+ def track_classes
23
+ TRACK_CLASSES
24
+ end
25
+
26
+ def bar_classes
27
+ BAR_CLASSES
28
+ end
29
+
30
+ def label_classes
31
+ LABEL_CLASSES
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ <label class="block cursor-pointer">
2
+ <input type="radio" name="<%= name %>" value="<%= value %>" <%= "checked" if checked? %> class="peer sr-only">
3
+ <span class="<%= classes %>">
4
+ <span class="<%= label_classes %>"><%= label %></span>
5
+ <% if hint? %>
6
+ <span class="<%= hint_classes %>"><%= hint %></span>
7
+ <% end %>
8
+ </span>
9
+ </label>