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,22 @@
1
+ <%= tag.div(**root_attrs("rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] shadow-sm")) do %>
2
+ <% if content? && members.empty? %>
3
+ <div class="p-4"><%= content %></div>
4
+ <% else %>
5
+ <ul class="divide-y divide-[hsl(var(--senren-border))]">
6
+ <% members.each do |member| %>
7
+ <li class="flex items-center justify-between gap-4 p-4">
8
+ <div class="flex min-w-0 items-center gap-3">
9
+ <span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[hsl(var(--senren-muted))] text-sm font-semibold text-[hsl(var(--senren-muted-foreground))]"><%= initials_for(member) %></span>
10
+ <div class="min-w-0">
11
+ <p class="truncate text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= member_value(member, :name) %></p>
12
+ <p class="truncate text-xs text-[hsl(var(--senren-muted-foreground))]"><%= member_value(member, :role) || member_value(member, :email) %></p>
13
+ </div>
14
+ </div>
15
+ <% if member_value(member, :status).present? %>
16
+ <span class="rounded-full bg-[hsl(var(--senren-muted))] px-2 py-1 text-xs font-medium text-[hsl(var(--senren-muted-foreground))]"><%= member_value(member, :status) %></span>
17
+ <% end %>
18
+ </li>
19
+ <% end %>
20
+ </ul>
21
+ <% end %>
22
+ <% end %>
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class TeamMemberListComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(members: [], class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @members = Array(members)
11
+ end
12
+
13
+ attr_reader :members
14
+
15
+ def member_value(member, key)
16
+ member.is_a?(Hash) ? (member[key] || member[key.to_s]) : nil
17
+ end
18
+
19
+ def initials_for(member)
20
+ explicit = member_value(member, :initials)
21
+ return explicit if explicit.present?
22
+
23
+ member_value(member, :name).to_s.split.map { |part| part[0] }.join.first(2).upcase
24
+ end
25
+ end
26
+ end
@@ -0,0 +1 @@
1
+ <%= tag.textarea(value, **root_attrs("flex w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] placeholder:text-[hsl(var(--senren-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", id: id, name: name, placeholder: placeholder, rows: rows, "aria-invalid": variant == :error)) %>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class TextareaComponent < BaseComponent
5
+ VARIANTS = {
6
+ default: 'border-[hsl(var(--senren-input))] focus-visible:ring-[hsl(var(--senren-ring))]',
7
+ error: 'border-[hsl(var(--senren-destructive))] focus-visible:ring-[hsl(var(--senren-destructive))]'
8
+ }.freeze
9
+
10
+ SIZES = { md: 'min-h-[80px] text-sm px-3 py-2' }.freeze
11
+
12
+ def initialize(name:, value: nil, placeholder: nil, id: nil, rows: 4, variant: :default, class_name: nil, **html)
13
+ super(variant: variant, size: :md, class_name: class_name, **html)
14
+ @name = name
15
+ @value = value
16
+ @placeholder = placeholder
17
+ @id = id || name.to_s.parameterize
18
+ @rows = rows
19
+ end
20
+
21
+ attr_reader :name, :value, :placeholder, :id, :rows
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ <button type="button" <%= tag.attributes(**root_attrs("inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background))] px-3 text-sm font-medium text-[hsl(var(--senren-foreground))] transition-colors hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]", data: { controller: "senren--theme-toggle", action: "click->senren--theme-toggle#toggle" }, aria: { label: label, pressed: "false" })) %>>
2
+ <span aria-hidden="true" data-senren--theme-toggle-target="icon">O</span>
3
+ <span data-senren--theme-toggle-target="label"><%= label %></span>
4
+ </button>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class ThemeToggleComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(label: 'Toggle theme', class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @label = label
11
+ end
12
+
13
+ attr_reader :label
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ <span data-controller="senren--tooltip" class="relative inline-block" data-senren-component="tooltip">
2
+ <span tabindex="0"
3
+ aria-describedby="<%= id %>"
4
+ data-action="mouseenter->senren--tooltip#show mouseleave->senren--tooltip#hide focus->senren--tooltip#show blur->senren--tooltip#hide">
5
+ <%= content %>
6
+ </span>
7
+ <span id="<%= id %>" role="tooltip" hidden data-senren--tooltip-target="bubble"
8
+ class="absolute z-50 mt-1 whitespace-nowrap rounded-md bg-[hsl(var(--senren-foreground))] text-[hsl(var(--senren-background))] px-2 py-1 text-xs shadow"><%= text %></span>
9
+ </span>
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class TooltipComponent < BaseComponent
5
+ VARIANTS = { default: '' }.freeze
6
+ SIZES = { md: '' }.freeze
7
+
8
+ def initialize(text:, class_name: nil, **html)
9
+ super(variant: :default, size: :md, class_name: class_name, **html)
10
+ @text = text
11
+ @id = "senren-tooltip-#{SecureRandom.hex(3)}"
12
+ end
13
+
14
+ attr_reader :text, :id
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ <header <%= tag.attributes(**root_attrs("sticky top-0 z-30 border-b border-[hsl(var(--senren-border))] backdrop-blur-md")) %>>
2
+ <div class="mx-auto flex h-14 w-full max-w-[1400px] items-center gap-6 px-4 lg:px-8">
3
+ <div class="min-w-0 flex-none font-display text-sm font-semibold text-[hsl(var(--senren-foreground))]">
4
+ <% if brand? %><%= brand %><% else %>Senren<% end %>
5
+ </div>
6
+
7
+ <% if items.any? %>
8
+ <nav aria-label="<%= label %>" class="hidden min-w-0 items-center gap-1 md:flex">
9
+ <% items.each do |item| %>
10
+ <%= link_to item[:label], item[:href], class: "rounded-(--senren-radius) px-3 py-2 text-sm font-medium transition-colors #{active_item?(item) ? 'bg-[hsl(var(--senren-accent))] text-[hsl(var(--senren-accent-foreground))]' : 'text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-accent-foreground))]'}" %>
11
+ <% end %>
12
+ </nav>
13
+ <% elsif content? %>
14
+ <div class="min-w-0 flex-1"><%= content %></div>
15
+ <% end %>
16
+
17
+ <% if actions? %>
18
+ <div class="ml-auto flex items-center gap-2"><%= actions %></div>
19
+ <% end %>
20
+ </div>
21
+ </header>
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ class TopNavComponent < BaseComponent
5
+ renders_one :brand
6
+ renders_one :actions
7
+
8
+ VARIANTS = {
9
+ default: 'bg-[hsl(var(--senren-background))/0.88]',
10
+ solid: 'bg-[hsl(var(--senren-card))]'
11
+ }.freeze
12
+ SIZES = { md: '' }.freeze
13
+
14
+ def initialize(items: [], current_path: nil, label: 'Global', variant: :default, class_name: nil, **html)
15
+ super(variant: variant, size: :md, class_name: class_name, **html)
16
+ @items = normalize_items(items)
17
+ @current_path = current_path
18
+ @label = label
19
+ end
20
+
21
+ attr_reader :items, :current_path, :label
22
+
23
+ def active_item?(item)
24
+ item[:active] || (current_path && item[:href] == current_path)
25
+ end
26
+
27
+ private
28
+
29
+ def normalize_items(items)
30
+ Array(items).map do |item|
31
+ if item.is_a?(Hash)
32
+ {
33
+ label: item[:label] || item['label'],
34
+ href: item[:href] || item['href'] || '#',
35
+ active: item[:active] || item['active']
36
+ }
37
+ else
38
+ label, href = item
39
+ { label: label, href: href || '#', active: false }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1 @@
1
+ <%= content_tag(html_tag, content, **root_attrs("")) %>
@@ -0,0 +1,24 @@
1
+ module Senren
2
+ class TypographyComponent < BaseComponent
3
+ VARIANTS = {
4
+ h1: 'scroll-m-20 text-4xl font-bold tracking-tight',
5
+ h2: 'scroll-m-20 text-3xl font-semibold tracking-tight',
6
+ h3: 'scroll-m-20 text-2xl font-semibold tracking-tight',
7
+ h4: 'scroll-m-20 text-xl font-semibold tracking-tight',
8
+ p: 'leading-7',
9
+ lead: 'text-xl text-[hsl(var(--senren-muted-foreground))]',
10
+ large: 'text-lg font-semibold',
11
+ small: 'text-sm font-medium leading-none',
12
+ muted: 'text-sm text-[hsl(var(--senren-muted-foreground))]'
13
+ }.freeze
14
+
15
+ SIZES = { md: '' }.freeze
16
+
17
+ TAG_FOR = {
18
+ h1: :h1, h2: :h2, h3: :h3, h4: :h4,
19
+ p: :p, lead: :p, large: :p, small: :small, muted: :p
20
+ }.freeze
21
+
22
+ def html_tag = TAG_FOR[variant]
23
+ end
24
+ end
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--accordion
4
+ // Local UI: expand/collapse one or multiple panels.
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "panel"]
7
+ static values = { multiple: Boolean }
8
+
9
+ toggle(event) {
10
+ const trigger = event.currentTarget
11
+ const panel = this._panelFor(trigger.dataset.panelId)
12
+ const nextOpen = trigger.getAttribute("aria-expanded") !== "true"
13
+
14
+ if (!this.multipleValue) this._closeAll()
15
+ trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false")
16
+ if (panel) panel.hidden = !nextOpen
17
+ }
18
+
19
+ _closeAll() {
20
+ this.triggerTargets.forEach((trigger) => trigger.setAttribute("aria-expanded", "false"))
21
+ this.panelTargets.forEach((panel) => { panel.hidden = true })
22
+ }
23
+
24
+ _panelFor(id) {
25
+ return this.panelTargets.find((panel) => panel.dataset.panelId === id)
26
+ }
27
+ }
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--alert-dialog
4
+ // Same shape as dialog, but role=alertdialog. Triggered by external buttons that include
5
+ // data-action="click->senren--alert-dialog#open" pointing at the controller scope.
6
+ export default class extends Controller {
7
+ static targets = ["overlay", "panel", "trigger"]
8
+
9
+ connect() {
10
+ this._onKey = this._onKey.bind(this)
11
+ }
12
+
13
+ disconnect() {
14
+ document.removeEventListener("keydown", this._onKey)
15
+ document.body.style.overflow = ""
16
+ }
17
+
18
+ open(event) {
19
+ event?.preventDefault()
20
+ this.overlayTarget.hidden = false
21
+ this.panelTarget.hidden = false
22
+ document.addEventListener("keydown", this._onKey)
23
+ document.body.style.overflow = "hidden"
24
+ queueMicrotask(() => this.panelTarget?.focus())
25
+ }
26
+
27
+ close(event) {
28
+ event?.preventDefault()
29
+ this.overlayTarget.hidden = true
30
+ this.panelTarget.hidden = true
31
+ document.removeEventListener("keydown", this._onKey)
32
+ document.body.style.overflow = ""
33
+ }
34
+
35
+ _onKey(event) {
36
+ if (event.key === "Escape") this.close()
37
+ }
38
+ }
@@ -0,0 +1,36 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--api-key-field
4
+ export default class extends Controller {
5
+ static targets = ["input", "revealButton", "status"]
6
+ static values = {
7
+ revealLabel: String,
8
+ hideLabel: String,
9
+ copyLabel: String
10
+ }
11
+
12
+ connect() {
13
+ this.hidden = true
14
+ }
15
+
16
+ toggle() {
17
+ this.hidden = !this.hidden
18
+ this.inputTarget.type = this.hidden ? "password" : "text"
19
+ this.revealButtonTarget.textContent = this.hidden ? this.revealLabelValue : this.hideLabelValue
20
+ }
21
+
22
+ async copy() {
23
+ const value = this.inputTarget.value
24
+ if (!value) return
25
+
26
+ if (navigator.clipboard) {
27
+ await navigator.clipboard.writeText(value)
28
+ } else {
29
+ this.inputTarget.select()
30
+ try { document.execCommand("copy") } catch (_) {}
31
+ this.inputTarget.setSelectionRange(0, 0)
32
+ }
33
+
34
+ if (this.hasStatusTarget) this.statusTarget.textContent = `${this.copyLabelValue} complete`
35
+ }
36
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--calendar
4
+ // Local UI: select a visible day and update the optional hidden field.
5
+ export default class extends Controller {
6
+ static targets = ["day", "value"]
7
+
8
+ select(event) {
9
+ const day = event.currentTarget
10
+ this.dayTargets.forEach((target) => {
11
+ target.classList.remove("bg-[hsl(var(--senren-primary))]", "text-[hsl(var(--senren-primary-foreground))]")
12
+ })
13
+ day.classList.add("bg-[hsl(var(--senren-primary))]", "text-[hsl(var(--senren-primary-foreground))]")
14
+ if (this.hasValueTarget) this.valueTarget.value = day.dataset.date
15
+ }
16
+ }
@@ -0,0 +1,50 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--carousel
4
+ // Local UI: previous/next controls, dots, and arrow-key navigation.
5
+ export default class extends Controller {
6
+ static targets = ["slide", "dot", "status"]
7
+
8
+ connect() {
9
+ this.index = 0
10
+ this.show(0)
11
+ }
12
+
13
+ previous() {
14
+ this.show(this.index - 1)
15
+ }
16
+
17
+ next() {
18
+ this.show(this.index + 1)
19
+ }
20
+
21
+ goTo(event) {
22
+ this.show(Number(event.currentTarget.dataset.index || 0))
23
+ }
24
+
25
+ onKey(event) {
26
+ if (event.key === "ArrowLeft") {
27
+ event.preventDefault()
28
+ this.previous()
29
+ }
30
+ if (event.key === "ArrowRight") {
31
+ event.preventDefault()
32
+ this.next()
33
+ }
34
+ }
35
+
36
+ show(index) {
37
+ const count = this.slideTargets.length
38
+ if (count === 0) return
39
+
40
+ this.index = (index + count) % count
41
+ this.slideTargets.forEach((slide, slideIndex) => {
42
+ slide.hidden = slideIndex !== this.index
43
+ slide.classList.toggle("hidden", slideIndex !== this.index)
44
+ })
45
+ this.dotTargets.forEach((dot, dotIndex) => {
46
+ dot.setAttribute("aria-current", dotIndex === this.index ? "true" : "false")
47
+ })
48
+ if (this.hasStatusTarget) this.statusTarget.textContent = `Slide ${this.index + 1} of ${count}`
49
+ }
50
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--clipboard
4
+ // Local UI: copy text to the clipboard and announce success.
5
+ export default class extends Controller {
6
+ static targets = ["source", "button", "status"]
7
+ static values = { copiedLabel: String }
8
+
9
+ async copy() {
10
+ const value = this.sourceTarget.value || this.sourceTarget.textContent
11
+ await navigator.clipboard.writeText(value)
12
+ const original = this.buttonTarget.textContent
13
+ this.buttonTarget.textContent = this.copiedLabelValue || "Copied"
14
+ if (this.hasStatusTarget) this.statusTarget.textContent = "Copied to clipboard"
15
+ window.setTimeout(() => { this.buttonTarget.textContent = original }, 1200)
16
+ }
17
+ }
@@ -0,0 +1,13 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--collapsible
4
+ // Local UI: toggle one content panel.
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "panel"]
7
+
8
+ toggle() {
9
+ const nextOpen = this.triggerTarget.getAttribute("aria-expanded") !== "true"
10
+ this.triggerTarget.setAttribute("aria-expanded", nextOpen ? "true" : "false")
11
+ this.panelTarget.hidden = !nextOpen
12
+ }
13
+ }
@@ -0,0 +1,64 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--combobox
4
+ // Local UI: open, filter options, and write the selected value to a hidden input.
5
+ export default class extends Controller {
6
+ static targets = ["button", "panel", "search", "option", "value", "label", "chevron"]
7
+
8
+ connect() {
9
+ this._onDocClick = this._onDocClick.bind(this)
10
+ this.setOpenState(false)
11
+ }
12
+
13
+ disconnect() {
14
+ document.removeEventListener("click", this._onDocClick)
15
+ }
16
+
17
+ toggle() {
18
+ this.panelTarget.hidden ? this.open() : this.close()
19
+ }
20
+
21
+ open() {
22
+ this.setOpenState(true)
23
+ document.addEventListener("click", this._onDocClick)
24
+ queueMicrotask(() => this.searchTarget.focus())
25
+ }
26
+
27
+ close() {
28
+ this.setOpenState(false)
29
+ document.removeEventListener("click", this._onDocClick)
30
+ }
31
+
32
+ filter() {
33
+ const query = this.searchTarget.value.toLowerCase()
34
+ this.optionTargets.forEach((option) => {
35
+ option.hidden = !option.dataset.label.toLowerCase().includes(query)
36
+ })
37
+ }
38
+
39
+ choose(event) {
40
+ const option = event.currentTarget
41
+ this.valueTarget.value = option.dataset.value
42
+ this.labelTarget.textContent = option.dataset.label
43
+ this.close()
44
+ this.buttonTarget.focus()
45
+ }
46
+
47
+ onKey(event) {
48
+ if (event.key === "Escape") {
49
+ this.close()
50
+ this.buttonTarget.focus()
51
+ }
52
+ }
53
+
54
+ setOpenState(open) {
55
+ this.panelTarget.hidden = !open
56
+ this.buttonTarget.setAttribute("aria-expanded", open ? "true" : "false")
57
+ this.buttonTarget.dataset.state = open ? "open" : "closed"
58
+ if (this.hasChevronTarget) this.chevronTarget.dataset.state = open ? "open" : "closed"
59
+ }
60
+
61
+ _onDocClick(event) {
62
+ if (!this.element.contains(event.target)) this.close()
63
+ }
64
+ }
@@ -0,0 +1,80 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--command
4
+ // Local UI: filter command options and support keyboard navigation.
5
+ export default class extends Controller {
6
+ static targets = ["input", "list", "option", "empty"]
7
+
8
+ connect() {
9
+ this.activeIndex = 0
10
+ this.filter()
11
+ }
12
+
13
+ filter() {
14
+ const query = this.normalize(this.inputTarget.value)
15
+ this.optionTargets.forEach((option) => {
16
+ const label = this.normalize(option.dataset.label || option.textContent)
17
+ option.hidden = query.length > 0 && !query.split(/\s+/).every((part) => label.includes(part))
18
+ })
19
+ this.activeIndex = 0
20
+ this.updateActive()
21
+ }
22
+
23
+ onKey(event) {
24
+ if (event.key === "ArrowDown") {
25
+ event.preventDefault()
26
+ this.move(1)
27
+ return
28
+ }
29
+
30
+ if (event.key === "ArrowUp") {
31
+ event.preventDefault()
32
+ this.move(-1)
33
+ return
34
+ }
35
+
36
+ if (event.key === "Enter") {
37
+ const option = this.visibleOptions[this.activeIndex]
38
+ if (option) {
39
+ event.preventDefault()
40
+ option.click()
41
+ }
42
+ }
43
+ }
44
+
45
+ choose(event) {
46
+ this.element.dispatchEvent(new CustomEvent("senren:command-select", {
47
+ bubbles: true,
48
+ detail: { label: event.currentTarget.textContent.trim(), href: event.currentTarget.getAttribute("href") }
49
+ }))
50
+ }
51
+
52
+ move(delta) {
53
+ const options = this.visibleOptions
54
+ if (options.length === 0) return
55
+ this.activeIndex = (this.activeIndex + delta + options.length) % options.length
56
+ this.updateActive()
57
+ }
58
+
59
+ updateActive() {
60
+ const options = this.visibleOptions
61
+ this.emptyTarget.hidden = options.length > 0
62
+ this.optionTargets.forEach((option) => option.setAttribute("aria-selected", "false"))
63
+ const active = options[this.activeIndex]
64
+ if (!active) {
65
+ this.inputTarget.removeAttribute("aria-activedescendant")
66
+ return
67
+ }
68
+ active.setAttribute("aria-selected", "true")
69
+ this.inputTarget.setAttribute("aria-activedescendant", active.id)
70
+ active.scrollIntoView({ block: "nearest" })
71
+ }
72
+
73
+ normalize(value) {
74
+ return String(value || "").toLowerCase().replace(/[_-]+/g, " ").trim()
75
+ }
76
+
77
+ get visibleOptions() {
78
+ return this.optionTargets.filter((option) => !option.hidden)
79
+ }
80
+ }
@@ -0,0 +1,36 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--context-menu
4
+ // Opens on right-click within the trigger; closes on outside click or Escape.
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "menu"]
7
+
8
+ connect() {
9
+ this._onDocClick = this._onDocClick.bind(this)
10
+ this._onKey = this._onKey.bind(this)
11
+ }
12
+
13
+ disconnect() {
14
+ document.removeEventListener("click", this._onDocClick)
15
+ document.removeEventListener("keydown", this._onKey)
16
+ }
17
+
18
+ open(event) {
19
+ event.preventDefault()
20
+ const rect = this.element.getBoundingClientRect()
21
+ this.menuTarget.style.top = (event.clientY - rect.top) + "px"
22
+ this.menuTarget.style.left = (event.clientX - rect.left) + "px"
23
+ this.menuTarget.hidden = false
24
+ document.addEventListener("click", this._onDocClick)
25
+ document.addEventListener("keydown", this._onKey)
26
+ }
27
+
28
+ close() {
29
+ this.menuTarget.hidden = true
30
+ document.removeEventListener("click", this._onDocClick)
31
+ document.removeEventListener("keydown", this._onKey)
32
+ }
33
+
34
+ _onDocClick(event) { if (!this.menuTarget.contains(event.target)) this.close() }
35
+ _onKey(event) { if (event.key === "Escape") this.close() }
36
+ }
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--data-table
4
+ export default class extends Controller {
5
+ static targets = ["body", "row"]
6
+
7
+ connect() {
8
+ this.directions = {}
9
+ }
10
+
11
+ sort(event) {
12
+ const key = event.currentTarget.dataset.sortKey
13
+ if (!key || !this.hasBodyTarget) return
14
+
15
+ const direction = this.directions[key] === "asc" ? "desc" : "asc"
16
+ this.directions[key] = direction
17
+
18
+ const rows = [...this.rowTargets]
19
+ rows.sort((a, b) => this.compare(this.valueFor(a, key), this.valueFor(b, key), direction))
20
+ rows.forEach((row) => this.bodyTarget.appendChild(row))
21
+ }
22
+
23
+ valueFor(row, key) {
24
+ const cell = row.querySelector(`[data-sort-key="${CSS.escape(key)}"]`)
25
+ return (cell?.dataset.sortValue || cell?.textContent || "").trim().toLowerCase()
26
+ }
27
+
28
+ compare(a, b, direction) {
29
+ const left = Number(a)
30
+ const right = Number(b)
31
+ const result = Number.isNaN(left) || Number.isNaN(right) ? a.localeCompare(b) : left - right
32
+ return direction === "asc" ? result : -result
33
+ }
34
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // senren--date-picker
4
+ // Local UI: small helpers around native date input.
5
+ export default class extends Controller {
6
+ static targets = ["input"]
7
+
8
+ today() {
9
+ this.inputTarget.value = new Date().toISOString().slice(0, 10)
10
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
11
+ }
12
+
13
+ clear() {
14
+ this.inputTarget.value = ""
15
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
16
+ }
17
+ }