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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +238 -0
- data/CONTRIBUTING.md +11 -0
- data/README.md +64 -0
- data/docs/i18n.md +194 -0
- data/docs/visual_style.md +80 -0
- data/lib/generators/senren/install/install_generator.rb +39 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +136 -7
- data/lib/generators/senren/install/templates/conventions.md.tt +57 -0
- data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
- data/lib/senren/rails/agent_rules_writer.rb +7 -0
- data/lib/senren/rails/base_component_patch.rb +24 -0
- data/lib/senren/rails/component_copier.rb +21 -0
- data/lib/senren/rails/host_paths.rb +5 -0
- data/lib/senren/rails/locale_installer.rb +90 -0
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -0
- data/lib/tasks/senren.rake +9 -0
- data/templates/components/accordion/accordion_component.html.erb +24 -6
- data/templates/components/accordion/accordion_component.rb +2 -1
- data/templates/components/alert_dialog/alert_dialog_component.html.erb +7 -4
- data/templates/components/alert_dialog/alert_dialog_component.rb +4 -0
- data/templates/components/api_key_field/api_key_field_component.html.erb +2 -2
- data/templates/components/api_key_field/api_key_field_component.rb +8 -1
- data/templates/components/app_shell/app_shell_component.rb +2 -1
- data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -1
- data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +1 -1
- data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +4 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/button/button_component.rb +27 -1
- data/templates/components/calendar/calendar_component.html.erb +2 -2
- data/templates/components/calendar/calendar_component.rb +37 -2
- data/templates/components/card/card_component.html.erb +4 -6
- data/templates/components/carousel/carousel_component.html.erb +6 -6
- data/templates/components/carousel/carousel_component.rb +12 -1
- data/templates/components/cart/cart_component.html.erb +13 -7
- data/templates/components/cart/cart_component.rb +11 -2
- data/templates/components/clipboard/clipboard_component.html.erb +3 -3
- data/templates/components/clipboard/clipboard_component.rb +6 -1
- data/templates/components/collapsible/collapsible_component.html.erb +3 -3
- data/templates/components/collapsible/collapsible_component.rb +1 -1
- data/templates/components/combobox/combobox_component.html.erb +3 -3
- data/templates/components/combobox/combobox_component.rb +2 -1
- data/templates/components/command/command_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +3 -1
- data/templates/components/context_menu/context_menu_component.html.erb +6 -3
- data/templates/components/data_table/data_table_component.html.erb +3 -3
- data/templates/components/data_table/data_table_component.rb +16 -1
- data/templates/components/date_picker/date_picker_component.html.erb +5 -5
- data/templates/components/date_picker/date_picker_component.rb +2 -1
- data/templates/components/dialog/dialog_component.html.erb +9 -5
- data/templates/components/dialog/dialog_component.rb +11 -0
- data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
- data/templates/components/filter_bar/filter_bar_component.rb +1 -1
- data/templates/components/form/form_component.html.erb +1 -1
- data/templates/components/form/form_component.rb +21 -0
- data/templates/components/hover_card/hover_card_component.html.erb +6 -3
- data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +11 -9
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +11 -3
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +2 -2
- data/templates/components/native_select/native_select_component.rb +12 -3
- data/templates/components/pagination/pagination_component.html.erb +4 -4
- data/templates/components/pagination/pagination_component.rb +2 -1
- data/templates/components/popover/popover_component.html.erb +6 -3
- data/templates/components/product_card/product_card_component.html.erb +3 -3
- data/templates/components/product_card/product_card_component.rb +2 -2
- data/templates/components/progress/progress_component.html.erb +2 -2
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +2 -16
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +32 -1
- data/templates/components/search_input/search_input_component.html.erb +2 -2
- data/templates/components/search_input/search_input_component.rb +2 -1
- data/templates/components/sheet/sheet_component.html.erb +15 -6
- data/templates/components/sheet/sheet_component.rb +11 -0
- data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
- data/templates/components/sidebar/sidebar_component.html.erb +4 -4
- data/templates/components/sidebar/sidebar_component.rb +4 -1
- data/templates/components/switch/switch_component.html.erb +1 -1
- data/templates/components/table/table_component.html.erb +2 -2
- data/templates/components/tabs/tabs_component.html.erb +3 -3
- data/templates/components/tabs/tabs_component.rb +4 -2
- data/templates/components/theme_toggle/theme_toggle_component.html.erb +17 -3
- data/templates/components/theme_toggle/theme_toggle_component.rb +6 -1
- data/templates/components/tooltip/tooltip_component.html.erb +5 -3
- data/templates/components/top_nav/top_nav_component.html.erb +3 -3
- data/templates/components/top_nav/top_nav_component.rb +3 -2
- data/templates/controllers/accordion_controller.js +68 -3
- data/templates/controllers/api_key_field_controller.js +3 -2
- data/templates/controllers/carousel_controller.js +6 -1
- data/templates/controllers/cart_controller.js +3 -1
- data/templates/controllers/clipboard_controller.js +3 -3
- data/templates/controllers/rich_text_editor_lite_controller.js +2 -2
- data/templates/controllers/theme_toggle_controller.js +13 -3
- data/templates/locales/senren.en.yml +151 -0
- data/templates/locales/senren.vi.yml +151 -0
- 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
|
data/lib/senren/rails/version.rb
CHANGED
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
|
data/lib/tasks/senren.rake
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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'] ||
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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 ||
|
|
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',
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
def
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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"
|
|
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)
|