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,37 @@
1
+ <div class="relative inline-block" data-controller="<%= controller_name %>" data-color-picker-value-value="<%= value %>">
2
+ <% if label %>
3
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"><%= label %></label>
4
+ <% end %>
5
+
6
+ <div class="flex items-center gap-2">
7
+ <button type="button"
8
+ class="<%= swatch_classes %>"
9
+ style="background-color: <%= value %>;"
10
+ data-color-picker-target="swatch"
11
+ data-action="color-picker#toggle">
12
+ </button>
13
+ <span class="text-sm text-gray-500 dark:text-gray-400" data-color-picker-target="hexLabel"><%= value %></span>
14
+ </div>
15
+
16
+ <input type="hidden" name="<%= name %>" value="<%= value %>" data-color-picker-target="input">
17
+
18
+ <div class="<%= panel_classes %>" data-color-picker-target="panel">
19
+ <%# Saturation/Value gradient area %>
20
+ <div class="w-48 h-36 rounded cursor-crosshair relative mb-2"
21
+ data-color-picker-target="svArea"
22
+ data-action="pointerdown->color-picker#svDown">
23
+ <div class="absolute inset-0 rounded" style="background: linear-gradient(to right, #fff, hsl(0, 100%, 50%));" data-color-picker-target="svGradient"></div>
24
+ <div class="absolute inset-0 rounded" style="background: linear-gradient(to bottom, transparent, #000);"></div>
25
+ <div class="absolute w-3 h-3 rounded-full border-2 border-white shadow -translate-x-1/2 -translate-y-1/2 pointer-events-none"
26
+ data-color-picker-target="svCursor"
27
+ style="left: 100%; top: 0;">
28
+ </div>
29
+ </div>
30
+
31
+ <%# Hue slider %>
32
+ <input type="range" min="0" max="360" value="0"
33
+ class="w-48 keystone-hue-slider"
34
+ data-color-picker-target="hueSlider"
35
+ data-action="input->color-picker#hueChanged">
36
+ </div>
37
+ </div>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class ColorPickerComponent < ViewComponent::Base
6
+ SWATCH_CLASSES = "w-10 h-10 rounded-lg border border-gray-300 dark:border-zinc-600 cursor-pointer"
7
+
8
+ PANEL_CLASSES = "absolute z-50 mt-2 p-3 rounded-lg shadow-lg bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 hidden"
9
+
10
+ attr_reader :name, :value, :label
11
+
12
+ def initialize(name:, value: "#000000", label: nil)
13
+ @name = name
14
+ @value = value
15
+ @label = label
16
+ end
17
+
18
+ def controller_name
19
+ "color-picker"
20
+ end
21
+
22
+ def swatch_classes
23
+ SWATCH_CLASSES
24
+ end
25
+
26
+ def panel_classes
27
+ PANEL_CLASSES
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class Column
6
+ attr_reader :key, :header_text
7
+
8
+ def initialize(key, header_text, mobile_hidden: false, sortable: false, hideable: false)
9
+ @key = key
10
+ @header_text = header_text
11
+ @mobile_hidden = mobile_hidden
12
+ @sortable = sortable
13
+ @hideable = hideable
14
+ end
15
+
16
+ def mobile_hidden? = @mobile_hidden
17
+ def sortable? = @sortable
18
+ def hideable? = @hideable
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ <div class="<%= WRAPPER_CLASSES %>" data-controller="column-picker" <% if @save_url %>data-column-picker-save-url-value="<%= @save_url %>"<% end %>>
2
+ <button type="button"
3
+ data-action="click->column-picker#toggle"
4
+ class="<%= TRIGGER_CLASSES %>">
5
+ <%= COLUMNS_ICON.html_safe %>
6
+ Columns
7
+ </button>
8
+ <div data-column-picker-target="menu" class="<%= MENU_CLASSES %>">
9
+ <% hideable_columns.each do |col| %>
10
+ <label class="<%= OPTION_CLASSES %>">
11
+ <input type="checkbox"
12
+ value="<%= col.key %>"
13
+ <%= "checked" unless hidden?(col.key) %>
14
+ data-action="change->column-picker#save"
15
+ class="<%= CHECKBOX_CLASSES %>">
16
+ <%= col.header_text %>
17
+ </label>
18
+ <% end %>
19
+ </div>
20
+ </div>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class ColumnPickerComponent < 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 right-0 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
+
12
+ COLUMNS_ICON = <<~SVG.freeze
13
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
14
+ <path fill-rule="evenodd" d="M.99 5.24A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25l.01 9.5A2.25 2.25 0 0 1 16.76 17H3.26A2.25 2.25 0 0 1 1 14.75l-.01-9.5Zm8.26 9.52v-3.5l-2.25.01v3.5l2.25-.01Zm1.5 0 2.25.01v-3.5l-2.25-.01v3.5Zm-1.5-5v-3.5l-2.25.01v3.49l2.25.01Zm1.5-.01 2.25.01v-3.5l-2.25-.01v3.5Z" clip-rule="evenodd" />
15
+ </svg>
16
+ SVG
17
+
18
+ attr_reader :save_url
19
+
20
+ def initialize(columns:, hidden_columns: [], save_url: nil)
21
+ @columns = columns
22
+ @hidden_keys = Array(hidden_columns).map(&:to_sym).to_set
23
+ @save_url = save_url
24
+ end
25
+
26
+ def hideable_columns
27
+ @columns.select(&:hideable?)
28
+ end
29
+
30
+ def hidden?(key)
31
+ @hidden_keys.include?(key.to_sym)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ <button
2
+ type="button"
3
+ class="<%= classes %>"
4
+ data-controller="clipboard"
5
+ data-action="click->clipboard#copy"
6
+ data-clipboard-text="<%= text %>"
7
+ data-clipboard-success-message-value="<%= success_message %>"
8
+ data-clipboard-error-message-value="<%= error_message %>">
9
+ <%= copy_icon.html_safe %>
10
+ <%= label %>
11
+ </button>
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class CopyButtonComponent < ViewComponent::Base
6
+ BUTTON_CLASSES = "inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-gray-300 dark:hover:bg-zinc-700"
7
+
8
+ COPY_ICON = <<~SVG.freeze
9
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
10
+ SVG
11
+
12
+ attr_reader :text, :label, :success_message, :error_message
13
+
14
+ def initialize(text:, label: "Copy", success_message: "Copied!", error_message: "Failed!")
15
+ @text = text
16
+ @label = label
17
+ @success_message = success_message
18
+ @error_message = error_message
19
+ end
20
+
21
+ def classes
22
+ BUTTON_CLASSES
23
+ end
24
+
25
+ def copy_icon
26
+ COPY_ICON
27
+ end
28
+
29
+ def wrapper_data
30
+ {
31
+ controller: "clipboard",
32
+ "clipboard-text": @text,
33
+ "clipboard-success-message-value": @success_message,
34
+ "clipboard-error-message-value": @error_message
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ <div class="<%= classes %>">
2
+ <h2 class="<%= title_classes %>"><%= title %></h2>
3
+ <% if subtitle? %>
4
+ <p class="<%= subtitle_classes %>"><%= subtitle %></p>
5
+ <% end %>
6
+ <div class="<%= actions_classes %>">
7
+ <%= content %>
8
+ </div>
9
+ </div>
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class CtaBannerComponent < ViewComponent::Base
6
+ CARD_LAYOUT_CLASSES = "rounded-2xl border px-6 py-12 text-center lg:px-16 lg:py-16"
7
+ TITLE_BASE_CLASSES = "mb-4 text-3xl font-bold tracking-tight sm:text-4xl"
8
+ SUBTITLE_BASE_CLASSES = "mx-auto mb-8 max-w-2xl text-lg"
9
+ ACTIONS_CLASSES = "flex flex-wrap justify-center gap-4"
10
+
11
+ attr_reader :title, :subtitle
12
+
13
+ def initialize(title:, subtitle: nil)
14
+ @title = title
15
+ @subtitle = subtitle
16
+ end
17
+
18
+ def classes
19
+ "#{CARD_LAYOUT_CLASSES} border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800"
20
+ end
21
+
22
+ def title_classes
23
+ "#{TITLE_BASE_CLASSES} text-surface-900 dark:text-white"
24
+ end
25
+
26
+ def subtitle_classes
27
+ "#{SUBTITLE_BASE_CLASSES} text-surface-500 dark:text-surface-400"
28
+ end
29
+
30
+ def subtitle?
31
+ !@subtitle.nil?
32
+ end
33
+
34
+ def actions_classes
35
+ ACTIONS_CLASSES
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,54 @@
1
+ <div class="overflow-hidden rounded-lg border border-gray-200 shadow-sm dark:border-zinc-700 dark:shadow-none">
2
+ <table class="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
3
+ <thead class="bg-gray-50 dark:bg-gray-800/75">
4
+ <tr>
5
+ <% header_cells.each do |cell| %>
6
+ <th scope="<%= cell[:scope] %>" class="<%= cell[:classes] %>">
7
+ <% if cell[:sort_href] %>
8
+ <a href="<%= cell[:sort_href] %>" class="<%= Keystone::Ui::DataTableComponent::SORT_LINK_CLASSES %>" data-turbo-action="replace">
9
+ <%= cell[:label] %>
10
+ <% if cell[:sort_active] %>
11
+ <span class="<%= Keystone::Ui::DataTableComponent::SORT_ICON_CLASSES %> <%= Keystone::Ui::DataTableComponent::SORT_ICON_ACTIVE %>"><%= (cell[:sort_direction] == :asc ? Keystone::Ui::DataTableComponent::SORT_ASC_ICON : Keystone::Ui::DataTableComponent::SORT_DESC_ICON).html_safe %></span>
12
+ <% else %>
13
+ <span class="<%= Keystone::Ui::DataTableComponent::SORT_ICON_CLASSES %> <%= Keystone::Ui::DataTableComponent::SORT_ICON_INACTIVE %>"><%= Keystone::Ui::DataTableComponent::SORT_NEUTRAL_ICON.html_safe %></span>
14
+ <% end %>
15
+ </a>
16
+ <% else %>
17
+ <%= cell[:label] %>
18
+ <% end %>
19
+ </th>
20
+ <% end %>
21
+ </tr>
22
+ </thead>
23
+ <tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-800/50">
24
+ <% if empty? %>
25
+ <% if @empty_message %>
26
+ <tr>
27
+ <td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400" colspan="<%= column_count %>">
28
+ <%= @empty_message %>
29
+ </td>
30
+ </tr>
31
+ <% end %>
32
+ <% else %>
33
+ <% @items.each_with_index do |item, idx| %>
34
+ <tr>
35
+ <% row_cells[idx].each do |cell| %>
36
+ <td class="<%= cell[:classes] %>">
37
+ <% if cell[:href] %>
38
+ <a href="<%= cell[:href] %>"><%= cell[:value] %></a>
39
+ <% else %>
40
+ <%= cell[:value] %>
41
+ <% end %>
42
+ </td>
43
+ <% end %>
44
+ <% if actions? %>
45
+ <td class="<%= Keystone::Ui::DataTableComponent::ROW_CLASSES_LAST %>">
46
+ <%= capture(item, &@actions_block) %>
47
+ </td>
48
+ <% end %>
49
+ </tr>
50
+ <% end %>
51
+ <% end %>
52
+ </tbody>
53
+ </table>
54
+ </div>
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class DataTableComponent < ViewComponent::Base
6
+ HEADER_CLASSES_FIRST = "py-3.5 pr-3 pl-6 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
7
+ HEADER_CLASSES_MIDDLE = "px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
8
+ HEADER_CLASSES_LAST = "py-3.5 pr-6 pl-3"
9
+
10
+ ROW_CLASSES_FIRST = "py-4 pr-3 pl-6 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white"
11
+ ROW_CLASSES_MIDDLE = "px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400"
12
+ ROW_CLASSES_LAST = "py-4 pr-6 pl-3 text-right text-sm font-medium whitespace-nowrap"
13
+
14
+ MOBILE_HIDDEN_CLASSES = "hidden sm:table-cell"
15
+
16
+ SORT_LINK_CLASSES = "group inline-flex items-center gap-1"
17
+ SORT_ICON_CLASSES = "h-4 w-4 flex-shrink-0"
18
+ SORT_ICON_ACTIVE = "text-gray-700 dark:text-gray-300"
19
+ SORT_ICON_INACTIVE = "text-gray-400 dark:text-gray-500 invisible group-hover:visible"
20
+
21
+ SORT_ASC_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z" clip-rule="evenodd" /></svg>'
22
+ SORT_DESC_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a.75.75 0 0 1 .75.75v10.638l3.96-4.158a.75.75 0 1 1 1.08 1.04l-5.25 5.5a.75.75 0 0 1-1.08 0l-5.25-5.5a.75.75 0 0 1 1.08-1.04l3.96 4.158V3.75A.75.75 0 0 1 10 3Z" clip-rule="evenodd" /></svg>'
23
+ SORT_NEUTRAL_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a.75.75 0 0 1 .55.24l3.25 3.5a.75.75 0 1 1-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 0 1-1.1-1.02l3.25-3.5A.75.75 0 0 1 10 3Zm-3.76 9.2a.75.75 0 0 1 1.06.04l2.7 2.908 2.7-2.908a.75.75 0 1 1 1.1 1.02l-3.25 3.5a.75.75 0 0 1-1.1 0l-3.25-3.5a.75.75 0 0 1 .04-1.06Z" clip-rule="evenodd" /></svg>'
24
+
25
+ def initialize(items:, columns:, empty_message: nil, sort: nil, sort_direction: nil, sort_url: nil, hidden_columns: [])
26
+ @items = items
27
+ all_columns = columns.map { |col| normalize_column(col) }
28
+ hidden_keys = Array(hidden_columns).map(&:to_sym).to_set
29
+ @columns = all_columns.reject { |col| col.hideable? && hidden_keys.include?(col.key) }
30
+ @empty_message = empty_message
31
+ @sort = sort&.to_sym
32
+ @sort_direction = sort_direction&.to_sym
33
+ @sort_url = sort_url
34
+ @actions_block = nil
35
+ @link_blocks = {}
36
+ end
37
+
38
+ def before_render
39
+ content
40
+ end
41
+
42
+ def link(column_key, &block)
43
+ @link_blocks[column_key] = block
44
+ end
45
+
46
+ def actions(&block)
47
+ @actions_block = block
48
+ end
49
+
50
+ def actions?
51
+ !!@actions_block
52
+ end
53
+
54
+ def column_keys
55
+ @column_keys ||= @columns.map(&:key)
56
+ end
57
+
58
+ def column_labels
59
+ @column_labels ||= @columns.map(&:header_text)
60
+ end
61
+
62
+ def header_cells
63
+ cells = @columns.map.with_index do |column, index|
64
+ tokens = [ header_classes_for(index) ]
65
+ tokens << MOBILE_HIDDEN_CLASSES if column.mobile_hidden?
66
+
67
+ cell = {
68
+ label: column.header_text,
69
+ classes: tokens.join(" "),
70
+ scope: "col"
71
+ }
72
+
73
+ if @sort_url
74
+ active = column.sortable? && @sort == column.key
75
+ cell[:sortable] = column.sortable?
76
+
77
+ if column.sortable?
78
+ if active
79
+ cell[:sort_active] = true
80
+ cell[:sort_direction] = @sort_direction
81
+ next_dir = @sort_direction == :asc ? :desc : :asc
82
+ else
83
+ cell[:sort_active] = false
84
+ next_dir = :asc
85
+ end
86
+ cell[:sort_href] = @sort_url.call(column.key, next_dir)
87
+ end
88
+ end
89
+
90
+ cell
91
+ end
92
+
93
+ if actions?
94
+ cells << {
95
+ label: "Actions",
96
+ classes: HEADER_CLASSES_LAST,
97
+ scope: "col"
98
+ }
99
+ end
100
+
101
+ cells
102
+ end
103
+
104
+ def row_cells
105
+ @row_cells ||= @items.map do |item|
106
+ @columns.map.with_index do |column, index|
107
+ tokens = [ row_classes_for(index) ]
108
+ tokens << MOBILE_HIDDEN_CLASSES if column.mobile_hidden?
109
+
110
+ cell = {
111
+ value: resolve_value(item, column.key),
112
+ classes: tokens.join(" ")
113
+ }
114
+
115
+ link_block = @link_blocks[column.key]
116
+ cell[:href] = link_block.call(item) if link_block
117
+
118
+ cell
119
+ end
120
+ end
121
+ end
122
+
123
+ def empty?
124
+ @items.empty?
125
+ end
126
+
127
+ def column_count
128
+ visual_column_count
129
+ end
130
+
131
+ private
132
+
133
+ def visual_column_count
134
+ @columns.length + (actions? ? 1 : 0)
135
+ end
136
+
137
+ def normalize_column(col)
138
+ case col
139
+ when Column then col
140
+ when Hash
141
+ key, label = col.first
142
+ Column.new(key, label)
143
+ end
144
+ end
145
+
146
+ def resolve_value(item, key)
147
+ if item.respond_to?(key)
148
+ item.public_send(key)
149
+ else
150
+ item[key]
151
+ end
152
+ end
153
+
154
+ def header_classes_for(index)
155
+ last_data_index = @columns.length - 1
156
+
157
+ return HEADER_CLASSES_FIRST if index.zero?
158
+ return HEADER_CLASSES_LAST if index == last_data_index && !actions?
159
+
160
+ HEADER_CLASSES_MIDDLE
161
+ end
162
+
163
+ def row_classes_for(index)
164
+ last_data_index = @columns.length - 1
165
+
166
+ return ROW_CLASSES_FIRST if index.zero?
167
+ return ROW_CLASSES_LAST if index == last_data_index && !actions?
168
+
169
+ ROW_CLASSES_MIDDLE
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,9 @@
1
+ <details class="<%= wrapper_classes %>"<% if open? %> open<% end %>>
2
+ <summary class="<%= summary_classes %>">
3
+ <span><%= summary %></span>
4
+ <span class="<%= icon_classes %>"><%= caret_icon.html_safe %></span>
5
+ </summary>
6
+ <div class="<%= body_classes %>">
7
+ <%= content %>
8
+ </div>
9
+ </details>
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class DisclosureComponent < ViewComponent::Base
6
+ WRAPPER_CLASSES = "group rounded-xl border border-surface-200 dark:border-surface-700"
7
+ SUMMARY_CLASSES = "flex cursor-pointer list-none items-center justify-between gap-4 px-6 py-4 font-semibold text-surface-900 [&::-webkit-details-marker]:hidden dark:text-white"
8
+ ICON_CLASSES = "h-4 w-4 shrink-0 text-surface-400 transition-transform group-open:rotate-180"
9
+ BODY_CLASSES = "px-6 pb-4 text-sm text-surface-600 dark:text-surface-400"
10
+
11
+ CARET_ICON = <<~SVG.freeze
12
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
13
+ SVG
14
+
15
+ renders_one :summary
16
+
17
+ def initialize(open: false)
18
+ @open = open
19
+ end
20
+
21
+ def open?
22
+ @open
23
+ end
24
+
25
+ def wrapper_classes
26
+ WRAPPER_CLASSES
27
+ end
28
+
29
+ def summary_classes
30
+ SUMMARY_CLASSES
31
+ end
32
+
33
+ def icon_classes
34
+ ICON_CLASSES
35
+ end
36
+
37
+ def body_classes
38
+ BODY_CLASSES
39
+ end
40
+
41
+ def caret_icon
42
+ CARET_ICON
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ <div>
2
+ <h2 class="<%= title_classes %>"><%= title %></h2>
3
+ <% if subtitle? %>
4
+ <p class="<%= subtitle_classes %>"><%= subtitle %></p>
5
+ <% end %>
6
+ <div class="<%= classes %>">
7
+ <% features.each do |feature| %>
8
+ <div class="<%= card_classes %>">
9
+ <div class="<%= icon_classes %>">
10
+ <%= feature[:icon].html_safe %>
11
+ </div>
12
+ <h3 class="<%= card_title_classes %>"><%= feature[:title] %></h3>
13
+ <p class="<%= card_description_classes %>"><%= feature[:description] %></p>
14
+ </div>
15
+ <% end %>
16
+ </div>
17
+ </div>
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class FeatureGridComponent < ViewComponent::Base
6
+ GRID_CLASSES = "grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
7
+ TITLE_BASE_CLASSES = "mb-4 text-center text-3xl font-bold tracking-tight sm:text-4xl"
8
+ SUBTITLE_BASE_CLASSES = "mx-auto mb-16 max-w-2xl text-center text-lg"
9
+ CARD_LAYOUT_CLASSES = "rounded-xl border p-6 transition"
10
+ ICON_BASE_CLASSES = "mb-4 flex size-10 items-center justify-center rounded-lg text-lg"
11
+ CARD_TITLE_BASE_CLASSES = "mb-2 text-lg font-semibold"
12
+ CARD_DESCRIPTION_BASE_CLASSES = "text-sm"
13
+
14
+ attr_reader :title, :subtitle, :features
15
+
16
+ def initialize(title:, features:, subtitle: nil)
17
+ @title = title
18
+ @subtitle = subtitle
19
+ @features = features
20
+ end
21
+
22
+ def classes
23
+ GRID_CLASSES
24
+ end
25
+
26
+ def title_classes
27
+ "#{TITLE_BASE_CLASSES} text-surface-900 dark:text-white"
28
+ end
29
+
30
+ def subtitle_classes
31
+ "#{SUBTITLE_BASE_CLASSES} text-surface-500 dark:text-surface-400"
32
+ end
33
+
34
+ def subtitle?
35
+ !@subtitle.nil?
36
+ end
37
+
38
+ def card_classes
39
+ "#{CARD_LAYOUT_CLASSES} border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 hover:border-accent-500/50 dark:hover:border-accent-500/50"
40
+ end
41
+
42
+ def icon_classes
43
+ "#{ICON_BASE_CLASSES} bg-accent-500/10 text-accent-600 dark:text-accent-400"
44
+ end
45
+
46
+ def card_title_classes
47
+ "#{CARD_TITLE_BASE_CLASSES} text-surface-900 dark:text-white"
48
+ end
49
+
50
+ def card_description_classes
51
+ "#{CARD_DESCRIPTION_BASE_CLASSES} text-surface-500 dark:text-surface-400"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,18 @@
1
+ <div class="<%= WRAPPER_CLASSES %>" data-controller="file-upload">
2
+ <span class="<%= LABEL_CLASSES %>"><%= label_text %></span>
3
+
4
+ <label class="<%= DROP_ZONE_CLASSES %>" data-file-upload-target="dropZone">
5
+ <div class="<%= DROP_ZONE_INNER_CLASSES %>">
6
+ <%= UPLOAD_ICON.html_safe %>
7
+ <p class="<%= PROMPT_CLASSES %>">
8
+ <%= prompt_text %> <span class="<%= BROWSE_CLASSES %>">browse</span>
9
+ </p>
10
+ <p class="<%= FILE_NAME_CLASSES %>" data-file-upload-target="fileName"></p>
11
+ </div>
12
+ <%= tag.input(**tag_options) %>
13
+ </label>
14
+
15
+ <% if hint? %>
16
+ <p class="<%= HINT_CLASSES %>"><%= hint_text %></p>
17
+ <% end %>
18
+ </div>