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
@@ -59,6 +59,30 @@ module Senren
59
59
  end
60
60
  end
61
61
  RUBY
62
+
63
+ # Ruby appended to a host app's existing BaseComponent when it predates
64
+ # senren_t. A component copied by `senren:add` calls it, so without this a
65
+ # host that upgraded the gem and added one component would get a
66
+ # NoMethodError the first time that component rendered.
67
+ #
68
+ # Same arrangement as URL_HELPERS: apps installed earlier never receive the
69
+ # template again, so the helper is duplicated here, and
70
+ # test/i18n_catalog_test.rb pins the two bodies together.
71
+ TRANSLATION_HELPER = <<~'RUBY'
72
+
73
+ # Added by senren:add so components that translate their text can render.
74
+ require 'active_support/i18n'
75
+
76
+ module Senren
77
+ class BaseComponent
78
+ def senren_t(key, default:, **options)
79
+ I18n.t("senren.#{key}", default: default, **options)
80
+ rescue I18n::MissingInterpolationArgument
81
+ I18n.interpolate(default, options)
82
+ end
83
+ end
84
+ end
85
+ RUBY
62
86
  end
63
87
  end
64
88
  end
@@ -19,6 +19,7 @@ module Senren
19
19
  ).freeze
20
20
  BASE_COMPONENT_TEMPLATE = File.join(INSTALL_GENERATOR_TEMPLATES, 'base_component.rb.tt').freeze
21
21
  BASE_URL_HELPER_PATCH = BaseComponentPatch::URL_HELPERS
22
+ BASE_TRANSLATION_HELPER_PATCH = BaseComponentPatch::TRANSLATION_HELPER
22
23
 
23
24
  attr_reader :registry, :paths, :stdout
24
25
 
@@ -35,6 +36,7 @@ module Senren
35
36
  validate_client_override!(component_names, client_override)
36
37
  paths.ensure_dirs!
37
38
  ensure_base_component_url_helpers!
39
+ ensure_base_component_translation_helper!
38
40
 
39
41
  requested = Array(component_names).map(&:to_s)
40
42
  wanted.each do |name|
@@ -85,6 +87,25 @@ module Senren
85
87
  copy_file(BASE_COMPONENT_TEMPLATE, dest, force: false, label: 'base_component.rb')
86
88
  end
87
89
 
90
+ # A component that translates its text calls senren_t, so a host whose
91
+ # BaseComponent predates it needs the helper before the component renders.
92
+ # Runs after the URL helper step, which copies the whole current template
93
+ # (helper included) when there is no base component at all, so in that
94
+ # case there is nothing left to add.
95
+ #
96
+ # A destination that escapes the app root was already reported by the URL
97
+ # step, so this one declines without saying it a second time.
98
+ def ensure_base_component_translation_helper!
99
+ dest = paths.base_component_path
100
+
101
+ return unless SafeWrite.inside?(dest, paths.root)
102
+ return unless dest.exist?
103
+ return if dest.read.include?('def senren_t')
104
+
105
+ SafeWrite.write!(dest, dest.read + BASE_TRANSLATION_HELPER_PATCH, paths.root, 'base_component.rb')
106
+ stdout.puts " update #{dest} (senren_t)"
107
+ end
108
+
88
109
  # Delegates to SafeWrite so an intermediate symlinked directory is caught,
89
110
  # not just a symlinked leaf. `app/components/senren -> /elsewhere` used to
90
111
  # pass every check here.
@@ -24,6 +24,11 @@ module Senren
24
24
 
25
25
  def stylesheet_path = root.join('app', 'assets', 'stylesheets', 'senren.css')
26
26
 
27
+ # Where the host's translations of component text live. One file per
28
+ # locale, so a team adds a language by adding a file.
29
+ def locales_dir = root.join('config', 'locales')
30
+ def locale_file(locale) = locales_dir.join("senren.#{locale}.yml")
31
+
27
32
  def stimulus_dir = root.join('app', 'javascript', 'controllers', 'senren')
28
33
 
29
34
  def github_dir = root.join('.github')
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'senren/rails/safe_write'
4
+
5
+ module Senren
6
+ module Rails
7
+ # Copies the translations the gem ships into the host app's config/locales.
8
+ #
9
+ # The files are not loaded from the gem. A Rails engine auto-loads
10
+ # `config/locales` from its own root, which would put `:vi` into
11
+ # `I18n.available_locales` of every app that merely bundles senren-ui. They
12
+ # are source, like the components: copied once, then the host's to edit.
13
+ #
14
+ # Nothing is needed for English to work. Every component carries its English
15
+ # as the `default:` of the `senren_t` call, so an app with no locale file at
16
+ # all renders what it always did. That is also why English is not copied
17
+ # unless asked for: Rails loads config/locales in name order, so a copied
18
+ # senren.en.yml loads after the host's en.yml and overrides any senren key
19
+ # set there, and it pins the wording, so a default the gem later improves
20
+ # never reaches the app. Ask for `en` to get a copy to translate from.
21
+ #
22
+ # An existing file is never overwritten without `force:`: it is the host's
23
+ # translation, and a re-run of the installer must not undo it.
24
+ class LocaleInstaller
25
+ LOCALE_PATTERN = /\A[a-z]{2,3}(?:-[A-Za-z0-9]+)*\z/
26
+
27
+ attr_reader :paths, :stdout
28
+
29
+ def self.source_dir
30
+ File.join(Senren::Rails.templates_root, 'locales')
31
+ end
32
+
33
+ # The locales the gem ships a file for.
34
+ def self.available
35
+ Dir[File.join(source_dir, 'senren.*.yml')].map { |path| File.basename(path).split('.')[1] }.sort
36
+ end
37
+
38
+ def initialize(paths: HostPaths.new, stdout: $stdout)
39
+ @paths = paths
40
+ @stdout = stdout
41
+ end
42
+
43
+ # Returns the locales actually written. Every name is checked before any
44
+ # file is, so a typo in the second does not leave the first half-installed.
45
+ def install(locales: [], force: false)
46
+ names = Array(locales).flatten.map(&:to_s).reject(&:empty?).uniq
47
+ if names.empty?
48
+ stdout.puts " skip no locale named (shipped: #{self.class.available.join(', ')}); English needs no file"
49
+ return []
50
+ end
51
+ names.each { |name| validate!(name) }
52
+
53
+ names.select { |name| copy_locale(name, force: force) }
54
+ end
55
+
56
+ private
57
+
58
+ def validate!(name)
59
+ unless LOCALE_PATTERN.match?(name)
60
+ raise ArgumentError, "#{name.inspect} is not a locale name. Expected something like en or pt-BR."
61
+ end
62
+ return if self.class.available.include?(name)
63
+
64
+ raise ArgumentError,
65
+ "Senren ships no #{name} translation (shipped: #{self.class.available.join(', ')}). " \
66
+ "To add #{name}, run bin/rails senren:locales en, copy config/locales/senren.en.yml to " \
67
+ "config/locales/senren.#{name}.yml, change the top-level key to #{name}:, and translate the values."
68
+ end
69
+
70
+ def copy_locale(name, force:)
71
+ source = File.join(self.class.source_dir, "senren.#{name}.yml")
72
+ dest = paths.locale_file(name)
73
+ label = "locale #{name}"
74
+
75
+ target = SafeWrite.resolve(dest, paths.root, label, io: stdout)
76
+ return false unless target
77
+
78
+ if File.exist?(target) && !force
79
+ stdout.puts " skip #{dest} (already exists; bin/rails senren:locales #{name} --force replaces it)"
80
+ return false
81
+ end
82
+
83
+ SafeWrite.mkdir_p!(paths.locales_dir, paths.root, label)
84
+ SafeWrite.copy!(source, dest, paths.root, label)
85
+ stdout.puts " copy #{dest}"
86
+ true
87
+ end
88
+ end
89
+ end
90
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Senren
4
4
  module Rails
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
data/lib/senren/rails.rb CHANGED
@@ -14,6 +14,7 @@ module Senren
14
14
  autoload :LlmsWriter, 'senren/rails/llms_writer'
15
15
  autoload :Doctor, 'senren/rails/doctor'
16
16
  autoload :HostPaths, 'senren/rails/host_paths'
17
+ autoload :LocaleInstaller, 'senren/rails/locale_installer'
17
18
  autoload :MarkerBlock, 'senren/rails/marker_block'
18
19
 
19
20
  def self.gem_root
@@ -18,6 +18,15 @@ namespace :senren do
18
18
  abort e.message
19
19
  end
20
20
 
21
+ desc 'Copy a shipped translation into config/locales. bin/rails senren:locales vi [--force]'
22
+ task :locales, [:names] do |_t, args|
23
+ options = SenrenRakeArgs.options
24
+
25
+ Senren::Rails::LocaleInstaller.new.install(locales: SenrenRakeArgs.names(args), force: options[:force])
26
+ rescue ArgumentError => e
27
+ abort e.message
28
+ end
29
+
21
30
  namespace :skill do
22
31
  desc 'Rebuild .senren/skill.md and refresh agent instruction adapters.'
23
32
  task sync: :environment do
@@ -1,16 +1,34 @@
1
- <div <%= tag.attributes(**root_attrs("divide-y divide-[hsl(var(--senren-border))] rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background))]", data: { controller: "senren--accordion", "senren--accordion-multiple-value": multiple? })) %>>
1
+ <%= tag.div(**root_attrs("divide-y divide-[hsl(var(--senren-border))] rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background))]", data: { controller: "senren--accordion", "senren--accordion-multiple-value": multiple? })) do %>
2
2
  <% items.each_with_index do |item, index| %>
3
3
  <% expanded = open_item?(item, index) %>
4
4
  <section>
5
5
  <h3>
6
- <button type="button" class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium text-[hsl(var(--senren-foreground))] transition-colors hover:bg-[hsl(var(--senren-muted)/0.5)]" aria-expanded="<%= expanded %>" aria-controls="<%= item[:id] %>-panel" data-senren--accordion-target="trigger" data-panel-id="<%= item[:id] %>" data-action="click->senren--accordion#toggle">
6
+ <button type="button" class="group flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium text-[hsl(var(--senren-foreground))] transition-colors hover:bg-[hsl(var(--senren-muted)/0.5)]" aria-expanded="<%= expanded %>" aria-controls="<%= item[:id] %>-panel" data-senren--accordion-target="trigger" data-panel-id="<%= item[:id] %>" data-action="click->senren--accordion#toggle">
7
7
  <span><%= item[:title] %></span>
8
- <span aria-hidden="true" class="text-[hsl(var(--senren-muted-foreground))]">+</span>
8
+ <%# Rotation is driven by aria-expanded, which the controller already %>
9
+ <%# maintains, so the icon cannot drift out of step with the state a %>
10
+ <%# screen reader is being told. A "+" was here before -- readable, but %>
11
+ <%# static, and it gave no hint that the panel animates. %>
12
+ <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 shrink-0 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-200 group-aria-expanded:rotate-180">
13
+ <path d="m5 8 5 5 5-5"></path>
14
+ </svg>
9
15
  </button>
10
16
  </h3>
11
- <div id="<%= item[:id] %>-panel" data-senren--accordion-target="panel" data-panel-id="<%= item[:id] %>" <%= "hidden" unless expanded %> class="px-4 pb-4 text-sm leading-6 text-[hsl(var(--senren-muted-foreground))]">
12
- <%= item[:content].presence || content %>
17
+ <%# The grid wrapper is what animates. A height transition needs a number %>
18
+ <%# to count to, and the content's height is not known until it is laid %>
19
+ <%# out; `grid-template-rows: 0fr -> 1fr` animates to the content's own %>
20
+ <%# height without measuring it in JavaScript. %>
21
+ <%# The `hidden` attribute stays, because content that is visually %>
22
+ <%# collapsed but present is still focusable and still read aloud. The %>
23
+ <%# controller removes it before opening and restores it after the close %>
24
+ <%# transition ends, so the animation never costs the semantics. %>
25
+ <div id="<%= item[:id] %>-panel" data-senren--accordion-target="panel" data-panel-id="<%= item[:id] %>" <% unless expanded %>hidden<% end %> class="grid transition-[grid-template-rows] duration-200 ease-out <%= expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' %>">
26
+ <div class="overflow-hidden">
27
+ <div class="px-4 pb-4 text-sm leading-6 text-[hsl(var(--senren-muted-foreground))]">
28
+ <%= item[:content].presence || content %>
29
+ </div>
30
+ </div>
13
31
  </div>
14
32
  </section>
15
33
  <% end %>
16
- </div>
34
+ <% end %>
@@ -22,7 +22,8 @@ module Senren
22
22
  def normalize_items(items)
23
23
  Array(items).map.with_index do |item, index|
24
24
  source = item.is_a?(Hash) ? item : { title: item.to_s }
25
- title = source[:title] || source['title'] || "Section #{index + 1}"
25
+ title = source[:title] || source['title'] ||
26
+ senren_t('accordion.section', default: 'Section %{number}', number: index + 1)
26
27
  id = (source[:id] || source['id'] || title.to_s.parameterize).to_s
27
28
  { id: id, title: title, content: source[:content] || source['content'] }
28
29
  end
@@ -1,4 +1,7 @@
1
- <div data-controller="senren--alert-dialog" data-senren-component="alert_dialog" 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--alert-dialog" })) do %>
2
5
  <% if trigger? %>
3
6
  <span data-action="click->senren--alert-dialog#open" data-senren--alert-dialog-target="trigger">
4
7
  <%= trigger %>
@@ -6,7 +9,7 @@
6
9
  <% else %>
7
10
  <button type="button" data-action="click->senren--alert-dialog#open"
8
11
  data-senren--alert-dialog-target="trigger"
9
- class="hidden">Open</button>
12
+ class="hidden"><%= senren_t('shared.open', default: 'Open') %></button>
10
13
  <% end %>
11
14
 
12
15
  <div data-senren--alert-dialog-target="overlay" hidden
@@ -15,7 +18,7 @@
15
18
  <div data-senren--alert-dialog-target="panel" hidden
16
19
  role="alertdialog" aria-modal="true" aria-labelledby="<%= dom_id %>-title" aria-describedby="<%= dom_id %>-desc"
17
20
  tabindex="-1"
18
- class="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-6 shadow-lg">
21
+ class="<%= panel_class("fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-6 shadow-lg") %>">
19
22
  <% if title? %>
20
23
  <h2 id="<%= dom_id %>-title" class="text-lg font-semibold"><%= title %></h2>
21
24
  <% end %>
@@ -31,4 +34,4 @@
31
34
  <% end %>
32
35
  </div>
33
36
  </div>
34
- </div>
37
+ <% end %>
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Senren
4
+ # Unlike Dialog and Sheet, this does not close when the overlay is clicked.
5
+ # An alert dialog exists to make someone choose, and dismissing it by
6
+ # clicking beside it is indistinguishable from choosing Cancel by accident.
7
+ # Escape still closes it, because a keyboard user needs a way out.
4
8
  class AlertDialogComponent < BaseComponent
5
9
  renders_one :trigger
6
10
  renders_one :title
@@ -1,10 +1,10 @@
1
- <%= tag.div(**root_attrs("space-y-2", data: { controller: "senren--api-key-field", "senren--api-key-field-reveal-label-value": reveal_label, "senren--api-key-field-hide-label-value": hide_label, "senren--api-key-field-copy-label-value": copy_label })) do %>
1
+ <%= tag.div(**root_attrs("space-y-2", data: { controller: "senren--api-key-field", "senren--api-key-field-reveal-label-value": reveal_label, "senren--api-key-field-hide-label-value": hide_label, "senren--api-key-field-copy-label-value": copy_label, "senren--api-key-field-copied-status-value": copied_status })) do %>
2
2
  <% if content? && value.blank? %>
3
3
  <%= content %>
4
4
  <% else %>
5
5
  <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= label %></label>
6
6
  <div class="flex flex-col gap-2 sm:flex-row">
7
- <input type="password" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
7
+ <input type="password" readonly autocomplete="off" aria-label="<%= label %>" value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
8
8
  <button type="button" data-senren--api-key-field-target="revealButton" data-action="click->senren--api-key-field#toggle" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]"><%= reveal_label %></button>
9
9
  <button type="button" data-action="click->senren--api-key-field#copy" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90"><%= copy_label %></button>
10
10
  </div>
@@ -5,7 +5,10 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(value: nil, label: 'API key', reveal_label: 'Reveal', hide_label: 'Hide', copy_label: 'Copy',
8
+ def initialize(value: nil, label: senren_t('api_key_field.label', default: 'API key'),
9
+ reveal_label: senren_t('api_key_field.reveal', default: 'Reveal'),
10
+ hide_label: senren_t('api_key_field.hide', default: 'Hide'),
11
+ copy_label: senren_t('api_key_field.copy', default: 'Copy'),
9
12
  class_name: nil, **html)
10
13
  super(variant: :default, size: :md, class_name: class_name, **html)
11
14
  @value = value
@@ -16,5 +19,9 @@ module Senren
16
19
  end
17
20
 
18
21
  attr_reader :value, :label, :reveal_label, :hide_label, :copy_label
22
+
23
+ # Announced after a copy. The controller reads it from a Stimulus value, so
24
+ # the sentence is built here, in the host's language, not in JavaScript.
25
+ def copied_status = senren_t('api_key_field.copied_status', default: '%{label} complete', label: copy_label)
19
26
  end
20
27
  end
@@ -13,7 +13,8 @@ module Senren
13
13
  }.freeze
14
14
  SIZES = { md: '' }.freeze
15
15
 
16
- def initialize(content_id: 'senren-main', skip_label: 'Skip to content', variant: :default, class_name: nil, **html)
16
+ def initialize(content_id: 'senren-main', skip_label: senren_t('app_shell.skip_to_content', default: 'Skip to content'),
17
+ variant: :default, class_name: nil, **html)
17
18
  super(variant: variant, size: :md, class_name: class_name, **html)
18
19
  @content_id = content_id
19
20
  @skip_label = skip_label
@@ -1,5 +1,9 @@
1
- <% if items.any? %>
2
- <nav <%= tag.attributes(**root_attrs("text-sm text-[hsl(var(--senren-muted-foreground))]", aria: { label: label })) %>>
1
+ <%# The <nav> is outside the branch on purpose. It used to be written twice,
2
+ once per branch, with the same root_attrs call duplicated -- so a change to
3
+ the root had to be made in two places and the empty case was one edit away
4
+ from silently losing its aria-label. %>
5
+ <%= tag.nav(**root_attrs("text-sm text-[hsl(var(--senren-muted-foreground))]", aria: { label: label })) do %>
6
+ <% if items.any? %>
3
7
  <ol class="flex flex-wrap items-center gap-2">
4
8
  <% items.each_with_index do |item, index| %>
5
9
  <% current = index == items.length - 1 || item[:href].blank? %>
@@ -15,9 +19,7 @@
15
19
  </li>
16
20
  <% end %>
17
21
  </ol>
18
- </nav>
19
- <% else %>
20
- <nav <%= tag.attributes(**root_attrs("text-sm text-[hsl(var(--senren-muted-foreground))]", aria: { label: label })) %>>
22
+ <% else %>
21
23
  <%= content %>
22
- </nav>
24
+ <% end %>
23
25
  <% end %>
@@ -5,7 +5,8 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(items: [], label: 'Breadcrumb', separator: '/', class_name: nil, **html)
8
+ def initialize(items: [], label: senren_t('breadcrumb.label', default: 'Breadcrumb'), separator: '/',
9
+ class_name: nil, **html)
9
10
  super(variant: :default, size: :md, class_name: class_name, **html)
10
11
  @items = normalize_items(items)
11
12
  @label = label
@@ -3,7 +3,7 @@
3
3
  <%= content %>
4
4
  <% else %>
5
5
  <div class="flex flex-wrap items-center justify-between gap-3">
6
- <div class="text-sm font-medium"><%= selection_text || "Selection ready" %></div>
6
+ <div class="text-sm font-medium"><%= selection_text || senren_t('bulk_action_bar.ready', default: 'Selection ready') %></div>
7
7
  <% if actions? %>
8
8
  <div class="flex flex-wrap items-center gap-2"><%= actions %></div>
9
9
  <% end %>
@@ -7,7 +7,8 @@ module Senren
7
7
  VARIANTS = { default: '' }.freeze
8
8
  SIZES = { md: '' }.freeze
9
9
 
10
- def initialize(selected_count: nil, item_label: 'items', class_name: nil, **html)
10
+ def initialize(selected_count: nil, item_label: senren_t('bulk_action_bar.items', default: 'items'),
11
+ class_name: nil, **html)
11
12
  super(variant: :default, size: :md, class_name: class_name, **html)
12
13
  @selected_count = selected_count
13
14
  @item_label = item_label
@@ -18,7 +19,8 @@ module Senren
18
19
  def selection_text
19
20
  return nil if selected_count.nil?
20
21
 
21
- "#{selected_count} #{item_label} selected"
22
+ senren_t('bulk_action_bar.selected', default: '%{count} %{item_label} selected',
23
+ count: selected_count, item_label: item_label)
22
24
  end
23
25
  end
24
26
  end
@@ -1,6 +1,6 @@
1
1
  <% base = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-(--senren-radius) font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed" %>
2
2
  <% if as == :a %>
3
- <%= tag.a content, **root_attrs(base, href: safe_url(href)) %>
3
+ <%= tag.a content, href: safe_url(href), **root_attrs(base) %>
4
4
  <% else %>
5
5
  <%= tag.button content, **root_attrs(base, type: type) %>
6
6
  <% end %>
@@ -17,7 +17,33 @@ module Senren
17
17
  lg: 'h-12 px-6 text-base'
18
18
  }.freeze
19
19
 
20
- def initialize(variant: :default, size: :md, type: 'button', as: :button, href: nil, class_name: nil, **html)
20
+ # FOR AI AGENTS AND HUMANS -- how `type` behaves, and why it changed.
21
+ #
22
+ # `type:` defaults to nil, so the attribute is omitted and the browser's own
23
+ # rule applies: a <button> inside a <form> submits it, one outside a form
24
+ # does nothing. Write what you would write in plain HTML; it behaves the
25
+ # same.
26
+ #
27
+ # <%= form_with url: sessions_path do %>
28
+ # <%= render(Senren::ButtonComponent.new(variant: :primary)) { "Sign in" } %>
29
+ # <% end %> # submits, as HTML says
30
+ #
31
+ # <%= render(Senren::ButtonComponent.new(type: :button)) { "Open" } %>
32
+ # # a trigger: say so
33
+ #
34
+ # This used to default to "button", which silently overrode the platform. A
35
+ # button rendered inside a form did nothing when clicked -- no submit, no
36
+ # navigation, nothing in the console -- and that is what users reported:
37
+ # "I click submit and it does not redirect". Every auth example this library
38
+ # ships had the bug, so anyone copying a snippet inherited it.
39
+ #
40
+ # The trade is deliberate. The old default failed silently; this one fails
41
+ # loudly, by submitting a form you did not mean to submit. A visible wrong
42
+ # is cheaper to find than an invisible nothing.
43
+ #
44
+ # Agents: writing a form? Do not pass `type:` at all. Writing a trigger for
45
+ # a dialog, sheet, or menu, which must not submit? Pass `type: :button`.
46
+ def initialize(variant: :default, size: :md, type: nil, as: :button, href: nil, class_name: nil, **html)
21
47
  super(variant: variant, size: size, class_name: class_name, **html)
22
48
  @type = type
23
49
  @as = href ? :a : as
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-72 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))]", data: { controller: "senren--calendar" })) %>>
1
+ <%= tag.div(**root_attrs("w-72 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))]", data: { controller: "senren--calendar" })) do %>
2
2
  <% if name.present? %>
3
3
  <input type="hidden" name="<%= name %>" value="<%= selected&.iso8601 %>" data-senren--calendar-target="value">
4
4
  <% end %>
@@ -18,4 +18,4 @@
18
18
  </button>
19
19
  <% end %>
20
20
  </div>
21
- </div>
21
+ <% end %>
@@ -14,8 +14,43 @@ module Senren
14
14
 
15
15
  def month_start = date.beginning_of_month
16
16
  def month_end = date.end_of_month
17
- def title = date.strftime('%B %Y')
18
- def weekday_labels = Date::ABBR_DAYNAMES
17
+
18
+ def title
19
+ senren_t('calendar.title', default: '%{month} %{year}',
20
+ month: month_names.fetch(date.month - 1), year: date.strftime('%Y'))
21
+ end
22
+
23
+ # Spelled out call by call rather than looped over a table, so each name is
24
+ # a literal key that bin/i18n-sync can read and a translator can find.
25
+ def month_names
26
+ [
27
+ senren_t('calendar.months.january', default: 'January'),
28
+ senren_t('calendar.months.february', default: 'February'),
29
+ senren_t('calendar.months.march', default: 'March'),
30
+ senren_t('calendar.months.april', default: 'April'),
31
+ senren_t('calendar.months.may', default: 'May'),
32
+ senren_t('calendar.months.june', default: 'June'),
33
+ senren_t('calendar.months.july', default: 'July'),
34
+ senren_t('calendar.months.august', default: 'August'),
35
+ senren_t('calendar.months.september', default: 'September'),
36
+ senren_t('calendar.months.october', default: 'October'),
37
+ senren_t('calendar.months.november', default: 'November'),
38
+ senren_t('calendar.months.december', default: 'December')
39
+ ]
40
+ end
41
+
42
+ # Sunday first, matching calendar_days.
43
+ def weekday_labels
44
+ [
45
+ senren_t('calendar.weekdays.sun', default: 'Sun'),
46
+ senren_t('calendar.weekdays.mon', default: 'Mon'),
47
+ senren_t('calendar.weekdays.tue', default: 'Tue'),
48
+ senren_t('calendar.weekdays.wed', default: 'Wed'),
49
+ senren_t('calendar.weekdays.thu', default: 'Thu'),
50
+ senren_t('calendar.weekdays.fri', default: 'Fri'),
51
+ senren_t('calendar.weekdays.sat', default: 'Sat')
52
+ ]
53
+ end
19
54
 
20
55
  def calendar_days
21
56
  start_day = month_start.beginning_of_week(:sunday)
@@ -2,12 +2,10 @@
2
2
  <% if header? %>
3
3
  <div class="flex flex-col space-y-1.5 p-6 border-b border-[hsl(var(--senren-border))]"><%= header %></div>
4
4
  <% end %>
5
- <% if body? %>
6
- <div class="p-6"><%= body %></div>
7
- <% else %>
8
- <div class="p-6"><%= content %></div>
9
- <% end %>
5
+ <%# One <div>, not the same one in both branches: the padding is the card's, %>
6
+ <%# and duplicating the element meant a change to it had to be made twice. %>
7
+ <div class="p-6"><%= body? ? body : content %></div>
10
8
  <% if footer? %>
11
- <div class="flex items-center p-6 pt-0 border-t border-[hsl(var(--senren-border))]"><%= footer %></div>
9
+ <div class="flex items-center gap-2 p-6 border-t border-[hsl(var(--senren-border))]"><%= footer %></div>
12
10
  <% end %>
13
11
  <% end %>
@@ -1,4 +1,4 @@
1
- <%= tag.section(**root_attrs("relative overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] text-[hsl(var(--senren-card-foreground))] shadow-sm", data: { controller: "senren--carousel" }, role: "region", "aria-roledescription": "carousel", "aria-label": label)) do %>
1
+ <%= tag.section(**root_attrs("relative overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] text-[hsl(var(--senren-card-foreground))] shadow-sm", data: { controller: "senren--carousel", "senren--carousel-status-template-value": status_template }, role: "region", "aria-roledescription": "carousel", "aria-label": label)) do %>
2
2
  <% if content? && slides.empty? %>
3
3
  <%= content %>
4
4
  <% else %>
@@ -6,7 +6,7 @@
6
6
  <% slides.each_with_index do |slide, index| %>
7
7
  <article data-senren--carousel-target="slide" data-index="<%= index %>" class="<%= index.zero? ? '' : 'hidden' %>">
8
8
  <% if slide[:image_url].present? %>
9
- <img src="<%= slide[:image_url] %>" alt="<%= slide[:alt] || slide[:title] %>" class="aspect-video w-full object-cover">
9
+ <%= image_tag slide[:image_url], alt: slide[:alt] || slide[:title], class: "aspect-video w-full object-cover" %>
10
10
  <% else %>
11
11
  <div class="relative flex aspect-video items-end overflow-hidden bg-[hsl(var(--senren-muted)/0.45)] p-6">
12
12
  <svg aria-hidden="true" viewBox="0 0 960 540" preserveAspectRatio="none" class="absolute inset-0 h-full w-full">
@@ -45,12 +45,12 @@
45
45
 
46
46
  <% if slides.size > 1 %>
47
47
  <div class="pointer-events-none absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-between px-3">
48
- <button type="button" class="pointer-events-auto inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background)/0.88)] text-[hsl(var(--senren-foreground))] shadow-sm transition hover:-translate-y-0.5 hover:bg-[hsl(var(--senren-accent))] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]" data-action="click->senren--carousel#previous" aria-label="Previous slide">
48
+ <button type="button" class="pointer-events-auto inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background)/0.88)] text-[hsl(var(--senren-foreground))] shadow-sm transition hover:-translate-y-0.5 hover:bg-[hsl(var(--senren-accent))] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]" data-action="click->senren--carousel#previous" aria-label="<%= previous_label %>">
49
49
  <svg aria-hidden="true" viewBox="0 0 20 20" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
50
50
  <path d="M12.5 4.5 7 10l5.5 5.5" />
51
51
  </svg>
52
52
  </button>
53
- <button type="button" class="pointer-events-auto inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background)/0.88)] text-[hsl(var(--senren-foreground))] shadow-sm transition hover:-translate-y-0.5 hover:bg-[hsl(var(--senren-accent))] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]" data-action="click->senren--carousel#next" aria-label="Next slide">
53
+ <button type="button" class="pointer-events-auto inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background)/0.88)] text-[hsl(var(--senren-foreground))] shadow-sm transition hover:-translate-y-0.5 hover:bg-[hsl(var(--senren-accent))] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]" data-action="click->senren--carousel#next" aria-label="<%= next_label %>">
54
54
  <svg aria-hidden="true" viewBox="0 0 20 20" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
55
55
  <path d="M7.5 4.5 13 10l-5.5 5.5" />
56
56
  </svg>
@@ -59,10 +59,10 @@
59
59
 
60
60
  <div class="absolute bottom-3 left-0 right-0 z-10 flex justify-center gap-1.5">
61
61
  <% slides.each_with_index do |_slide, index| %>
62
- <button type="button" class="h-2.5 w-2.5 cursor-pointer rounded-full bg-[hsl(var(--senren-background)/0.72)] ring-1 ring-[hsl(var(--senren-border))] transition-all aria-current:w-6 aria-current:bg-[hsl(var(--senren-primary))]" data-senren--carousel-target="dot" data-index="<%= index %>" data-action="click->senren--carousel#goTo" aria-label="Go to slide <%= index + 1 %>" aria-current="<%= index.zero? ? 'true' : 'false' %>"></button>
62
+ <button type="button" class="h-2.5 w-2.5 cursor-pointer rounded-full bg-[hsl(var(--senren-background)/0.72)] ring-1 ring-[hsl(var(--senren-border))] transition-all aria-current:w-6 aria-current:bg-[hsl(var(--senren-primary))]" data-senren--carousel-target="dot" data-index="<%= index %>" data-action="click->senren--carousel#goTo" aria-label="<%= go_to_label(index + 1) %>" aria-current="<%= index.zero? ? 'true' : 'false' %>"></button>
63
63
  <% end %>
64
64
  </div>
65
65
  <% end %>
66
- <p class="sr-only" aria-live="polite" data-senren--carousel-target="status">Slide 1 of <%= slides.size %></p>
66
+ <p class="sr-only" aria-live="polite" data-senren--carousel-target="status"><%= status_text %></p>
67
67
  <% end %>
68
68
  <% end %>
@@ -5,7 +5,7 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(slides: [], label: 'Carousel', class_name: nil, **html)
8
+ def initialize(slides: [], label: senren_t('carousel.label', default: 'Carousel'), class_name: nil, **html)
9
9
  super(variant: :default, size: :md, class_name: class_name, **html)
10
10
  @slides = normalize_slides(slides)
11
11
  @label = label
@@ -13,6 +13,17 @@ module Senren
13
13
 
14
14
  attr_reader :slides, :label
15
15
 
16
+ def previous_label = senren_t('carousel.previous', default: 'Previous slide')
17
+ def next_label = senren_t('carousel.next', default: 'Next slide')
18
+ def go_to_label(number) = senren_t('carousel.go_to', default: 'Go to slide %{number}', number: number)
19
+
20
+ # The announcement as the server first renders it, and the same sentence as a
21
+ # template for the controller, which has to say it again on every slide
22
+ # change. No values are passed for the template, so I18n hands back the
23
+ # `%{current}` and `%{total}` placeholders untouched.
24
+ def status_text = senren_t('carousel.status', default: 'Slide %{current} of %{total}', current: 1, total: slides.size)
25
+ def status_template = senren_t('carousel.status', default: 'Slide %{current} of %{total}')
26
+
16
27
  private
17
28
 
18
29
  def normalize_slides(slides)