senren-ui 0.3.0 → 0.4.1

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -0
  3. data/CONTRIBUTING.md +10 -0
  4. data/README.md +36 -0
  5. data/docs/i18n.md +194 -0
  6. data/lib/commands/senren/add/add_command.rb +4 -1
  7. data/lib/commands/senren/locales/locales_command.rb +38 -0
  8. data/lib/generators/senren/install/install_generator.rb +36 -0
  9. data/lib/generators/senren/install/templates/base_component.rb.tt +39 -0
  10. data/lib/generators/senren/install/templates/conventions.md.tt +16 -0
  11. data/lib/generators/senren/install/templates/installed_components.yml.tt +1 -1
  12. data/lib/senren/rails/agent_rules_writer.rb +4 -0
  13. data/lib/senren/rails/base_component_patch.rb +24 -0
  14. data/lib/senren/rails/component_copier.rb +21 -0
  15. data/lib/senren/rails/doctor.rb +5 -1
  16. data/lib/senren/rails/host_paths.rb +5 -0
  17. data/lib/senren/rails/locale_installer.rb +90 -0
  18. data/lib/senren/rails/version.rb +1 -1
  19. data/lib/senren/rails.rb +1 -0
  20. data/lib/tasks/senren.rake +9 -0
  21. data/templates/components/accordion/accordion_component.html.erb +22 -4
  22. data/templates/components/accordion/accordion_component.rb +2 -1
  23. data/templates/components/alert_dialog/alert_dialog_component.html.erb +1 -1
  24. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  25. data/templates/components/api_key_field/api_key_field_component.rb +8 -1
  26. data/templates/components/app_shell/app_shell_component.rb +2 -1
  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/calendar/calendar_component.rb +37 -2
  31. data/templates/components/carousel/carousel_component.html.erb +5 -5
  32. data/templates/components/carousel/carousel_component.rb +12 -1
  33. data/templates/components/cart/cart_component.html.erb +6 -6
  34. data/templates/components/cart/cart_component.rb +11 -2
  35. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  36. data/templates/components/clipboard/clipboard_component.rb +6 -1
  37. data/templates/components/collapsible/collapsible_component.rb +1 -1
  38. data/templates/components/combobox/combobox_component.rb +2 -1
  39. data/templates/components/command/command_component.rb +3 -1
  40. data/templates/components/data_table/data_table_component.html.erb +3 -3
  41. data/templates/components/data_table/data_table_component.rb +16 -1
  42. data/templates/components/date_picker/date_picker_component.html.erb +2 -2
  43. data/templates/components/date_picker/date_picker_component.rb +2 -1
  44. data/templates/components/dialog/dialog_component.html.erb +2 -2
  45. data/templates/components/filter_bar/filter_bar_component.rb +1 -1
  46. data/templates/components/form/form_component.html.erb +1 -1
  47. data/templates/components/form/form_component.rb +21 -0
  48. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +9 -8
  49. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +11 -3
  50. data/templates/components/native_select/native_select_component.rb +12 -3
  51. data/templates/components/pagination/pagination_component.html.erb +2 -2
  52. data/templates/components/pagination/pagination_component.rb +2 -1
  53. data/templates/components/product_card/product_card_component.html.erb +1 -1
  54. data/templates/components/product_card/product_card_component.rb +2 -2
  55. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +2 -16
  56. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +32 -1
  57. data/templates/components/search_input/search_input_component.rb +2 -1
  58. data/templates/components/sheet/sheet_component.html.erb +2 -2
  59. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  60. data/templates/components/sidebar/sidebar_component.rb +4 -1
  61. data/templates/components/table/table_component.html.erb +1 -1
  62. data/templates/components/tabs/tabs_component.rb +4 -2
  63. data/templates/components/theme_toggle/theme_toggle_component.html.erb +17 -3
  64. data/templates/components/theme_toggle/theme_toggle_component.rb +6 -1
  65. data/templates/components/top_nav/top_nav_component.html.erb +1 -1
  66. data/templates/components/top_nav/top_nav_component.rb +3 -2
  67. data/templates/controllers/accordion_controller.js +68 -3
  68. data/templates/controllers/api_key_field_controller.js +3 -2
  69. data/templates/controllers/carousel_controller.js +6 -1
  70. data/templates/controllers/clipboard_controller.js +3 -3
  71. data/templates/controllers/rich_text_editor_lite_controller.js +2 -2
  72. data/templates/controllers/theme_toggle_controller.js +13 -3
  73. data/templates/locales/senren.en.yml +151 -0
  74. data/templates/locales/senren.vi.yml +151 -0
  75. metadata +6 -1
@@ -1,4 +1,4 @@
1
- <%= tag.div(**root_attrs("overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] shadow-sm", data: { controller: "senren--data-table" })) do %>
1
+ <%= tag.div(**root_attrs("relative overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] shadow-sm", data: { controller: "senren--data-table" })) do %>
2
2
  <% if toolbar? %>
3
3
  <div class="border-b border-[hsl(var(--senren-border))] p-3"><%= toolbar %></div>
4
4
  <% end %>
@@ -18,7 +18,7 @@
18
18
  <% if sortable? %>
19
19
  <button type="button" class="inline-flex cursor-pointer items-center gap-1 rounded-sm hover:text-[hsl(var(--senren-foreground))]" data-action="click->senren--data-table#sort" data-sort-key="<%= column_sort_key(column) %>">
20
20
  <%= column_label(column) %>
21
- <span aria-hidden="true">Sort</span>
21
+ <span aria-hidden="true"><%= senren_t('data_table.sort', default: 'Sort') %></span>
22
22
  </button>
23
23
  <% else %>
24
24
  <%= column_label(column) %>
@@ -32,7 +32,7 @@
32
32
  <tr class="transition-colors hover:bg-[hsl(var(--senren-muted)/0.35)]" data-senren--data-table-target="row">
33
33
  <% columns.each do |column| %>
34
34
  <% value = cell_value(row, column) %>
35
- <td class="px-4 py-3" data-sort-key="<%= column_sort_key(column) %>" data-sort-value="<%= value %>"><%= value %></td>
35
+ <td class="px-4 py-3" data-sort-key="<%= column_sort_key(column) %>" data-sort-value="<%= sort_value(value) %>"><%= value %></td>
36
36
  <% end %>
37
37
  </tr>
38
38
  <% end %>
@@ -6,7 +6,8 @@ module Senren
6
6
  VARIANTS = { default: '' }.freeze
7
7
  SIZES = { md: '' }.freeze
8
8
 
9
- def initialize(columns: [], rows: [], caption: nil, empty_text: 'No records found.', sortable: true,
9
+ def initialize(columns: [], rows: [], caption: nil,
10
+ empty_text: senren_t('data_table.empty', default: 'No records found.'), sortable: true,
10
11
  variant: :default, class_name: nil, **html)
11
12
  super(variant: variant, size: :md, class_name: class_name, **html)
12
13
  @columns = Array(columns)
@@ -33,6 +34,20 @@ module Senren
33
34
  column_key(column).to_s
34
35
  end
35
36
 
37
+ # The text a cell shows, for its data-sort-value. A cell holding markup (a
38
+ # badge, a link) is html_safe, which ERB does not escape, so writing it
39
+ # into the attribute as-is pasted the markup inside the quotes and broke
40
+ # the <td>. Its decoded text is a plain String, which ERB escapes once.
41
+ #
42
+ # Asks the string, not the value: ERB decides on `to_s.html_safe?`, so an
43
+ # object whose to_s returns markup breaks the attribute just the same.
44
+ def sort_value(value)
45
+ text = value.to_s
46
+ return text unless text.html_safe?
47
+
48
+ Nokogiri::HTML5.fragment(text.to_str).text
49
+ end
50
+
36
51
  private
37
52
 
38
53
  def column_key(column)
@@ -1,5 +1,5 @@
1
1
  <%= tag.div(**root_attrs("flex w-full items-center gap-2", data: { controller: "senren--date-picker" })) do %>
2
2
  <input type="date" autocomplete="off" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="h-10 w-full max-w-56 cursor-pointer rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none transition-colors focus:ring-2 focus:ring-[hsl(var(--senren-ring))] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-60 [&::-webkit-calendar-picker-indicator]:hover:opacity-100 <%= self.class::VARIANTS[variant] %>">
3
- <button type="button" data-action="click->senren--date-picker#today" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]">Today</button>
4
- <button type="button" data-action="click->senren--date-picker#clear" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-3 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))]">Clear</button>
3
+ <button type="button" data-action="click->senren--date-picker#today" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]"><%= senren_t('date_picker.today', default: 'Today') %></button>
4
+ <button type="button" data-action="click->senren--date-picker#clear" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-3 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))]"><%= senren_t('date_picker.clear', default: 'Clear') %></button>
5
5
  <% end %>
@@ -8,7 +8,8 @@ module Senren
8
8
  }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(name:, value: nil, id: nil, placeholder: 'Select date', variant: :default, class_name: nil, **html)
11
+ def initialize(name:, value: nil, id: nil, placeholder: senren_t('date_picker.placeholder', default: 'Select date'),
12
+ variant: :default, class_name: nil, **html)
12
13
  super(variant: variant, size: :md, class_name: class_name, **html)
13
14
  @name = name
14
15
  @value = value
@@ -8,7 +8,7 @@
8
8
  </span>
9
9
  <% else %>
10
10
  <button type="button" data-action="click->senren--dialog#open"
11
- class="hidden" data-senren--dialog-target="trigger" aria-controls="<%= dom_id %>-panel">Open</button>
11
+ class="hidden" data-senren--dialog-target="trigger" aria-controls="<%= dom_id %>-panel"><%= senren_t('shared.open', default: 'Open') %></button>
12
12
  <% end %>
13
13
 
14
14
  <div data-senren--dialog-target="overlay" hidden
@@ -34,7 +34,7 @@
34
34
  <% end %>
35
35
 
36
36
  <button type="button" data-action="click->senren--dialog#close"
37
- aria-label="Close"
37
+ aria-label="<%= senren_t('shared.close', default: 'Close') %>"
38
38
  class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
39
39
  <span aria-hidden="true">×</span>
40
40
  </button>
@@ -5,7 +5,7 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(label: 'Filters', class_name: nil, **html)
8
+ def initialize(label: senren_t('filter_bar.label', default: 'Filters'), class_name: nil, **html)
9
9
  super(variant: :default, size: :md, class_name: class_name, **html)
10
10
  @label = label
11
11
  end
@@ -6,5 +6,5 @@
6
6
  form_opts[:multipart] = multipart if multipart
7
7
  %>
8
8
  <%= form_with(**form_opts, **root_attrs("space-y-4")) do |f| %>
9
- <%= content || capture(f, &Proc.new) %>
9
+ <%= form_body(f) %>
10
10
  <% end %>
@@ -17,6 +17,27 @@ module Senren
17
17
 
18
18
  attr_reader :model, :method, :multipart
19
19
 
20
+ # ViewComponent's `content` calls the render block with the component as
21
+ # its argument, so `|f|` would be this component, not the form builder.
22
+ # And because a component is an ActionView::Base, `f.text_field :title,
23
+ # class: "x"` would not raise: it would reach the template-level helper and
24
+ # render `name="title[{class: "x"}]"`. Keep the block and call it with the
25
+ # builder form_with yields instead.
26
+ def render_in(view_context, **, &block)
27
+ @form_block = block
28
+ super
29
+ end
30
+
31
+ # Mirrors ViewComponent's own `content`: the block runs under the caller's
32
+ # virtual path, so relative `t('.key')` lookups inside it resolve against
33
+ # the caller's template. (A block and with_content together never get
34
+ # here: ViewComponent raises DuplicateContentError first.)
35
+ def form_body(builder)
36
+ return content unless @form_block
37
+
38
+ with_captured_virtual_path(@old_virtual_path) { view_context.capture(builder, &@form_block) }
39
+ end
40
+
20
41
  # This one reaches form_with's `action`, which makes it the highest-value
21
42
  # URL sink in the library: `//evil.example` is a protocol-relative absolute
22
43
  # URL, so a form built from user-controlled input POSTs every field —
@@ -19,18 +19,19 @@
19
19
  <% if content? %>
20
20
  <%= content %>
21
21
  <% else %>
22
- <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-email">Email</label>
23
- <input id="<%= dom_id %>-email" name="<%= email_name %>" type="email" autocomplete="email" placeholder="teammate@company.com" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-3 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
24
- <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-role">Role</label>
25
- <select id="<%= dom_id %>-role" name="<%= role_name %>" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-3 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
26
- <% roles.each do |role| %><option><%= role %></option><% end %>
27
- </select>
22
+ <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-email"><%= senren_t('invite_member_dialog.email', default: 'Email') %></label>
23
+ <input id="<%= dom_id %>-email" name="<%= email_name %>" type="email" autocomplete="email" placeholder="<%= senren_t('invite_member_dialog.email_placeholder', default: 'teammate@company.com') %>" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-3 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
24
+ <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-role"><%= senren_t('invite_member_dialog.role', default: 'Role') %></label>
25
+ <%# Was a hand-written <select> with the styling copied out of %>
26
+ <%# NativeSelectComponent, which meant it silently missed anything that %>
27
+ <%# component gained later -- the chevron and its rotation among them. %>
28
+ <%= render Senren::NativeSelectComponent.new(name: role_name, id: "#{dom_id}-role", options: roles) %>
28
29
  <% end %>
29
30
  </div>
30
31
 
31
32
  <div class="mt-6 flex justify-end gap-2">
32
- <button type="button" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-4 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]" data-action="click->senren--invite-member-dialog#close">Cancel</button>
33
- <% if footer? %><%= footer %><% else %><button type="submit" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90">Send invite</button><% end %>
33
+ <button type="button" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-4 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]" data-action="click->senren--invite-member-dialog#close"><%= senren_t('invite_member_dialog.cancel', default: 'Cancel') %></button>
34
+ <% if footer? %><%= footer %><% else %><button type="submit" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90"><%= senren_t('invite_member_dialog.send', default: 'Send invite') %></button><% end %>
34
35
  </div>
35
36
  </div>
36
37
  <% end %>
@@ -8,9 +8,17 @@ module Senren
8
8
  VARIANTS = { default: '' }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(title: 'Invite teammate', description: 'Send an invitation to join this workspace.',
12
- email_name: 'email', role_name: 'role', roles: %w[Member
13
- Admin], button_label: 'Invite member', id: nil, class_name: nil, **html)
11
+ # The role option values stay `Member` and `Admin`, because that is what the
12
+ # form submits and what the host's code compares against. Only the label a
13
+ # person reads is translated.
14
+ def initialize(title: senren_t('invite_member_dialog.title', default: 'Invite teammate'),
15
+ description: senren_t('invite_member_dialog.description',
16
+ default: 'Send an invitation to join this workspace.'),
17
+ email_name: 'email', role_name: 'role',
18
+ roles: [['Member', senren_t('invite_member_dialog.role_member', default: 'Member')],
19
+ ['Admin', senren_t('invite_member_dialog.role_admin', default: 'Admin')]],
20
+ button_label: senren_t('invite_member_dialog.button', default: 'Invite member'),
21
+ id: nil, class_name: nil, **html)
14
22
  super(variant: :default, size: :md, class_name: class_name, **html)
15
23
  @title = title
16
24
  @description = description
@@ -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
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 } %>
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
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)
@@ -4,7 +4,7 @@
4
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
 
@@ -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,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
@@ -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
@@ -7,7 +7,7 @@
7
7
  <%= trigger %>
8
8
  </span>
9
9
  <% else %>
10
- <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>
11
11
  <% end %>
12
12
 
13
13
  <div data-senren--sheet-target="overlay" hidden
@@ -38,7 +38,7 @@
38
38
  </div>
39
39
  <% end %>
40
40
  </div>
41
- <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') %>"
42
42
  class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
43
43
  <span aria-hidden="true">×</span>
44
44
  </button>
@@ -1,8 +1,8 @@
1
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" />
@@ -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,4 +1,4 @@
1
- <%= tag.div(**root_attrs("w-full overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))]")) do %>
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>
@@ -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,7 +1,7 @@
1
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? %>
@@ -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
@@ -6,6 +6,19 @@ export default class extends Controller {
6
6
  static targets = ["trigger", "panel"]
7
7
  static values = { multiple: Boolean }
8
8
 
9
+ // Every pending close timer, so disconnect() can clear them. A panel mid-close
10
+ // when Turbo swaps the page would otherwise leave a timer holding a reference
11
+ // to a detached element -- caught by bin/performance, which fails the build
12
+ // for a timer no disconnect() clears.
13
+ connect() {
14
+ this._timers = new Set()
15
+ }
16
+
17
+ disconnect() {
18
+ this._timers?.forEach(clearTimeout)
19
+ this._timers?.clear()
20
+ }
21
+
9
22
  toggle(event) {
10
23
  const trigger = event.currentTarget
11
24
  const panel = this._panelFor(trigger.dataset.panelId)
@@ -13,12 +26,64 @@ export default class extends Controller {
13
26
 
14
27
  if (!this.multipleValue) this._closeAll()
15
28
  trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false")
16
- if (panel) panel.hidden = !nextOpen
29
+ this._setOpen(panel, nextOpen)
30
+ }
31
+
32
+ // The panel animates from grid-template-rows 0fr to 1fr, and it also carries
33
+ // the `hidden` attribute so collapsed content is not focusable or read aloud.
34
+ // Those two want opposite things: `hidden` has to be off for the browser to
35
+ // animate anything, and on once the panel is closed.
36
+ //
37
+ // Opening: drop `hidden`, then flip the row on the next frame -- setting both
38
+ // in one go gives the browser no first value to animate from, and the panel
39
+ // snaps open.
40
+ //
41
+ // Closing: flip the row now, restore `hidden` when the transition ends.
42
+ _setOpen(panel, open) {
43
+ if (!panel) return
44
+
45
+ if (open) {
46
+ panel.hidden = false
47
+ requestAnimationFrame(() => {
48
+ panel.classList.remove("grid-rows-[0fr]")
49
+ panel.classList.add("grid-rows-[1fr]")
50
+ })
51
+ return
52
+ }
53
+
54
+ panel.classList.remove("grid-rows-[1fr]")
55
+ panel.classList.add("grid-rows-[0fr]")
56
+ this._hideAfterTransition(panel)
57
+ }
58
+
59
+ // `transitionend` alone is not enough: it never fires when the panel is
60
+ // already collapsed, when a reduced-motion setting removes the transition, or
61
+ // when the element is off-screen. Without the timeout those panels would stay
62
+ // focusable forever, so whichever arrives first wins and the other is dropped.
63
+ _hideAfterTransition(panel) {
64
+ const finish = () => {
65
+ clearTimeout(timer)
66
+ this._timers?.delete(timer)
67
+ panel.removeEventListener("transitionend", onEnd)
68
+ if (panel.classList.contains("grid-rows-[0fr]")) panel.hidden = true
69
+ }
70
+
71
+ const onEnd = (event) => {
72
+ if (event.target === panel && event.propertyName === "grid-template-rows") finish()
73
+ }
74
+
75
+ const timer = setTimeout(finish, 300)
76
+ this._timers?.add(timer)
77
+ panel.addEventListener("transitionend", onEnd)
17
78
  }
18
79
 
19
80
  _closeAll() {
20
- this.triggerTargets.forEach((trigger) => { trigger.setAttribute("aria-expanded", "false") })
21
- this.panelTargets.forEach((panel) => { panel.hidden = true })
81
+ this.triggerTargets.forEach((trigger) => {
82
+ trigger.setAttribute("aria-expanded", "false")
83
+ })
84
+ this.panelTargets.forEach((panel) => {
85
+ this._setOpen(panel, false)
86
+ })
22
87
  }
23
88
 
24
89
  _panelFor(id) {