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
@@ -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.
@@ -50,7 +50,11 @@ module Senren
50
50
 
51
51
  def runtime_checks
52
52
  [
53
- check('ViewComponent gem available') { defined?(::ViewComponent) },
53
+ # Not just defined?(::ViewComponent): a host's Gemfile lists senren-ui,
54
+ # not view_component, so Bundler.require never loads it, and nothing
55
+ # does until BaseComponent is autoloaded on the first render. A healthy
56
+ # app booted for this task failed the check.
57
+ check('ViewComponent gem available') { defined?(::ViewComponent) || gem_loadable?('view_component') },
54
58
  check('TailwindCSS stylesheet present') { paths.stylesheet_path.exist? },
55
59
  check('Stimulus directory present') { paths.stimulus_dir.directory? },
56
60
  check('Turbo gem available') { defined?(::Turbo) || gem_loadable?('turbo-rails') }
@@ -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.3.0'
5
+ VERSION = '0.4.1'
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
@@ -3,13 +3,31 @@
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] %>" <% unless expanded %>hidden<% end %> 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 %>
@@ -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
@@ -9,7 +9,7 @@
9
9
  <% else %>
10
10
  <button type="button" data-action="click->senren--alert-dialog#open"
11
11
  data-senren--alert-dialog-target="trigger"
12
- class="hidden">Open</button>
12
+ class="hidden"><%= senren_t('shared.open', default: 'Open') %></button>
13
13
  <% end %>
14
14
 
15
15
  <div data-senren--alert-dialog-target="overlay" hidden
@@ -1,4 +1,4 @@
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,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
@@ -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
@@ -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)
@@ -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 %>
@@ -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)
@@ -30,10 +30,10 @@
30
30
  <p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
31
31
  </div>
32
32
 
33
- <div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
33
+ <div class="flex items-center gap-1" role="group" aria-label="<%= quantity_label(item) %>">
34
34
  <button type="button"
35
35
  data-action="click->senren--cart#decrement"
36
- aria-label="Decrease <%= item[:name] %> quantity"
36
+ aria-label="<%= decrease_label(item) %>"
37
37
  class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">&minus;</button>
38
38
 
39
39
  <output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
@@ -41,21 +41,21 @@
41
41
 
42
42
  <button type="button"
43
43
  data-action="click->senren--cart#increment"
44
- aria-label="Increase <%= item[:name] %> quantity"
44
+ aria-label="<%= increase_label(item) %>"
45
45
  class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">+</button>
46
46
  </div>
47
47
 
48
48
  <button type="button"
49
49
  data-action="click->senren--cart#remove"
50
- aria-label="Remove <%= item[:name] %>"
51
- class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-destructive))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">Remove</button>
50
+ aria-label="<%= remove_item_label(item) %>"
51
+ class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-destructive))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]"><%= remove_label %></button>
52
52
  </li>
53
53
  <% end %>
54
54
  </ul>
55
55
 
56
56
  <footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
57
57
  <div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
58
- Subtotal
58
+ <%= subtotal_label %>
59
59
  <span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
60
60
  </div>
61
61
 
@@ -22,8 +22,9 @@ module Senren
22
22
 
23
23
  SIZES = { md: '' }.freeze
24
24
 
25
- def initialize(items: [], currency: '$', title: 'Cart', empty_text: 'Your cart is empty.',
26
- checkout_url: nil, checkout_label: 'Checkout', variant: :default,
25
+ def initialize(items: [], currency: '$', title: senren_t('cart.title', default: 'Cart'),
26
+ empty_text: senren_t('cart.empty', default: 'Your cart is empty.'),
27
+ checkout_url: nil, checkout_label: senren_t('cart.checkout', default: 'Checkout'), variant: :default,
27
28
  id: nil, class_name: nil, **html)
28
29
  super(variant: variant, size: :md, class_name: class_name, **html)
29
30
  @items = Array(items).map { |item| normalize_item(item) }
@@ -37,6 +38,14 @@ module Senren
37
38
 
38
39
  attr_reader :items, :currency, :title, :empty_text, :checkout_label, :dom_id
39
40
 
41
+ def subtotal_label = senren_t('cart.subtotal', default: 'Subtotal')
42
+ def remove_label = senren_t('cart.remove', default: 'Remove')
43
+
44
+ def quantity_label(item) = senren_t('cart.quantity_group', default: '%{name} quantity', name: item[:name])
45
+ def decrease_label(item) = senren_t('cart.decrease', default: 'Decrease %{name} quantity', name: item[:name])
46
+ def increase_label(item) = senren_t('cart.increase', default: 'Increase %{name} quantity', name: item[:name])
47
+ def remove_item_label(item) = senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name])
48
+
40
49
  def empty? = items.empty?
41
50
  def total_quantity = items.sum { |item| item[:quantity] }
42
51
  def subtotal_cents = items.sum { |item| item[:price_cents] * item[:quantity] }
@@ -1,4 +1,4 @@
1
- <%= tag.div(**root_attrs("inline-flex items-center gap-2", data: { controller: "senren--clipboard", "senren--clipboard-copied-label-value": copied_label })) do %>
1
+ <%= tag.div(**root_attrs("inline-flex items-center gap-2", data: { controller: "senren--clipboard", "senren--clipboard-copied-label-value": copied_label, "senren--clipboard-copied-status-value": copied_status })) do %>
2
2
  <input type="text" readonly autocomplete="off" aria-label="<%= label %>" value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
3
3
  <button type="button" data-senren--clipboard-target="button" data-action="click->senren--clipboard#copy" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90">
4
4
  <%= label %>
@@ -5,7 +5,8 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(value:, label: 'Copy', copied_label: 'Copied', class_name: nil, **html)
8
+ def initialize(value:, label: senren_t('clipboard.copy', default: 'Copy'),
9
+ copied_label: senren_t('clipboard.copied', default: 'Copied'), class_name: nil, **html)
9
10
  super(variant: :default, size: :md, class_name: class_name, **html)
10
11
  @label = label
11
12
  @value = value
@@ -13,5 +14,9 @@ module Senren
13
14
  end
14
15
 
15
16
  attr_reader :label, :value, :copied_label
17
+
18
+ # What a screen reader is told once the copy has happened. Passed to the
19
+ # controller as a Stimulus value so no sentence lives in JavaScript.
20
+ def copied_status = senren_t('clipboard.copied_status', default: 'Copied to clipboard')
16
21
  end
17
22
  end
@@ -6,7 +6,7 @@ module Senren
6
6
  VARIANTS = { default: '' }.freeze
7
7
  SIZES = { md: '' }.freeze
8
8
 
9
- def initialize(title: 'Details', open: false, class_name: nil, **html)
9
+ def initialize(title: senren_t('collapsible.details', default: 'Details'), open: false, class_name: nil, **html)
10
10
  super(variant: :default, size: :md, class_name: class_name, **html)
11
11
  @title = title
12
12
  @open = open
@@ -8,7 +8,8 @@ module Senren
8
8
  }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(name:, options:, value: nil, placeholder: 'Search...', variant: :default, class_name: nil, **html)
11
+ def initialize(name:, options:, value: nil, placeholder: senren_t('combobox.placeholder', default: 'Search...'),
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
  @options = normalize_options(options)
@@ -5,7 +5,9 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(items: [], placeholder: 'Type a command...', label: 'Command menu', empty_text: 'No results found.',
8
+ def initialize(items: [], placeholder: senren_t('command.placeholder', default: 'Type a command...'),
9
+ label: senren_t('command.label', default: 'Command menu'),
10
+ empty_text: senren_t('command.empty', default: 'No results found.'),
9
11
  id: nil, class_name: nil, **html)
10
12
  super(variant: :default, size: :md, class_name: class_name, **html)
11
13
  @placeholder = placeholder