senren-ui 0.1.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 (182) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/CONTRIBUTING.md +63 -0
  4. data/LICENSE +21 -0
  5. data/README.md +135 -0
  6. data/Rakefile +22 -0
  7. data/docs/visual_style.md +51 -0
  8. data/lib/generators/senren/component/component_generator.rb +62 -0
  9. data/lib/generators/senren/component/templates/component.html.erb.tt +3 -0
  10. data/lib/generators/senren/component/templates/component.rb.tt +13 -0
  11. data/lib/generators/senren/component/templates/component_test.rb.tt +16 -0
  12. data/lib/generators/senren/component/templates/controller.js.tt +23 -0
  13. data/lib/generators/senren/component/templates/system_test.rb.tt +7 -0
  14. data/lib/generators/senren/install/install_generator.rb +67 -0
  15. data/lib/generators/senren/install/templates/base_component.rb.tt +45 -0
  16. data/lib/generators/senren/install/templates/conventions.md.tt +66 -0
  17. data/lib/generators/senren/install/templates/installed_components.yml.tt +4 -0
  18. data/lib/generators/senren/install/templates/senren.css.tt +164 -0
  19. data/lib/senren/rails/component_copier.rb +111 -0
  20. data/lib/senren/rails/doctor.rb +86 -0
  21. data/lib/senren/rails/engine.rb +16 -0
  22. data/lib/senren/rails/host_paths.rb +36 -0
  23. data/lib/senren/rails/installer.rb +83 -0
  24. data/lib/senren/rails/llms_writer.rb +149 -0
  25. data/lib/senren/rails/registry.rb +161 -0
  26. data/lib/senren/rails/skill_writer.rb +166 -0
  27. data/lib/senren/rails/version.rb +7 -0
  28. data/lib/senren/rails.rb +39 -0
  29. data/lib/tasks/senren.rake +74 -0
  30. data/registry/components.yml +1053 -0
  31. data/registry/groups.yml +25 -0
  32. data/registry/recipes.yml +79 -0
  33. data/templates/components/accordion/accordion_component.html.erb +16 -0
  34. data/templates/components/accordion/accordion_component.rb +31 -0
  35. data/templates/components/activity_feed/activity_feed_component.html.erb +22 -0
  36. data/templates/components/activity_feed/activity_feed_component.rb +19 -0
  37. data/templates/components/alert/alert_component.html.erb +9 -0
  38. data/templates/components/alert/alert_component.rb +18 -0
  39. data/templates/components/alert_dialog/alert_dialog_component.html.erb +34 -0
  40. data/templates/components/alert_dialog/alert_dialog_component.rb +21 -0
  41. data/templates/components/api_key_field/api_key_field_component.html.erb +13 -0
  42. data/templates/components/api_key_field/api_key_field_component.rb +20 -0
  43. data/templates/components/app_shell/app_shell_component.html.erb +28 -0
  44. data/templates/components/app_shell/app_shell_component.rb +24 -0
  45. data/templates/components/aspect_ratio/aspect_ratio_component.html.erb +3 -0
  46. data/templates/components/aspect_ratio/aspect_ratio_component.rb +14 -0
  47. data/templates/components/avatar/avatar_component.html.erb +27 -0
  48. data/templates/components/avatar/avatar_component.rb +30 -0
  49. data/templates/components/badge/badge_component.html.erb +1 -0
  50. data/templates/components/badge/badge_component.rb +16 -0
  51. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +28 -0
  52. data/templates/components/billing_plan_card/billing_plan_card_component.rb +27 -0
  53. data/templates/components/breadcrumb/breadcrumb_component.html.erb +23 -0
  54. data/templates/components/breadcrumb/breadcrumb_component.rb +30 -0
  55. data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +12 -0
  56. data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +24 -0
  57. data/templates/components/button/button_component.html.erb +6 -0
  58. data/templates/components/button/button_component.rb +29 -0
  59. data/templates/components/calendar/calendar_component.html.erb +21 -0
  60. data/templates/components/calendar/calendar_component.rb +30 -0
  61. data/templates/components/card/card_component.html.erb +13 -0
  62. data/templates/components/card/card_component.rb +17 -0
  63. data/templates/components/carousel/carousel_component.html.erb +68 -0
  64. data/templates/components/carousel/carousel_component.rb +34 -0
  65. data/templates/components/checkbox/checkbox_component.html.erb +8 -0
  66. data/templates/components/checkbox/checkbox_component.rb +19 -0
  67. data/templates/components/checkbox_group/checkbox_group_component.html.erb +10 -0
  68. data/templates/components/checkbox_group/checkbox_group_component.rb +30 -0
  69. data/templates/components/clipboard/clipboard_component.html.erb +7 -0
  70. data/templates/components/clipboard/clipboard_component.rb +17 -0
  71. data/templates/components/codeblock/codeblock_component.html.erb +11 -0
  72. data/templates/components/codeblock/codeblock_component.rb +31 -0
  73. data/templates/components/collapsible/collapsible_component.html.erb +9 -0
  74. data/templates/components/collapsible/collapsible_component.rb +19 -0
  75. data/templates/components/combobox/combobox_component.html.erb +19 -0
  76. data/templates/components/combobox/combobox_component.rb +38 -0
  77. data/templates/components/command/command_component.html.erb +22 -0
  78. data/templates/components/command/command_component.rb +38 -0
  79. data/templates/components/context_menu/context_menu_component.html.erb +11 -0
  80. data/templates/components/context_menu/context_menu_component.rb +11 -0
  81. data/templates/components/data_table/data_table_component.html.erb +50 -0
  82. data/templates/components/data_table/data_table_component.rb +42 -0
  83. data/templates/components/date_picker/date_picker_component.html.erb +5 -0
  84. data/templates/components/date_picker/date_picker_component.rb +21 -0
  85. data/templates/components/dialog/dialog_component.html.erb +38 -0
  86. data/templates/components/dialog/dialog_component.rb +22 -0
  87. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +12 -0
  88. data/templates/components/dropdown_menu/dropdown_menu_component.rb +36 -0
  89. data/templates/components/empty_state/empty_state_component.html.erb +18 -0
  90. data/templates/components/empty_state/empty_state_component.rb +22 -0
  91. data/templates/components/filter_bar/filter_bar_component.html.erb +5 -0
  92. data/templates/components/filter_bar/filter_bar_component.rb +15 -0
  93. data/templates/components/form/form_component.html.erb +3 -0
  94. data/templates/components/form/form_component.rb +18 -0
  95. data/templates/components/hover_card/hover_card_component.html.erb +10 -0
  96. data/templates/components/hover_card/hover_card_component.rb +11 -0
  97. data/templates/components/input/input_component.html.erb +1 -0
  98. data/templates/components/input/input_component.rb +28 -0
  99. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +35 -0
  100. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +26 -0
  101. data/templates/components/label/label_component.html.erb +4 -0
  102. data/templates/components/label/label_component.rb +19 -0
  103. data/templates/components/link/link_component.html.erb +1 -0
  104. data/templates/components/link/link_component.rb +25 -0
  105. data/templates/components/masked_input/masked_input_component.html.erb +1 -0
  106. data/templates/components/masked_input/masked_input_component.rb +18 -0
  107. data/templates/components/native_select/native_select_component.html.erb +14 -0
  108. data/templates/components/native_select/native_select_component.rb +52 -0
  109. data/templates/components/page_header/page_header_component.html.erb +20 -0
  110. data/templates/components/page_header/page_header_component.rb +19 -0
  111. data/templates/components/pagination/pagination_component.html.erb +11 -0
  112. data/templates/components/pagination/pagination_component.rb +24 -0
  113. data/templates/components/popover/popover_component.html.erb +9 -0
  114. data/templates/components/popover/popover_component.rb +11 -0
  115. data/templates/components/progress/progress_component.html.erb +11 -0
  116. data/templates/components/progress/progress_component.rb +26 -0
  117. data/templates/components/radio_button/radio_button_component.html.erb +8 -0
  118. data/templates/components/radio_button/radio_button_component.rb +19 -0
  119. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +32 -0
  120. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +30 -0
  121. data/templates/components/search_input/search_input_component.html.erb +14 -0
  122. data/templates/components/search_input/search_input_component.rb +18 -0
  123. data/templates/components/select/select_component.html.erb +1 -0
  124. data/templates/components/select/select_component.rb +19 -0
  125. data/templates/components/separator/separator_component.html.erb +1 -0
  126. data/templates/components/separator/separator_component.rb +12 -0
  127. data/templates/components/settings_section/settings_section_component.html.erb +20 -0
  128. data/templates/components/settings_section/settings_section_component.rb +18 -0
  129. data/templates/components/sheet/sheet_component.html.erb +37 -0
  130. data/templates/components/sheet/sheet_component.rb +27 -0
  131. data/templates/components/shortcut_key/shortcut_key_component.html.erb +6 -0
  132. data/templates/components/shortcut_key/shortcut_key_component.rb +15 -0
  133. data/templates/components/sidebar/sidebar_component.html.erb +14 -0
  134. data/templates/components/sidebar/sidebar_component.rb +37 -0
  135. data/templates/components/skeleton/skeleton_component.html.erb +1 -0
  136. data/templates/components/skeleton/skeleton_component.rb +13 -0
  137. data/templates/components/stat_card/stat_card_component.html.erb +20 -0
  138. data/templates/components/stat_card/stat_card_component.rb +31 -0
  139. data/templates/components/switch/switch_component.html.erb +11 -0
  140. data/templates/components/switch/switch_component.rb +19 -0
  141. data/templates/components/table/table_component.html.erb +26 -0
  142. data/templates/components/table/table_component.rb +35 -0
  143. data/templates/components/tabs/tabs_component.html.erb +18 -0
  144. data/templates/components/tabs/tabs_component.rb +35 -0
  145. data/templates/components/team_member_list/team_member_list_component.html.erb +22 -0
  146. data/templates/components/team_member_list/team_member_list_component.rb +26 -0
  147. data/templates/components/textarea/textarea_component.html.erb +1 -0
  148. data/templates/components/textarea/textarea_component.rb +23 -0
  149. data/templates/components/theme_toggle/theme_toggle_component.html.erb +4 -0
  150. data/templates/components/theme_toggle/theme_toggle_component.rb +15 -0
  151. data/templates/components/tooltip/tooltip_component.html.erb +9 -0
  152. data/templates/components/tooltip/tooltip_component.rb +16 -0
  153. data/templates/components/top_nav/top_nav_component.html.erb +21 -0
  154. data/templates/components/top_nav/top_nav_component.rb +44 -0
  155. data/templates/components/typography/typography_component.html.erb +1 -0
  156. data/templates/components/typography/typography_component.rb +24 -0
  157. data/templates/controllers/accordion_controller.js +27 -0
  158. data/templates/controllers/alert_dialog_controller.js +38 -0
  159. data/templates/controllers/api_key_field_controller.js +36 -0
  160. data/templates/controllers/calendar_controller.js +16 -0
  161. data/templates/controllers/carousel_controller.js +50 -0
  162. data/templates/controllers/clipboard_controller.js +17 -0
  163. data/templates/controllers/collapsible_controller.js +13 -0
  164. data/templates/controllers/combobox_controller.js +64 -0
  165. data/templates/controllers/command_controller.js +80 -0
  166. data/templates/controllers/context_menu_controller.js +36 -0
  167. data/templates/controllers/data_table_controller.js +34 -0
  168. data/templates/controllers/date_picker_controller.js +17 -0
  169. data/templates/controllers/dialog_controller.js +50 -0
  170. data/templates/controllers/dropdown_menu_controller.js +92 -0
  171. data/templates/controllers/hover_card_controller.js +17 -0
  172. data/templates/controllers/invite_member_dialog_controller.js +28 -0
  173. data/templates/controllers/masked_input_controller.js +30 -0
  174. data/templates/controllers/popover_controller.js +42 -0
  175. data/templates/controllers/rich_text_editor_lite_controller.js +443 -0
  176. data/templates/controllers/select_controller.js +10 -0
  177. data/templates/controllers/sheet_controller.js +34 -0
  178. data/templates/controllers/sidebar_controller.js +10 -0
  179. data/templates/controllers/tabs_controller.js +41 -0
  180. data/templates/controllers/theme_toggle_controller.js +24 -0
  181. data/templates/controllers/tooltip_controller.js +10 -0
  182. metadata +257 -0
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class PopoverComponent < BaseComponent
5
+ renders_one :trigger
6
+ renders_one :content_panel
7
+
8
+ VARIANTS = { default: '' }.freeze
9
+ SIZES = { md: '' }.freeze
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ <div <%= tag.attributes(**root_attrs("w-full")) %>>
2
+ <% if label.present? %>
3
+ <div class="mb-2 flex items-center justify-between text-sm">
4
+ <span class="font-medium text-[hsl(var(--senren-foreground))]"><%= label %></span>
5
+ <span class="text-[hsl(var(--senren-muted-foreground))]"><%= percent %>%</span>
6
+ </div>
7
+ <% end %>
8
+ <div role="progressbar" aria-valuemin="0" aria-valuemax="<%= max.to_i %>" aria-valuenow="<%= value.to_i %>" class="h-2 w-full overflow-hidden rounded-full bg-[hsl(var(--senren-muted))]">
9
+ <div class="h-full rounded-full transition-all <%= self.class::VARIANTS[variant] %>" style="width: <%= percent %>%"></div>
10
+ </div>
11
+ </div>
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class ProgressComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: 'bg-[hsl(var(--senren-primary))]',
7
+ success: 'bg-[hsl(var(--senren-success))]',
8
+ warning: 'bg-[hsl(var(--senren-warning))]',
9
+ destructive: 'bg-[hsl(var(--senren-destructive))]'
10
+ }.freeze
11
+ SIZES = { md: '' }.freeze
12
+
13
+ def initialize(value: 0, max: 100, label: nil, variant: :default, class_name: nil, **html)
14
+ super(variant: variant, size: :md, class_name: class_name, **html)
15
+ @value = value.to_f
16
+ @max = max.to_f.positive? ? max.to_f : 100.0
17
+ @label = label
18
+ end
19
+
20
+ attr_reader :value, :max, :label
21
+
22
+ def percent
23
+ ((value / max) * 100).clamp(0, 100).round
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,8 @@
1
+ <label class="inline-flex items-center gap-2 cursor-pointer">
2
+ <%= tag.input(**root_attrs("h-4 w-4 border border-[hsl(var(--senren-input))] text-[hsl(var(--senren-primary))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]", type: "radio", id: id, name: name, value: value, checked: checked)) %>
3
+ <% if label %>
4
+ <span class="text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= label %></span>
5
+ <% else %>
6
+ <%= content %>
7
+ <% end %>
8
+ </label>
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class RadioButtonComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(name:, value:, checked: false, id: nil, label: nil, class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @name = name
11
+ @value = value
12
+ @checked = checked
13
+ @id = id || "#{name.to_s.parameterize}-#{value.to_s.parameterize}"
14
+ @label = label
15
+ end
16
+
17
+ attr_reader :name, :value, :checked, :id, :label
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ <%= tag.div(**root_attrs("overflow-hidden rounded-(--senren-radius) border shadow-sm", data: { controller: "senren--rich-text-editor-lite", "senren--rich-text-editor-lite-debug-value": debug? })) do %>
2
+ <label for="<%= dom_id %>-editor" class="sr-only"><%= label %></label>
3
+ <textarea hidden name="<%= name %>" data-senren--rich-text-editor-lite-target="input"><%= initial_content %></textarea>
4
+
5
+ <% if toolbar? %>
6
+ <div class="flex flex-wrap items-center gap-1 border-b border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.35)] p-2">
7
+ <% [
8
+ ["formatBlock:p", "P", "Paragraph"],
9
+ ["formatBlock:h1", "H1", "Heading 1"],
10
+ ["formatBlock:h2", "H2", "Heading 2"],
11
+ ["formatBlock:h3", "H3", "Heading 3"],
12
+ ["bold", "B", "Bold"],
13
+ ["italic", "I", "Italic"],
14
+ ["createLink", "Link", "Create link"],
15
+ ["insertUnorderedList", "List", "Bulleted list"],
16
+ ["insertOrderedList", "1.", "Numbered list"],
17
+ ["align:left", "Left", "Align left"],
18
+ ["align:center", "Center", "Align center"],
19
+ ["align:right", "Right", "Align right"],
20
+ ["align:justify", "Justify", "Justify"]
21
+ ].each do |command, label_text, aria_label| %>
22
+ <button type="button" disabled class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm font-medium text-[hsl(var(--senren-foreground))] hover:bg-[hsl(var(--senren-accent))] disabled:cursor-not-allowed disabled:opacity-50 aria-pressed:bg-[hsl(var(--senren-accent))]" data-senren--rich-text-editor-lite-target="button" data-command="<%= command %>" data-action="pointerdown->senren--rich-text-editor-lite#keepSelection mousedown->senren--rich-text-editor-lite#keepSelection touchstart->senren--rich-text-editor-lite#keepSelection click->senren--rich-text-editor-lite#format" aria-label="<%= aria_label %>" aria-pressed="false">
23
+ <%= label_text %>
24
+ </button>
25
+ <% end %>
26
+ </div>
27
+ <% end %>
28
+
29
+ <div id="<%= dom_id %>-editor" role="textbox" aria-multiline="true" aria-label="<%= label %>" contenteditable="true" data-placeholder="<%= placeholder %>" data-senren--rich-text-editor-lite-target="editor" data-action="click->senren--rich-text-editor-lite#openLink input->senren--rich-text-editor-lite#sync input->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#updateToolbar mouseup->senren--rich-text-editor-lite#rememberSelection mouseup->senren--rich-text-editor-lite#updateToolbar touchend->senren--rich-text-editor-lite#rememberSelection paste->senren--rich-text-editor-lite#syncSoon" class="min-h-40 cursor-text bg-[hsl(var(--senren-background))] p-4 text-left text-sm leading-6 text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-inset focus:ring-[hsl(var(--senren-ring))]">
30
+ <%= sanitize(initial_content, tags: %w[p br strong b em i a ul ol li h1 h2 h3], attributes: %w[href rel target data-align]) %>
31
+ </div>
32
+ <% end %>
@@ -0,0 +1,30 @@
1
+ module Senren
2
+ class RichTextEditorLiteComponent < BaseComponent
3
+ VARIANTS = {
4
+ default: 'border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))]'
5
+ }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(name: 'content', value: nil, label: 'Content', placeholder: 'Write something...', id: nil,
9
+ toolbar: true, debug: ::Rails.env.development?, class_name: nil, **html)
10
+ super(variant: :default, size: :md, class_name: class_name, **html)
11
+ @name = name
12
+ @value = value
13
+ @label = label
14
+ @placeholder = placeholder
15
+ @dom_id = id || "senren-editor-#{SecureRandom.hex(3)}"
16
+ @toolbar = toolbar
17
+ @debug = debug
18
+ end
19
+
20
+ attr_reader :name, :value, :label, :placeholder, :dom_id
21
+
22
+ def toolbar? = !!@toolbar
23
+
24
+ def debug? = !!@debug
25
+
26
+ def initial_content
27
+ value.presence || content.to_s.presence || "<p>#{ERB::Util.html_escape(placeholder)}</p>"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,14 @@
1
+ <%= tag.div(**root_attrs("relative")) do %>
2
+ <% if content? %>
3
+ <%= content %>
4
+ <% else %>
5
+ <label class="sr-only" for="<%= name %>"><%= label %></label>
6
+ <span aria-hidden="true" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))]">
7
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
8
+ <circle cx="11" cy="11" r="7"></circle>
9
+ <path d="m20 20-3.5-3.5"></path>
10
+ </svg>
11
+ </span>
12
+ <input id="<%= name %>" name="<%= name %>" type="search" value="<%= value %>" placeholder="<%= placeholder %>" aria-label="<%= label %>" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-9 text-sm text-[hsl(var(--senren-foreground))] outline-none placeholder:text-[hsl(var(--senren-muted-foreground))] focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
13
+ <% end %>
14
+ <% end %>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class SearchInputComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(name: 'q', value: nil, placeholder: 'Search...', label: 'Search', class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @name = name
11
+ @value = value
12
+ @placeholder = placeholder
13
+ @label = label
14
+ end
15
+
16
+ attr_reader :name, :value, :placeholder, :label
17
+ end
18
+ end
@@ -0,0 +1 @@
1
+ <%= render(Senren::NativeSelectComponent.new(**native_select_args)) { content } %>
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ # Stimulus-driven styled select. v0.1 ships as a thin wrapper around the
5
+ # native_select with a Stimulus controller hook for future styling work.
6
+ class SelectComponent < BaseComponent
7
+ VARIANTS = NativeSelectComponent::VARIANTS
8
+ SIZES = NativeSelectComponent::SIZES
9
+
10
+ def initialize(**args)
11
+ @args = args
12
+ end
13
+
14
+ def native_select_args
15
+ data = (@args[:data] || {}).merge(controller: 'senren--select')
16
+ @args.merge(data: data)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1 @@
1
+ <%= tag.div "", **root_attrs("", role: "separator", "aria-orientation": aria_orientation) %>
@@ -0,0 +1,12 @@
1
+ module Senren
2
+ class SeparatorComponent < BaseComponent
3
+ VARIANTS = {
4
+ horizontal: 'h-px w-full bg-[hsl(var(--senren-border))]',
5
+ vertical: 'w-px h-full bg-[hsl(var(--senren-border))]'
6
+ }.freeze
7
+
8
+ SIZES = { md: '' }.freeze
9
+
10
+ def aria_orientation = variant == :vertical ? 'vertical' : 'horizontal'
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ <%= tag.section(**root_attrs("rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-5 shadow-sm")) do %>
2
+ <% if title.present? || description.present? || actions? %>
3
+ <div class="flex items-start justify-between gap-4 border-b border-[hsl(var(--senren-border))] pb-4">
4
+ <div class="min-w-0">
5
+ <% if title.present? %>
6
+ <h2 class="font-display text-base font-semibold text-[hsl(var(--senren-foreground))]"><%= title %></h2>
7
+ <% end %>
8
+ <% if description.present? %>
9
+ <p class="mt-1 text-sm leading-6 text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
10
+ <% end %>
11
+ </div>
12
+ <% if actions? %>
13
+ <div class="shrink-0"><%= actions %></div>
14
+ <% end %>
15
+ </div>
16
+ <div class="pt-4"><%= content %></div>
17
+ <% else %>
18
+ <%= content %>
19
+ <% end %>
20
+ <% end %>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class SettingsSectionComponent < BaseComponent
5
+ renders_one :actions
6
+
7
+ VARIANTS = { default: '' }.freeze
8
+ SIZES = { md: '' }.freeze
9
+
10
+ def initialize(title: nil, description: nil, class_name: nil, **html)
11
+ super(variant: :default, size: :md, class_name: class_name, **html)
12
+ @title = title
13
+ @description = description
14
+ end
15
+
16
+ attr_reader :title, :description
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ <div data-controller="senren--sheet" data-senren-component="sheet" id="<%= dom_id %>">
2
+ <% if trigger? %>
3
+ <span data-action="click->senren--sheet#open" data-senren--sheet-target="trigger">
4
+ <%= trigger %>
5
+ </span>
6
+ <% else %>
7
+ <button type="button" data-action="click->senren--sheet#open" data-senren--sheet-target="trigger" class="hidden">Open</button>
8
+ <% end %>
9
+
10
+ <div data-senren--sheet-target="overlay" hidden
11
+ class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"></div>
12
+
13
+ <div data-senren--sheet-target="panel" hidden role="dialog" aria-modal="true"
14
+ data-open="false"
15
+ class="fixed z-50 bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] shadow-lg transition-transform duration-200 <%= self.class::VARIANTS[variant] %>">
16
+ <div class="flex h-full flex-col p-6">
17
+ <% if title? %>
18
+ <h2 class="text-lg font-semibold"><%= title %></h2>
19
+ <% end %>
20
+ <% if description? %>
21
+ <p class="text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
22
+ <% end %>
23
+ <div class="mt-4 flex-1 overflow-auto">
24
+ <%= body || content %>
25
+ </div>
26
+ <% if footer? %>
27
+ <div class="mt-4 border-t border-[hsl(var(--senren-border))] pt-4">
28
+ <%= footer %>
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+ <button type="button" data-action="click->senren--sheet#close" aria-label="Close"
33
+ class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
34
+ <span aria-hidden="true">×</span>
35
+ </button>
36
+ </div>
37
+ </div>
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class SheetComponent < BaseComponent
5
+ renders_one :trigger
6
+ renders_one :title
7
+ renders_one :description
8
+ renders_one :body
9
+ renders_one :footer
10
+
11
+ VARIANTS = {
12
+ right: 'right-0 top-0 h-full w-full max-w-md translate-x-full data-[open=true]:translate-x-0',
13
+ left: 'left-0 top-0 h-full w-full max-w-md -translate-x-full data-[open=true]:translate-x-0',
14
+ top: 'left-0 top-0 w-full h-1/2 -translate-y-full data-[open=true]:translate-y-0',
15
+ bottom: 'left-0 bottom-0 w-full h-1/2 translate-y-full data-[open=true]:translate-y-0'
16
+ }.freeze
17
+
18
+ SIZES = { md: '' }.freeze
19
+
20
+ def initialize(side: :right, id: nil, class_name: nil, **html)
21
+ super(variant: side, size: :md, class_name: class_name, **html)
22
+ @dom_id = id || "senren-sheet-#{SecureRandom.hex(3)}"
23
+ end
24
+
25
+ attr_reader :dom_id
26
+ end
27
+ end
@@ -0,0 +1,6 @@
1
+ <span <%= tag.attributes(**root_attrs("inline-flex items-center gap-1 text-xs text-[hsl(var(--senren-muted-foreground))]")) %>>
2
+ <% (keys.presence || [content]).compact.each_with_index do |key, index| %>
3
+ <% if index.positive? %><span aria-hidden="true">+</span><% end %>
4
+ <kbd class="rounded border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted))] px-1.5 py-0.5 font-mono text-[11px] text-[hsl(var(--senren-foreground))] shadow-sm"><%= key %></kbd>
5
+ <% end %>
6
+ </span>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class ShortcutKeyComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(keys: [], class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @keys = Array(keys)
11
+ end
12
+
13
+ attr_reader :keys
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ <aside <%= tag.attributes(**root_attrs("flex min-h-80 flex-col rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))] #{self.class::VARIANTS[variant]}", data: { controller: "senren--sidebar" })) %>>
2
+ <div class="mb-4 flex items-center justify-between gap-2 px-2">
3
+ <div class="truncate font-display text-sm font-semibold tracking-tight"><%= brand %></div>
4
+ <button type="button" class="cursor-pointer rounded-md px-2 py-1 text-xs text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))]" data-action="click->senren--sidebar#toggle" aria-label="Toggle sidebar">Toggle</button>
5
+ </div>
6
+ <nav aria-label="<%= label %>" class="space-y-1">
7
+ <% items.each do |item| %>
8
+ <%= link_to item[:label], item[:href], class: "block truncate rounded-md px-3 py-2 text-sm transition-colors #{item[:active] ? "bg-[hsl(var(--senren-primary))] text-[hsl(var(--senren-primary-foreground))]" : "text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-accent-foreground))]"}" %>
9
+ <% end %>
10
+ </nav>
11
+ <% if content? %>
12
+ <div class="mt-auto pt-4 text-xs text-[hsl(var(--senren-muted-foreground))]"><%= content %></div>
13
+ <% end %>
14
+ </aside>
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class SidebarComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: 'w-64',
7
+ compact: 'w-20'
8
+ }.freeze
9
+ SIZES = { md: '' }.freeze
10
+
11
+ def initialize(items: [], brand: 'Senren', variant: :default, label: 'Primary', class_name: nil, **html)
12
+ super(variant: variant, size: :md, class_name: class_name, **html)
13
+ @items = normalize_items(items)
14
+ @brand = brand
15
+ @label = label
16
+ end
17
+
18
+ attr_reader :items, :brand, :label
19
+
20
+ private
21
+
22
+ def normalize_items(items)
23
+ Array(items).map do |item|
24
+ if item.is_a?(Hash)
25
+ {
26
+ label: item[:label] || item['label'],
27
+ href: item[:href] || item['href'] || '#',
28
+ active: item[:active] || item['active']
29
+ }
30
+ else
31
+ label, href = item
32
+ { label: label, href: href || '#', active: false }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1 @@
1
+ <%= tag.div "", **root_attrs("animate-pulse bg-[hsl(var(--senren-muted))]", "aria-hidden": "true") %>
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class SkeletonComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: 'rounded-(--senren-radius)',
7
+ circle: 'rounded-full',
8
+ text: 'rounded-md h-4'
9
+ }.freeze
10
+
11
+ SIZES = { md: '' }.freeze
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ <%= tag.div(**root_attrs("rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] p-5 shadow-sm")) do %>
2
+ <% if content? && label.blank? && value.blank? %>
3
+ <%= content %>
4
+ <% else %>
5
+ <% if label.present? %>
6
+ <p class="text-xs font-semibold uppercase tracking-wide text-[hsl(var(--senren-muted-foreground))]"><%= label %></p>
7
+ <% end %>
8
+ <% if value.present? %>
9
+ <p class="mt-2 font-display text-3xl font-semibold tracking-tight text-[hsl(var(--senren-foreground))]"><%= value %></p>
10
+ <% end %>
11
+ <div class="mt-3 flex items-center justify-between gap-3 text-sm">
12
+ <% if change.present? %>
13
+ <span class="font-medium <%= accent_class %>"><%= change %></span>
14
+ <% end %>
15
+ <% if helper_text.present? %>
16
+ <span class="text-[hsl(var(--senren-muted-foreground))]"><%= helper_text %></span>
17
+ <% end %>
18
+ </div>
19
+ <% end %>
20
+ <% end %>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class StatCardComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: 'border-[hsl(var(--senren-border))]',
7
+ success: 'border-[hsl(var(--senren-success)/0.4)]',
8
+ warning: 'border-[hsl(var(--senren-warning)/0.5)]',
9
+ destructive: 'border-[hsl(var(--senren-destructive)/0.42)]'
10
+ }.freeze
11
+ SIZES = { md: '' }.freeze
12
+
13
+ def initialize(label: nil, value: nil, change: nil, helper_text: nil, variant: :default, class_name: nil, **html)
14
+ super(variant: variant, size: :md, class_name: class_name, **html)
15
+ @label = label
16
+ @value = value
17
+ @change = change
18
+ @helper_text = helper_text
19
+ end
20
+
21
+ attr_reader :label, :value, :change, :helper_text
22
+
23
+ def accent_class
24
+ {
25
+ success: 'text-[hsl(var(--senren-success))]',
26
+ warning: 'text-[hsl(var(--senren-warning))]',
27
+ destructive: 'text-[hsl(var(--senren-destructive))]'
28
+ }.fetch(variant, 'text-[hsl(var(--senren-muted-foreground))]')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ <label class="inline-flex items-center gap-3 cursor-pointer">
2
+ <span class="relative inline-flex h-6 w-11 items-center rounded-full bg-[hsl(var(--senren-muted))] has-[:checked]:bg-[hsl(var(--senren-primary))] transition-colors">
3
+ <input type="checkbox" role="switch" id="<%= id %>" name="<%= name %>" value="<%= value %>" <%= "checked" if checked %> class="peer sr-only">
4
+ <span aria-hidden="true" class="absolute left-0.5 top-0.5 inline-block h-5 w-5 transform rounded-full bg-[hsl(var(--senren-background))] transition-transform peer-checked:translate-x-5"></span>
5
+ </span>
6
+ <% if label %>
7
+ <span class="text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= label %></span>
8
+ <% else %>
9
+ <%= content %>
10
+ <% end %>
11
+ </label>
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class SwitchComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(name:, checked: false, value: '1', id: nil, label: nil, class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @name = name
11
+ @checked = checked
12
+ @value = value
13
+ @id = id || name.to_s.parameterize
14
+ @label = label
15
+ end
16
+
17
+ attr_reader :name, :checked, :value, :id, :label
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ <div <%= tag.attributes(**root_attrs("w-full overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))]")) %>>
2
+ <table class="w-full border-collapse text-left text-sm <%= self.class::VARIANTS[variant] %>">
3
+ <% if caption.present? %>
4
+ <caption class="sr-only"><%= caption %></caption>
5
+ <% end %>
6
+ <thead class="bg-[hsl(var(--senren-muted)/0.6)] text-[hsl(var(--senren-muted-foreground))]">
7
+ <tr>
8
+ <% columns.each do |column| %>
9
+ <th scope="col" class="px-4 py-3 font-medium"><%= column_label(column) %></th>
10
+ <% end %>
11
+ </tr>
12
+ </thead>
13
+ <tbody class="divide-y divide-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))]">
14
+ <% rows.each do |row| %>
15
+ <tr class="transition-colors hover:bg-[hsl(var(--senren-muted)/0.4)]">
16
+ <% columns.each do |column| %>
17
+ <td class="px-4 py-3"><%= cell_value(row, column) %></td>
18
+ <% end %>
19
+ </tr>
20
+ <% end %>
21
+ <% if rows.empty? && content? %>
22
+ <tr><td class="px-4 py-6" colspan="<%= columns.size.nonzero? || 1 %>"><%= content %></td></tr>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ </div>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class TableComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: '',
7
+ compact: 'text-xs'
8
+ }.freeze
9
+ SIZES = { md: '' }.freeze
10
+
11
+ def initialize(columns: [], rows: [], caption: nil, variant: :default, class_name: nil, **html)
12
+ super(variant: variant, size: :md, class_name: class_name, **html)
13
+ @columns = Array(columns)
14
+ @rows = Array(rows)
15
+ @caption = caption
16
+ end
17
+
18
+ attr_reader :columns, :rows, :caption
19
+
20
+ def cell_value(row, column)
21
+ key = column_key(column)
22
+ row.is_a?(Hash) ? (row[key] || row[key.to_s]) : row[key.to_i]
23
+ end
24
+
25
+ def column_label(column)
26
+ column.is_a?(Hash) ? (column[:label] || column['label']) : column.to_s.titleize
27
+ end
28
+
29
+ private
30
+
31
+ def column_key(column)
32
+ column.is_a?(Hash) ? (column[:key] || column['key']) : column
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ <div <%= tag.attributes(**root_attrs("w-full", data: { controller: "senren--tabs" })) %>>
2
+ <div role="tablist" aria-label="<%= label %>" class="<%= self.class::VARIANTS[variant] %> flex flex-wrap items-center gap-1">
3
+ <% items.each do |item| %>
4
+ <% selected = active_item?(item) %>
5
+ <button type="button" role="tab" id="<%= item[:id] %>-tab" aria-selected="<%= selected %>" aria-controls="<%= item[:id] %>-panel" tabindex="<%= selected ? 0 : -1 %>" data-senren--tabs-target="tab" data-panel-id="<%= item[:id] %>" data-action="click->senren--tabs#select keydown->senren--tabs#onKey" class="cursor-pointer rounded-[calc(var(--senren-radius)-2px)] px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] <%= selected ? "bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] shadow-sm" : "text-[hsl(var(--senren-muted-foreground))] hover:text-[hsl(var(--senren-foreground))]" %>">
6
+ <%= item[:label] %>
7
+ </button>
8
+ <% end %>
9
+ </div>
10
+ <div class="mt-4">
11
+ <% items.each do |item| %>
12
+ <% selected = active_item?(item) %>
13
+ <section id="<%= item[:id] %>-panel" role="tabpanel" aria-labelledby="<%= item[:id] %>-tab" data-senren--tabs-target="panel" data-panel-id="<%= item[:id] %>" <%= "hidden" unless selected %> class="rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-4 text-sm text-[hsl(var(--senren-card-foreground))]">
14
+ <%= item[:content].presence || content %>
15
+ </section>
16
+ <% end %>
17
+ </div>
18
+ </div>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class TabsComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: 'rounded-(--senren-radius) bg-[hsl(var(--senren-muted))] p-1',
7
+ underline: 'border-b border-[hsl(var(--senren-border))]'
8
+ }.freeze
9
+ SIZES = { md: '' }.freeze
10
+
11
+ def initialize(items: [], variant: :default, active: nil, label: 'Tabs', class_name: nil, **html)
12
+ super(variant: variant, size: :md, class_name: class_name, **html)
13
+ @items = normalize_items(items)
14
+ @active = active&.to_s || @items.first&.fetch(:id, nil)
15
+ @label = label
16
+ end
17
+
18
+ attr_reader :items, :active, :label
19
+
20
+ def active_item?(item)
21
+ item[:id].to_s == active.to_s
22
+ end
23
+
24
+ private
25
+
26
+ def normalize_items(items)
27
+ Array(items).map.with_index do |item, index|
28
+ source = item.is_a?(Hash) ? item : { label: item.to_s }
29
+ label = source[:label] || source['label'] || "Tab #{index + 1}"
30
+ id = (source[:id] || source['id'] || label.to_s.parameterize).to_s
31
+ { id: id, label: label, content: source[:content] || source['content'] }
32
+ end
33
+ end
34
+ end
35
+ end