senren-ui 0.2.0 → 0.4.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +238 -0
  3. data/CONTRIBUTING.md +11 -0
  4. data/README.md +64 -0
  5. data/docs/i18n.md +194 -0
  6. data/docs/visual_style.md +80 -0
  7. data/lib/generators/senren/install/install_generator.rb +39 -0
  8. data/lib/generators/senren/install/templates/base_component.rb.tt +136 -7
  9. data/lib/generators/senren/install/templates/conventions.md.tt +57 -0
  10. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  11. data/lib/senren/rails/agent_rules_writer.rb +7 -0
  12. data/lib/senren/rails/base_component_patch.rb +24 -0
  13. data/lib/senren/rails/component_copier.rb +21 -0
  14. data/lib/senren/rails/host_paths.rb +5 -0
  15. data/lib/senren/rails/locale_installer.rb +90 -0
  16. data/lib/senren/rails/version.rb +1 -1
  17. data/lib/senren/rails.rb +1 -0
  18. data/lib/tasks/senren.rake +9 -0
  19. data/templates/components/accordion/accordion_component.html.erb +24 -6
  20. data/templates/components/accordion/accordion_component.rb +2 -1
  21. data/templates/components/alert_dialog/alert_dialog_component.html.erb +7 -4
  22. data/templates/components/alert_dialog/alert_dialog_component.rb +4 -0
  23. data/templates/components/api_key_field/api_key_field_component.html.erb +2 -2
  24. data/templates/components/api_key_field/api_key_field_component.rb +8 -1
  25. data/templates/components/app_shell/app_shell_component.rb +2 -1
  26. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  27. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -1
  28. data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +1 -1
  29. data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +4 -2
  30. data/templates/components/button/button_component.html.erb +1 -1
  31. data/templates/components/button/button_component.rb +27 -1
  32. data/templates/components/calendar/calendar_component.html.erb +2 -2
  33. data/templates/components/calendar/calendar_component.rb +37 -2
  34. data/templates/components/card/card_component.html.erb +4 -6
  35. data/templates/components/carousel/carousel_component.html.erb +6 -6
  36. data/templates/components/carousel/carousel_component.rb +12 -1
  37. data/templates/components/cart/cart_component.html.erb +13 -7
  38. data/templates/components/cart/cart_component.rb +11 -2
  39. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  40. data/templates/components/clipboard/clipboard_component.rb +6 -1
  41. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  42. data/templates/components/collapsible/collapsible_component.rb +1 -1
  43. data/templates/components/combobox/combobox_component.html.erb +3 -3
  44. data/templates/components/combobox/combobox_component.rb +2 -1
  45. data/templates/components/command/command_component.html.erb +1 -1
  46. data/templates/components/command/command_component.rb +3 -1
  47. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  48. data/templates/components/data_table/data_table_component.html.erb +3 -3
  49. data/templates/components/data_table/data_table_component.rb +16 -1
  50. data/templates/components/date_picker/date_picker_component.html.erb +5 -5
  51. data/templates/components/date_picker/date_picker_component.rb +2 -1
  52. data/templates/components/dialog/dialog_component.html.erb +9 -5
  53. data/templates/components/dialog/dialog_component.rb +11 -0
  54. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  55. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  56. data/templates/components/filter_bar/filter_bar_component.rb +1 -1
  57. data/templates/components/form/form_component.html.erb +1 -1
  58. data/templates/components/form/form_component.rb +21 -0
  59. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  60. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +11 -9
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +11 -3
  62. data/templates/components/link/link_component.html.erb +1 -1
  63. data/templates/components/native_select/native_select_component.html.erb +2 -2
  64. data/templates/components/native_select/native_select_component.rb +12 -3
  65. data/templates/components/pagination/pagination_component.html.erb +4 -4
  66. data/templates/components/pagination/pagination_component.rb +2 -1
  67. data/templates/components/popover/popover_component.html.erb +6 -3
  68. data/templates/components/product_card/product_card_component.html.erb +3 -3
  69. data/templates/components/product_card/product_card_component.rb +2 -2
  70. data/templates/components/progress/progress_component.html.erb +2 -2
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +2 -16
  72. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +32 -1
  73. data/templates/components/search_input/search_input_component.html.erb +2 -2
  74. data/templates/components/search_input/search_input_component.rb +2 -1
  75. data/templates/components/sheet/sheet_component.html.erb +15 -6
  76. data/templates/components/sheet/sheet_component.rb +11 -0
  77. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  78. data/templates/components/sidebar/sidebar_component.html.erb +4 -4
  79. data/templates/components/sidebar/sidebar_component.rb +4 -1
  80. data/templates/components/switch/switch_component.html.erb +1 -1
  81. data/templates/components/table/table_component.html.erb +2 -2
  82. data/templates/components/tabs/tabs_component.html.erb +3 -3
  83. data/templates/components/tabs/tabs_component.rb +4 -2
  84. data/templates/components/theme_toggle/theme_toggle_component.html.erb +17 -3
  85. data/templates/components/theme_toggle/theme_toggle_component.rb +6 -1
  86. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  87. data/templates/components/top_nav/top_nav_component.html.erb +3 -3
  88. data/templates/components/top_nav/top_nav_component.rb +3 -2
  89. data/templates/controllers/accordion_controller.js +68 -3
  90. data/templates/controllers/api_key_field_controller.js +3 -2
  91. data/templates/controllers/carousel_controller.js +6 -1
  92. data/templates/controllers/cart_controller.js +3 -1
  93. data/templates/controllers/clipboard_controller.js +3 -3
  94. data/templates/controllers/rich_text_editor_lite_controller.js +2 -2
  95. data/templates/controllers/theme_toggle_controller.js +13 -3
  96. data/templates/locales/senren.en.yml +151 -0
  97. data/templates/locales/senren.vi.yml +151 -0
  98. metadata +6 -1
@@ -6,7 +6,7 @@
6
6
  <% end %>
7
7
  <% options.each do |opt| %>
8
8
  <% value, label = Array === opt ? opt : [opt, opt] %>
9
- <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
9
+ <option value="<%= value %>" <% if selected.to_s == value.to_s %>selected<% end %>><%= label %></option>
10
10
  <% end %>
11
11
  <% end %>
12
12
  <% else %>
@@ -18,7 +18,7 @@
18
18
  <% end %>
19
19
  <% options.each do |opt| %>
20
20
  <% value, label = Array === opt ? opt : [opt, opt] %>
21
- <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
21
+ <option value="<%= value %>" <% if selected.to_s == value.to_s %>selected<% end %>><%= label %></option>
22
22
  <% end %>
23
23
  <% end %>
24
24
  <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
@@ -13,9 +13,18 @@ module Senren
13
13
  lg: 'h-12 text-base px-4'
14
14
  }.freeze
15
15
 
16
- # native_arrow: true → keep browser/OS native arrow (appearance-auto)
17
- # native_arrow: false → use custom SVG arrow (appearance-none + SVG overlay)
18
- def initialize(name:, options:, selected: nil, id: nil, prompt: nil, native_arrow: true,
16
+ # native_arrow: false → custom SVG chevron that rotates while the select has
17
+ # focus (appearance-none + positioned SVG). The default.
18
+ # native_arrow: true → the browser's own arrow (appearance-auto), which
19
+ # matches the OS but cannot be styled or animated.
20
+ #
21
+ # The default was `true`, so out of the box a Senren select was the one
22
+ # control in the library with no state to see: the arrow never moved, and
23
+ # nothing distinguished an open select from a closed one. The custom arrow
24
+ # was already written and had to be asked for by name, which meant nobody
25
+ # got it. Pass native_arrow: true to opt back in -- worth doing on mobile,
26
+ # where the OS renders its own picker anyway.
27
+ def initialize(name:, options:, selected: nil, id: nil, prompt: nil, native_arrow: false,
19
28
  variant: :default, size: :md, class_name: nil, **html)
20
29
  super(variant: variant, size: size, class_name: class_name, **html)
21
30
  @name = name
@@ -1,11 +1,11 @@
1
- <nav <%= tag.attributes(**root_attrs("flex items-center justify-center gap-1", aria: { label: label })) %>>
1
+ <%= tag.nav(**root_attrs("flex flex-wrap items-center justify-center gap-1", aria: { label: label })) do %>
2
2
  <% prev_page = current_page - 1 %>
3
- <%= link_to "Previous", page_url(prev_page), class: "rounded-md border border-[hsl(var(--senren-border))] px-3 py-2 text-sm #{current_page == 1 ? "pointer-events-none opacity-50" : "hover:bg-[hsl(var(--senren-accent))]"}", aria: { disabled: current_page == 1 } %>
3
+ <%= link_to senren_t('pagination.previous', default: 'Previous'), page_url(prev_page), class: "rounded-md border border-[hsl(var(--senren-border))] px-3 py-2 text-sm #{current_page == 1 ? "pointer-events-none opacity-50" : "hover:bg-[hsl(var(--senren-accent))]"}", aria: { disabled: current_page == 1 } %>
4
4
 
5
5
  <% (1..total_pages).each do |page| %>
6
6
  <%= link_to page, page_url(page), class: "inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium #{page == current_page ? "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))]"}", aria: { current: (page == current_page ? "page" : nil) } %>
7
7
  <% end %>
8
8
 
9
9
  <% next_page = current_page + 1 %>
10
- <%= link_to "Next", page_url(next_page), class: "rounded-md border border-[hsl(var(--senren-border))] px-3 py-2 text-sm #{current_page == total_pages ? "pointer-events-none opacity-50" : "hover:bg-[hsl(var(--senren-accent))]"}", aria: { disabled: current_page == total_pages } %>
11
- </nav>
10
+ <%= link_to senren_t('pagination.next', default: 'Next'), page_url(next_page), class: "rounded-md border border-[hsl(var(--senren-border))] px-3 py-2 text-sm #{current_page == total_pages ? "pointer-events-none opacity-50" : "hover:bg-[hsl(var(--senren-accent))]"}", aria: { disabled: current_page == total_pages } %>
11
+ <% end %>
@@ -5,7 +5,8 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(current_page: 1, total_pages: 1, path: nil, label: 'Pagination', class_name: nil, **html)
8
+ def initialize(current_page: 1, total_pages: 1, path: nil, label: senren_t('pagination.label', default: 'Pagination'),
9
+ class_name: nil, **html)
9
10
  super(variant: :default, size: :md, class_name: class_name, **html)
10
11
  @total_pages = [total_pages.to_i, 1].max
11
12
  @current_page = current_page.to_i.clamp(1, @total_pages)
@@ -1,9 +1,12 @@
1
- <div data-controller="senren--popover" class="relative inline-block" data-senren-component="popover">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both -- which is why a Stimulus
3
+ value could not be set from the server. %>
4
+ <%= tag.div(**wrapper_attrs("relative inline-block", data: { controller: "senren--popover" })) do %>
2
5
  <div data-senren--popover-target="trigger" data-action="click->senren--popover#toggle">
3
6
  <%= trigger %>
4
7
  </div>
5
8
  <div data-senren--popover-target="panel" hidden role="dialog"
6
- class="absolute z-40 mt-2 min-w-[12rem] rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-4 shadow-md">
9
+ class="<%= panel_class("absolute z-40 mt-2 min-w-[12rem] rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-4 shadow-md") %>">
7
10
  <%= content_panel || content %>
8
11
  </div>
9
- </div>
12
+ <% end %>
@@ -1,10 +1,10 @@
1
1
  <%= tag.article(**root_attrs("flex flex-col overflow-hidden rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id)) do %>
2
2
  <div class="relative aspect-square w-full bg-[hsl(var(--senren-muted)/0.4)]">
3
3
  <% if safe_image_url %>
4
- <img src="<%= safe_image_url %>" alt="<%= title %>" loading="lazy" class="h-full w-full object-cover">
4
+ <%= image_tag safe_image_url, alt: title, loading: "lazy", class: "h-full w-full object-cover" %>
5
5
  <% else %>
6
6
  <div class="flex h-full w-full items-center justify-center text-sm text-[hsl(var(--senren-muted-foreground))]" aria-hidden="true">
7
- No image
7
+ <%= senren_t('product_card.no_image', default: 'No image') %>
8
8
  </div>
9
9
  <% end %>
10
10
 
@@ -29,7 +29,7 @@
29
29
  <%# A plain form: the server owns the cart, and Turbo handles the response. %>
30
30
  <%= form_with url: safe_action_url, method: method, class: "mt-1" do %>
31
31
  <button type="submit"
32
- <%= "disabled" unless available? %>
32
+ <% unless available? %>disabled<% end %>
33
33
  class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:cursor-not-allowed disabled:opacity-50">
34
34
  <%= button_label %>
35
35
  </button>
@@ -20,7 +20,7 @@ module Senren
20
20
  SIZES = { md: '' }.freeze
21
21
 
22
22
  def initialize(title:, price:, url:, image_url: nil, description: nil, badge: nil,
23
- action_label: 'Add to cart', available: true, method: :post,
23
+ action_label: senren_t('product_card.add_to_cart', default: 'Add to cart'), available: true, method: :post,
24
24
  variant: :default, id: nil, class_name: nil, **html)
25
25
  super(variant: variant, size: :md, class_name: class_name, **html)
26
26
  @title = title
@@ -44,6 +44,6 @@ module Senren
44
44
  def safe_action_url = safe_url(@url)
45
45
  def safe_image_url = safe_media_url(@image_url)
46
46
 
47
- def button_label = available? ? action_label : 'Out of stock'
47
+ def button_label = available? ? action_label : senren_t('product_card.out_of_stock', default: 'Out of stock')
48
48
  end
49
49
  end
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-full")) %>>
1
+ <%= tag.div(**root_attrs("w-full")) do %>
2
2
  <% if label.present? %>
3
3
  <div class="mb-2 flex items-center justify-between text-sm">
4
4
  <span class="font-medium text-[hsl(var(--senren-foreground))]"><%= label %></span>
@@ -8,4 +8,4 @@
8
8
  <div role="progressbar" aria-valuemin="0" aria-valuemax="<%= max.to_i %>" aria-valuenow="<%= value.to_i %>" class="h-2.5 w-full overflow-hidden rounded-full bg-[hsl(var(--senren-muted))]">
9
9
  <div class="h-full rounded-full transition-[width] duration-300 ease-out <%= indicator_class %>" style="width: <%= percent %>%"></div>
10
10
  </div>
11
- </div>
11
+ <% end %>
@@ -1,24 +1,10 @@
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 %>
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?, "senren--rich-text-editor-lite-link-prompt-value": link_prompt })) do %>
2
2
  <label for="<%= dom_id %>-editor" class="sr-only"><%= label %></label>
3
3
  <textarea hidden name="<%= name %>" data-senren--rich-text-editor-lite-target="input"><%= initial_content %></textarea>
4
4
 
5
5
  <% if toolbar? %>
6
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| %>
7
+ <% toolbar_buttons.each do |command, label_text, aria_label| %>
22
8
  <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
9
  <%= label_text %>
24
10
  </button>
@@ -5,7 +5,8 @@ module Senren
5
5
  }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(name: 'content', value: nil, label: 'Content', placeholder: 'Write something...', id: nil,
8
+ def initialize(name: 'content', value: nil, label: senren_t('rich_text_editor_lite.label', default: 'Content'),
9
+ placeholder: senren_t('rich_text_editor_lite.placeholder', default: 'Write something...'), id: nil,
9
10
  toolbar: true, debug: ::Rails.env.development?, class_name: nil, **html)
10
11
  super(variant: :default, size: :md, class_name: class_name, **html)
11
12
  @name = name
@@ -23,6 +24,36 @@ module Senren
23
24
 
24
25
  def debug? = !!@debug
25
26
 
27
+ # Read by the controller from a Stimulus value, so the prompt it opens is in
28
+ # the host's language without a sentence living in JavaScript.
29
+ def link_prompt = senren_t('rich_text_editor_lite.link_prompt', default: 'Paste a URL')
30
+
31
+ # [command, visible text, aria-label]. `P`, `H1`-`H3`, `B`, `I` and `1.` are
32
+ # symbols, not words, so they are not translated; every word is.
33
+ def toolbar_buttons
34
+ [
35
+ ['formatBlock:p', 'P', senren_t('rich_text_editor_lite.toolbar.paragraph', default: 'Paragraph')],
36
+ ['formatBlock:h1', 'H1', senren_t('rich_text_editor_lite.toolbar.heading_1', default: 'Heading 1')],
37
+ ['formatBlock:h2', 'H2', senren_t('rich_text_editor_lite.toolbar.heading_2', default: 'Heading 2')],
38
+ ['formatBlock:h3', 'H3', senren_t('rich_text_editor_lite.toolbar.heading_3', default: 'Heading 3')],
39
+ ['bold', 'B', senren_t('rich_text_editor_lite.toolbar.bold', default: 'Bold')],
40
+ ['italic', 'I', senren_t('rich_text_editor_lite.toolbar.italic', default: 'Italic')],
41
+ ['createLink', senren_t('rich_text_editor_lite.toolbar_short.link', default: 'Link'),
42
+ senren_t('rich_text_editor_lite.toolbar.create_link', default: 'Create link')],
43
+ ['insertUnorderedList', senren_t('rich_text_editor_lite.toolbar_short.list', default: 'List'),
44
+ senren_t('rich_text_editor_lite.toolbar.bulleted_list', default: 'Bulleted list')],
45
+ ['insertOrderedList', '1.', senren_t('rich_text_editor_lite.toolbar.numbered_list', default: 'Numbered list')],
46
+ ['align:left', senren_t('rich_text_editor_lite.toolbar_short.left', default: 'Left'),
47
+ senren_t('rich_text_editor_lite.toolbar.align_left', default: 'Align left')],
48
+ ['align:center', senren_t('rich_text_editor_lite.toolbar_short.center', default: 'Center'),
49
+ senren_t('rich_text_editor_lite.toolbar.align_center', default: 'Align center')],
50
+ ['align:right', senren_t('rich_text_editor_lite.toolbar_short.right', default: 'Right'),
51
+ senren_t('rich_text_editor_lite.toolbar.align_right', default: 'Align right')],
52
+ ['align:justify', senren_t('rich_text_editor_lite.toolbar_short.justify', default: 'Justify'),
53
+ senren_t('rich_text_editor_lite.toolbar.justify', default: 'Justify')]
54
+ ]
55
+ end
56
+
26
57
  def initial_content
27
58
  value.presence || content.to_s.presence || "<p>#{ERB::Util.html_escape(placeholder)}</p>"
28
59
  end
@@ -4,11 +4,11 @@
4
4
  <% else %>
5
5
  <label class="sr-only" for="<%= name %>"><%= label %></label>
6
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">
7
+ <svg aria-hidden="true" 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
8
  <circle cx="11" cy="11" r="7"></circle>
9
9
  <path d="m20 20-3.5-3.5"></path>
10
10
  </svg>
11
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))]">
12
+ <input id="<%= name %>" name="<%= name %>" type="search" autocomplete="off" 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
13
  <% end %>
14
14
  <% end %>
@@ -5,7 +5,8 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(name: 'q', value: nil, placeholder: 'Search...', label: 'Search', class_name: nil, **html)
8
+ def initialize(name: 'q', value: nil, placeholder: senren_t('search_input.placeholder', default: 'Search...'),
9
+ label: senren_t('search_input.label', default: 'Search'), class_name: nil, **html)
9
10
  super(variant: :default, size: :md, class_name: class_name, **html)
10
11
  @name = name
11
12
  @value = value
@@ -1,18 +1,22 @@
1
- <div data-controller="senren--sheet" data-senren-component="sheet" id="<%= dom_id %>">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both -- which is why a Stimulus
3
+ value could not be set from the server. %>
4
+ <%= tag.div(**wrapper_attrs(id: dom_id, data: { controller: "senren--sheet" })) do %>
2
5
  <% if trigger? %>
3
6
  <span data-action="click->senren--sheet#open" data-senren--sheet-target="trigger">
4
7
  <%= trigger %>
5
8
  </span>
6
9
  <% else %>
7
- <button type="button" data-action="click->senren--sheet#open" data-senren--sheet-target="trigger" class="hidden">Open</button>
10
+ <button type="button" data-action="click->senren--sheet#open" data-senren--sheet-target="trigger" class="hidden"><%= senren_t('shared.open', default: 'Open') %></button>
8
11
  <% end %>
9
12
 
10
13
  <div data-senren--sheet-target="overlay" hidden
14
+ data-action="click->senren--sheet#close"
11
15
  class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"></div>
12
16
 
13
17
  <div data-senren--sheet-target="panel" hidden role="dialog" aria-modal="true"
14
18
  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] %>">
19
+ class="<%= panel_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
20
  <div class="flex h-full flex-col p-6">
17
21
  <% if title? %>
18
22
  <h2 class="text-lg font-semibold"><%= title %></h2>
@@ -20,7 +24,12 @@
20
24
  <% if description? %>
21
25
  <p class="text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
22
26
  <% end %>
23
- <div class="mt-4 flex-1 overflow-auto">
27
+ <%# -mx-2 px-2 gives the scroll container room for focus rings.
28
+ overflow-auto clips whatever draws outside a child's border box,
29
+ and a ring does exactly that -- a focused input sitting flush
30
+ against the edge had its ring shaved off on both sides. The
31
+ negative margin cancels the padding, so nothing moves. %>
32
+ <div class="-mx-2 mt-4 flex-1 overflow-auto px-2">
24
33
  <%= body || content %>
25
34
  </div>
26
35
  <% if footer? %>
@@ -29,9 +38,9 @@
29
38
  </div>
30
39
  <% end %>
31
40
  </div>
32
- <button type="button" data-action="click->senren--sheet#close" aria-label="Close"
41
+ <button type="button" data-action="click->senren--sheet#close" aria-label="<%= senren_t('shared.close', default: 'Close') %>"
33
42
  class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
34
43
  <span aria-hidden="true">×</span>
35
44
  </button>
36
45
  </div>
37
- </div>
46
+ <% end %>
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Senren
4
+ # Two ancestor traps to know about, because neither is visible in this file
5
+ # and both look like a z-index bug from the outside:
6
+ #
7
+ # * A `z-index` on any positioned ancestor creates a stacking context, and
8
+ # a fixed overlay cannot escape one. The panel then competes only inside
9
+ # that context, so a sticky header with a lower z-index still covers it.
10
+ # `position: relative` alone is fine; it is the z-index that traps.
11
+ # * `transform`, `filter`, `backdrop-filter`, `will-change` and an opacity
12
+ # below 1 create a stacking context too, with the same effect.
13
+ #
14
+ # Symptom in both cases: raising the panel's z-index changes nothing.
4
15
  class SheetComponent < BaseComponent
5
16
  renders_one :trigger
6
17
  renders_one :title
@@ -1,6 +1,6 @@
1
- <span <%= tag.attributes(**root_attrs("inline-flex items-center gap-1 text-xs text-[hsl(var(--senren-muted-foreground))]")) %>>
1
+ <%= tag.span(**root_attrs("inline-flex items-center gap-1 text-xs text-[hsl(var(--senren-muted-foreground))]")) do %>
2
2
  <% (keys.presence || [content]).compact.each_with_index do |key, index| %>
3
3
  <% if index.positive? %><span aria-hidden="true">+</span><% end %>
4
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
5
  <% end %>
6
- </span>
6
+ <% end %>
@@ -1,8 +1,8 @@
1
- <aside <%= tag.attributes(**root_attrs("flex min-h-80 flex-col overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))] transition-[width] duration-300 ease-in-out", data: { controller: "senren--sidebar" })) %>>
1
+ <%= tag.aside(**root_attrs("flex min-h-80 flex-col overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))] transition-[width] duration-300 ease-in-out", data: { controller: "senren--sidebar" })) do %>
2
2
  <div class="mb-4 flex items-center justify-between gap-2 px-2">
3
3
  <div data-senren--sidebar-target="brand" class="truncate font-display text-sm font-semibold tracking-tight"><%= brand %></div>
4
- <button type="button" data-senren--sidebar-target="toggleButton" class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-[hsl(var(--senren-muted-foreground))] transition-colors hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-accent-foreground))]" data-action="click->senren--sidebar#toggle" aria-label="Toggle sidebar" aria-expanded="<%= variant != :compact %>">
5
- <span class="sr-only">Toggle sidebar</span>
4
+ <button type="button" data-senren--sidebar-target="toggleButton" class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-[hsl(var(--senren-muted-foreground))] transition-colors hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-accent-foreground))]" data-action="click->senren--sidebar#toggle" aria-label="<%= toggle_label %>" aria-expanded="<%= variant != :compact %>">
5
+ <span class="sr-only"><%= toggle_label %></span>
6
6
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
7
7
  <path d="M3 6h18" />
8
8
  <path d="M3 12h18" />
@@ -21,4 +21,4 @@
21
21
  <% if content? %>
22
22
  <div data-senren--sidebar-target="footer" class="mt-auto pt-4 text-xs text-[hsl(var(--senren-muted-foreground))]"><%= content %></div>
23
23
  <% end %>
24
- </aside>
24
+ <% end %>
@@ -8,7 +8,8 @@ module Senren
8
8
  }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(items: [], brand: 'Senren', variant: :default, label: 'Primary', class_name: nil, **html)
11
+ def initialize(items: [], brand: senren_t('sidebar.brand', default: 'Senren'), variant: :default,
12
+ label: senren_t('sidebar.label', default: 'Primary'), class_name: nil, **html)
12
13
  super(variant: variant, size: :md, class_name: class_name, **html)
13
14
  @items = normalize_items(items)
14
15
  @brand = brand
@@ -17,6 +18,8 @@ module Senren
17
18
 
18
19
  attr_reader :items, :brand, :label
19
20
 
21
+ def toggle_label = senren_t('sidebar.toggle', default: 'Toggle sidebar')
22
+
20
23
  private
21
24
 
22
25
  def normalize_items(items)
@@ -1,6 +1,6 @@
1
1
  <%= tag.label(**root_attrs("inline-flex items-center gap-3 cursor-pointer")) do %>
2
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">
3
+ <input type="checkbox" role="switch" id="<%= id %>" name="<%= name %>" value="<%= value %>" <% if checked %>checked<% end %> class="peer sr-only">
4
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
5
  </span>
6
6
  <% if label %>
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-full overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))]")) %>>
1
+ <%= tag.div(**root_attrs("relative w-full overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))]")) do %>
2
2
  <table class="w-full border-collapse text-left text-sm <%= self.class::VARIANTS[variant] %>">
3
3
  <% if caption.present? %>
4
4
  <caption class="sr-only"><%= caption %></caption>
@@ -23,4 +23,4 @@
23
23
  <% end %>
24
24
  </tbody>
25
25
  </table>
26
- </div>
26
+ <% end %>
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-full", data: { controller: "senren--tabs" })) %>>
1
+ <%= tag.div(**root_attrs("w-full", data: { controller: "senren--tabs" })) do %>
2
2
  <div role="tablist" aria-label="<%= label %>" class="<%= self.class::VARIANTS[variant] %> flex flex-wrap items-center gap-1">
3
3
  <% items.each do |item| %>
4
4
  <% selected = active_item?(item) %>
@@ -10,9 +10,9 @@
10
10
  <div class="mt-4">
11
11
  <% items.each do |item| %>
12
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] %>" data-state="<%= selected ? "active" : "inactive" %>" <%= "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))]">
13
+ <section id="<%= item[:id] %>-panel" role="tabpanel" aria-labelledby="<%= item[:id] %>-tab" data-senren--tabs-target="panel" data-panel-id="<%= item[:id] %>" data-state="<%= selected ? "active" : "inactive" %>" <% unless selected %>hidden<% end %> 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
14
  <%= item[:content].presence || content %>
15
15
  </section>
16
16
  <% end %>
17
17
  </div>
18
- </div>
18
+ <% end %>
@@ -8,7 +8,8 @@ module Senren
8
8
  }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(items: [], variant: :default, active: nil, label: 'Tabs', class_name: nil, **html)
11
+ def initialize(items: [], variant: :default, active: nil, label: senren_t('tabs.label', default: 'Tabs'),
12
+ class_name: nil, **html)
12
13
  super(variant: variant, size: :md, class_name: class_name, **html)
13
14
  @items = normalize_items(items)
14
15
  @active = active&.to_s || @items.first&.fetch(:id, nil)
@@ -26,7 +27,8 @@ module Senren
26
27
  def normalize_items(items)
27
28
  Array(items).map.with_index do |item, index|
28
29
  source = item.is_a?(Hash) ? item : { label: item.to_s }
29
- label = source[:label] || source['label'] || "Tab #{index + 1}"
30
+ label = source[:label] || source['label'] ||
31
+ senren_t('tabs.tab', default: 'Tab %{number}', number: index + 1)
30
32
  id = (source[:id] || source['id'] || label.to_s.parameterize).to_s
31
33
  { id: id, label: label, content: source[:content] || source['content'] }
32
34
  end
@@ -1,4 +1,18 @@
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>
1
+ <%# Two icons, one hidden, rather than one element whose text is rewritten. %>
2
+ <%# The old version wrote the letters "D" and "L" into a span, which read as %>
3
+ <%# literal capital letters next to the label -- and to a screen reader, as %>
4
+ <%# "D Dark theme" if the aria-hidden ever came off. %>
5
+ <%= tag.button(**root_attrs("group 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))]", type: :button, data: { controller: "senren--theme-toggle", action: "click->senren--theme-toggle#toggle", "senren--theme-toggle-light-label-value": light_label, "senren--theme-toggle-dark-label-value": dark_label }, aria: { label: label, pressed: "false" })) do %>
6
+ <span aria-hidden="true" data-senren--theme-toggle-target="iconLight" hidden class="inline-flex">
7
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 transition-transform duration-200 group-hover:rotate-45">
8
+ <circle cx="12" cy="12" r="4"></circle>
9
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
10
+ </svg>
11
+ </span>
12
+ <span aria-hidden="true" data-senren--theme-toggle-target="iconDark" class="inline-flex">
13
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 transition-transform duration-200 group-hover:-rotate-12">
14
+ <path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
15
+ </svg>
16
+ </span>
3
17
  <span data-senren--theme-toggle-target="label"><%= label %></span>
4
- </button>
18
+ <% end %>
@@ -5,11 +5,16 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(label: 'Toggle theme', class_name: nil, **html)
8
+ def initialize(label: senren_t('theme_toggle.label', default: 'Toggle theme'), class_name: nil, **html)
9
9
  super(variant: :default, size: :md, class_name: class_name, **html)
10
10
  @label = label
11
11
  end
12
12
 
13
13
  attr_reader :label
14
+
15
+ # What the label reads once the theme has switched. The controller swaps
16
+ # between the two, so both reach it as Stimulus values.
17
+ def light_label = senren_t('theme_toggle.light', default: 'Light theme')
18
+ def dark_label = senren_t('theme_toggle.dark', default: 'Dark theme')
14
19
  end
15
20
  end
@@ -1,9 +1,11 @@
1
- <span data-controller="senren--tooltip" class="relative inline-block" data-senren-component="tooltip">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both. %>
3
+ <%= tag.span(**wrapper_attrs("relative inline-block", data: { controller: "senren--tooltip" })) do %>
2
4
  <span tabindex="0"
3
5
  aria-describedby="<%= id %>"
4
6
  data-action="mouseenter->senren--tooltip#show mouseleave->senren--tooltip#hide focus->senren--tooltip#show blur->senren--tooltip#hide">
5
7
  <%= content %>
6
8
  </span>
7
9
  <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>
10
+ class="<%= panel_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>
11
+ <% end %>
@@ -1,7 +1,7 @@
1
- <header <%= tag.attributes(**root_attrs("sticky top-0 z-30 border-b border-[hsl(var(--senren-border))] backdrop-blur-md")) %>>
1
+ <%= tag.header(**root_attrs("sticky top-0 z-30 border-b border-[hsl(var(--senren-border))] backdrop-blur-md")) do %>
2
2
  <div class="mx-auto flex h-14 w-full max-w-[1400px] items-center gap-6 px-4 lg:px-8">
3
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 %>
4
+ <% if brand? %><%= brand %><% else %><%= senren_t('top_nav.brand', default: 'Senren') %><% end %>
5
5
  </div>
6
6
 
7
7
  <% if items.any? %>
@@ -18,4 +18,4 @@
18
18
  <div class="ml-auto flex items-center gap-2"><%= actions %></div>
19
19
  <% end %>
20
20
  </div>
21
- </header>
21
+ <% end %>
@@ -6,12 +6,13 @@ module Senren
6
6
  renders_one :actions
7
7
 
8
8
  VARIANTS = {
9
- default: 'bg-[hsl(var(--senren-background))/0.88]',
9
+ default: 'bg-[hsl(var(--senren-background)/0.88)]',
10
10
  solid: 'bg-[hsl(var(--senren-card))]'
11
11
  }.freeze
12
12
  SIZES = { md: '' }.freeze
13
13
 
14
- def initialize(items: [], current_path: nil, label: 'Global', variant: :default, class_name: nil, **html)
14
+ def initialize(items: [], current_path: nil, label: senren_t('top_nav.label', default: 'Global'), variant: :default,
15
+ class_name: nil, **html)
15
16
  super(variant: variant, size: :md, class_name: class_name, **html)
16
17
  @items = normalize_items(items)
17
18
  @current_path = current_path