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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c822ae4c548e611bba93a0ffd7654a881b4626173e3a85898e8221318aca6f2e
4
- data.tar.gz: 48b9d43cf61a38d652f5e993e5baff8d5bff3514b381ee4eeafce95dad7f1113
3
+ metadata.gz: 0af2c6143260dc7e068bb79237a99b7e58d6ea0523c7863ab328ff7773d4148e
4
+ data.tar.gz: 31b1bd8c86eb92ba5caf578ed314ebd857aac247b6fb214007a145acfe7dc76f
5
5
  SHA512:
6
- metadata.gz: cb02d352ef3be737cc00be01c8178874d6b9409c7e54da22917d2d6c4dbe1be0e3baa948bfabce77e5f18241284097bd254b520845ab71b5d1d4d01f573d4e64
7
- data.tar.gz: edcb1633812058eba68a7014f7239cddf4df698dccc8371c3d968c73e6cbd0913453d7fb9fee00e791e12e4c1080a9b31f1c1db76b3ac42b069606975d07d684
6
+ metadata.gz: 292220303c382a0b84f7e59cd4b1c0806f901f3fe4cb27a85d70eaddad304f501c1412e6df19d49bcf5546cf050cdf60937b9f2233d191f660bbb6f0e0f1f3c5
7
+ data.tar.gz: 88efe90a4fac627f9fc103d1c811738f63b03f7413f83040c5d4c7d893188f47587284d89fe249b2c902025f82fb9ba46a53f0dce7fa595eef8b4ba2644cff2d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,153 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  v0.x is a pre-stable line: minor bumps may break things; patch bumps are
8
8
  bug fixes only.
9
9
 
10
+ ## [0.4.1] — 2026-10-08
11
+
12
+ ### Fixed
13
+
14
+ - `bin/rails senren:locales vi`, as the README, `docs/i18n.md` and the install
15
+ generator write it, failed with `Unrecognized command "vi"`: the task was a
16
+ rake task only, so Rails passed `vi` to rake as a second task name, and the
17
+ one spelling that worked was `bin/rails 'senren:locales[vi]'`. It is now a
18
+ Rails command, like `senren:add`, so `bin/rails senren:locales vi`,
19
+ `senren:locales en vi` and `senren:locales vi --force` work as documented.
20
+ - `bin/rails senren:doctor` reported `✗ ViewComponent gem available` and exited
21
+ 1 on a healthy app. It checked only whether the `ViewComponent` constant was
22
+ loaded, and a host's Gemfile lists `senren-ui`, not `view_component`, so
23
+ nothing loads it until the first component renders. The check now also asks
24
+ whether the gem is installed, as the Turbo check already did.
25
+ - `bin/rails senren:add` and `senren:locales` given a name they do not know (or
26
+ `senren:add` given none) printed a Thor backtrace with the useful line at the
27
+ top of it. They now print the message alone and exit 1, as the rake tasks
28
+ always did.
29
+
30
+ ## [0.4.0] — 2026-10-08
31
+
32
+ ### Added
33
+
34
+ - **Every word a component shows or announces can be translated from the host
35
+ app, without editing a component.** Visible text, `aria-label` and `sr-only`
36
+ text, the defaults of `label:`, `placeholder:` and `empty_text:`, the
37
+ calendar's month and weekday names, and the sentences a Stimulus controller
38
+ says after load now go through one helper, `senren_t`, with the English as its
39
+ default: `senren_t('pagination.next', default: 'Next')` is
40
+ `I18n.t("senren.pagination.next", default: "Next")`. 111 keys across 27
41
+ components, named `senren.<component>.<key>`, with `senren.shared.*` for the
42
+ two words that are the same word in several components.
43
+
44
+ An app with no locale file renders exactly what it did before, and a missing
45
+ translation never raises, including under
46
+ `config.i18n.raise_on_missing_translations`. Text you pass in yourself is used
47
+ as given and never looked up; `label: nil` still means "none", and leaving the
48
+ argument out is what gets the translated default.
49
+
50
+ The gem ships `senren.en.yml` and `senren.vi.yml`, and copies either only
51
+ when asked for: `bin/rails generate senren:install --locales vi`, or later
52
+ `bin/rails senren:locales vi`. Not even English is copied by default: a copied
53
+ `senren.en.yml` loads after the app's `en.yml`, overriding any `senren.*` key
54
+ set there, and pins today's wording against later releases. An existing file
55
+ is never overwritten unless that is asked for by name
56
+ (`senren:locales vi --force`, or `senren:install --force-locales`); the
57
+ `--force` that refreshes Senren's own files leaves translations alone. To add
58
+ another language, `bin/rails senren:locales en`, copy the file, change `en:`,
59
+ and translate the values. A translation with a misspelt `%{variable}` falls
60
+ back to the English instead of raising. See [docs/i18n.md](docs/i18n.md).
61
+
62
+ `bin/i18n-sync` regenerates `templates/locales/senren.en.yml` from the code,
63
+ and a test fails if it, `senren.vi.yml`, and the components disagree, or if a
64
+ template shows a word that did not go through `senren_t`.
65
+
66
+ ### Changed
67
+
68
+ - **Five Stimulus controllers no longer carry an English sentence.** `clipboard`
69
+ ("Copied", "Copied to clipboard"), `api_key_field` ("Copy complete"),
70
+ `carousel` ("Slide 2 of 3"), `theme_toggle` ("Light theme", "Dark theme") and
71
+ `rich_text_editor_lite` (the link prompt) read the sentence from a Stimulus
72
+ value the component renders, so they speak the host's language. The markup
73
+ gains six `data-senren--<controller>-…-value` attributes and is otherwise
74
+ byte-for-byte what it was.
75
+
76
+ **Migrate:** none for an unmodified install. The controller and its component
77
+ go together: if you copied a component and edited its controller, or the other
78
+ way round, copy both again (`bin/rails senren:add <name> --force`) so the value
79
+ the controller reads is the one the component writes.
80
+ - `bin/rails senren:add` appends `senren_t` to an existing
81
+ `app/components/senren/base_component.rb` that lacks it, so a component added
82
+ after upgrading the gem does not raise `NoMethodError`. Components you copied
83
+ earlier keep the English written into them until you copy them again.
84
+ - The performance budgets for component source (140000 -> 150000 bytes) and for
85
+ the Stimulus payload (56000 -> 56500) are raised, with the reasons recorded in
86
+ `config/performance_budgets.yml`. The per-file caps and the gzip total are
87
+ unchanged.
88
+ - **`NativeSelectComponent` defaults to its own chevron instead of the browser's
89
+ arrow** (`native_arrow: false`). A select was the one control in the library
90
+ with no visible state: the OS arrow cannot be styled or animated, so nothing
91
+ distinguished an open select from a closed one. The custom chevron already
92
+ existed, rotated on focus, and had to be asked for by name — which meant
93
+ nobody got it. Pass `native_arrow: true` to opt back in; worth doing on
94
+ mobile, where the OS renders its own picker anyway.
95
+
96
+ The component marker moves with it. In custom-arrow mode
97
+ `data-senren-component="native_select"` sits on the wrapper that positions the
98
+ chevron, and the `<select>` underneath still carries the name, the controller
99
+ and the selection. Anything selecting on `select[data-senren-component]` needs
100
+ updating.
101
+ - `InviteMemberDialogComponent` renders its role field through
102
+ `NativeSelectComponent` rather than a hand-written `<select>` whose styling
103
+ had been copied out of it — which is why it silently missed everything that
104
+ component gained afterwards, the chevron included.
105
+
106
+ ### Fixed
107
+
108
+ - `FormComponent` yields the Rails form builder, as `docs/components.md` always
109
+ said it did. The block received the component instead, and because a
110
+ component is an `ActionView::Base` the documented `f.text_field :title,
111
+ class: "x"` did not raise: it reached the template-level helper and rendered
112
+ `name="title[{class: "x"}]"`, so the field posted under a name no controller
113
+ reads. Blocks that never touch `f`, and `with_content`, render as before.
114
+
115
+ Code that treated `|f|` as the component breaks: `f.tag`, `f.link_to`,
116
+ `f.render`, `f.model` and `f.url` now raise `NoMethodError` on the builder.
117
+ Call those on the view instead. A `FormComponent` rendered with neither a
118
+ block nor content now renders an empty `<form>` rather than raising.
119
+ - `TopNavComponent`'s default variant had no background. Its class,
120
+ `bg-[hsl(var(--senren-background))/0.88]`, put the alpha outside `hsl()`,
121
+ inside the brackets, so Tailwind emitted `hsl(...)/0.88` verbatim and the
122
+ browser dropped it as an invalid colour: the sticky header was transparent
123
+ over scrolled content. It is now `bg-[hsl(var(--senren-background)/0.88)]`,
124
+ the form every other translucent class in the library uses, and a test bans
125
+ the broken shape across `templates/` and `lib/`.
126
+ - `TableComponent` and `DataTableComponent` are now `relative`, so their
127
+ `sr-only` caption is positioned inside the component. `sr-only` is
128
+ `position: absolute`, and with nothing positioned the caption escaped the
129
+ components' own overflow clipping and was placed against the page. In a
130
+ fixed-height shell whose `<main>` scrolls, a captioned table below the fold
131
+ made the document taller, and the window scrolled into blank space under a
132
+ shell that should never scroll.
133
+ - `DataTableComponent` no longer breaks a `<td>` whose cell holds markup. The
134
+ cell value was written straight into `data-sort-value="..."`, and a badge,
135
+ link or anything else built with `tag` or `render` is a SafeBuffer, which ERB
136
+ does not escape: the markup's first `"` closed the attribute, the rest became
137
+ junk attributes, and every row sorted on the same `<span class=` fragment.
138
+ The sort value is now the cell's decoded text, escaped once, so a cell
139
+ showing `R&D` sorts as `R&D`. Plain-string and numeric cells are unchanged.
140
+ - `ThemeToggleComponent` rendered the letters `O`, `D` and `L` where an icon
141
+ belongs. It now ships sun and moon SVGs and swaps which is hidden, so the
142
+ correct icon is in the HTML the server sends rather than written in by
143
+ JavaScript after first paint. The icon names the theme the click switches to,
144
+ matching the label.
145
+ - `AccordionComponent` showed a static `+` and snapped open. It now has a
146
+ chevron that turns over, driven by `aria-expanded` so the icon cannot drift
147
+ out of step with the state assistive technology is told, and the panel
148
+ animates via `grid-template-rows: 0fr → 1fr` — which reaches the content's own
149
+ height without measuring it in JavaScript.
150
+
151
+ The `hidden` attribute is kept through the animation. Content that is visually
152
+ collapsed but present is still focusable and still read aloud, so the
153
+ controller drops `hidden` before opening and restores it once the closing
154
+ transition ends, with a timer for the cases where `transitionend` never
155
+ arrives: reduced motion, an off-screen element, an already-collapsed panel.
156
+
10
157
  ## [0.3.0] — 2026-09-01
11
158
 
12
159
  ### Breaking
data/CONTRIBUTING.md CHANGED
@@ -36,6 +36,7 @@ bundle exec rubocop
36
36
  bundle exec bundle-audit check --update
37
37
  bun run controllers:check
38
38
  bin/lint-fix # auto-fix RuboCop, ERB lint, controller JS
39
+ bin/i18n-sync --check # templates/locales/senren.en.yml matches the components
39
40
  ```
40
41
 
41
42
  `bin/system` needs no setup: it uses a system Chrome and
@@ -87,6 +88,13 @@ real documentation/reference app is maintained separately in
87
88
  - ViewComponent Ruby class under `Senren::` namespace.
88
89
  - ERB template with `data-senren-component="<name>"` on root.
89
90
  - Tailwind classes use semantic tokens only.
91
+ - Every word the component shows or announces (visible text, `aria-label`,
92
+ `sr-only`, a default `label:` or `placeholder:`, a sentence a controller will
93
+ say) goes through `senren_t('<component>.<key>', default: 'English')` with a
94
+ literal key and a literal default, and a controller receives it as a Stimulus
95
+ value, never as text in JavaScript. Run `bin/i18n-sync` to regenerate
96
+ `templates/locales/senren.en.yml` and add the same key to `senren.vi.yml`.
97
+ See `docs/i18n.md`.
90
98
  - Variants/sizes declared as class-level constants.
91
99
  - Stimulus controller iff client behavior is needed
92
100
  (`app/javascript/controllers/senren/<name>_controller.js`).
@@ -115,6 +123,8 @@ real documentation/reference app is maintained separately in
115
123
  templates, Stimulus controllers, or Importmap loading guidance.
116
124
  - Run `bun run controllers:check` before pushing if you touched
117
125
  `templates/controllers/*.js`.
126
+ - Run `bin/i18n-sync --check` before pushing if you touched text in a
127
+ component; `bin/i18n-sync` fixes what it reports.
118
128
 
119
129
  ## Pull request workflow
120
130
 
data/README.md CHANGED
@@ -80,6 +80,40 @@ change, no build step, no configuration. Switch at runtime with
80
80
 
81
81
  Details and the token table: [docs/visual_style.md](docs/visual_style.md).
82
82
 
83
+ ## Translating components
84
+
85
+ Every word a component shows or announces (labels, `aria-label` and
86
+ screen-reader text, default placeholders, the calendar's month and weekday names,
87
+ and what a Stimulus controller says after load) goes through one helper with its
88
+ English as the default:
89
+
90
+ ```ruby
91
+ senren_t('pagination.next', default: 'Next') # I18n.t("senren.pagination.next", default: "Next")
92
+ ```
93
+
94
+ An app with no locale file renders exactly the English it always did, and the
95
+ installer copies none unless asked. To change a word, set its `senren.*` key in
96
+ your own locale file. To add a language, copy a shipped file and translate it:
97
+
98
+ ```bash
99
+ bin/rails generate senren:install --locales vi # writes config/locales/senren.vi.yml
100
+ bin/rails senren:locales en # every key and its English, to translate from
101
+ ```
102
+
103
+ ```yaml
104
+ # config/locales/senren.fr.yml - copy senren.en.yml, change `en:` to `fr:`, translate the values
105
+ fr:
106
+ senren:
107
+ pagination:
108
+ previous: "Précédent"
109
+ next: "Suivant"
110
+ ```
111
+
112
+ The gem ships `en` and `vi`. A key you leave out stays English, and a missing
113
+ key never raises. [docs/i18n.md](docs/i18n.md) has the key naming, the values a
114
+ controller reads, what is not translated, and how to move components you copied
115
+ before this existed.
116
+
83
117
  ## Daily commands
84
118
 
85
119
  ```bash
@@ -88,6 +122,7 @@ bin/rails generate senren:component picker --client # custom component with Sti
88
122
  bin/rails generate senren:component picker --no-client # without Stimulus
89
123
  bin/rails senren:add dialog --client # install interactive official component
90
124
  bin/rails senren:add button # install static official component
125
+ bin/rails senren:locales vi # copy a shipped translation to config/locales
91
126
  bundle exec rails senren:add form input # equivalent alternate entry point
92
127
  bin/rails senren:skill:sync # rebuild .senren/skill.md
93
128
  bin/rails senren:agents:sync # rebuild .senren/agent-rules + adapters
@@ -263,6 +298,7 @@ bun run controllers:check # lint + syntax check for templates/controllers
263
298
  bun run controllers:lint:fix # auto-fix lint issues for controllers
264
299
  bundle exec rake test:system # Stimulus/system tests
265
300
  bin/lint-fix # auto-fix RuboCop, ERB lint, and controller JS
301
+ bin/i18n-sync # rewrite templates/locales/senren.en.yml from the senren_t calls
266
302
  ```
267
303
 
268
304
  ## Contributing
data/docs/i18n.md ADDED
@@ -0,0 +1,194 @@
1
+ # Translating Senren components
2
+
3
+ Every word a component shows or announces can be translated from your app's
4
+ locale files, without editing a component. That covers the visible labels
5
+ ("Previous", "Next", "Close", "Today"), the `aria-label` and screen-reader text
6
+ ("Toggle sidebar", "Go to slide 2"), the defaults for arguments such as
7
+ `label:` and `placeholder:`, the calendar's month and weekday names, and the
8
+ sentences a Stimulus controller writes into the page after it loads ("Copied",
9
+ "Slide 2 of 3").
10
+
11
+ ## How it works
12
+
13
+ Each string goes through one helper, `senren_t`, with its English as the
14
+ default:
15
+
16
+ ```ruby
17
+ senren_t('pagination.next', default: 'Next')
18
+ ```
19
+
20
+ That is `I18n.t("senren.pagination.next", default: "Next")`. If your app defines
21
+ `senren.pagination.next` for the current locale it wins; if not, the English in
22
+ the component is used. Two things follow:
23
+
24
+ - **Nothing is required for English.** Install Senren, render a component, and it
25
+ says what it always said. There is no locale file to forget.
26
+ - **A missing translation never raises**, not even with
27
+ `config.i18n.raise_on_missing_translations = true`, because I18n only reports
28
+ a key that has no default.
29
+
30
+ Keys are `senren.<component>.<key>`. `senren.shared.*` holds the few words that
31
+ are the same word in more than one component (`open`, `close`).
32
+
33
+ ## Install the locale files
34
+
35
+ `senren:install` copies no locale file: English needs none. Every file is
36
+ copied only when you ask for it by name:
37
+
38
+ ```bash
39
+ bin/rails generate senren:install --locales vi # at install time
40
+ bin/rails senren:locales vi # any time later
41
+ bin/rails senren:locales en # every key and its English, to translate from
42
+ bin/rails senren:locales vi --force # replace your copy with the shipped one
43
+ ```
44
+
45
+ Today the gem ships `en` and `vi`. The files are yours once they are copied:
46
+ the installer never overwrites one that exists unless you ask for that file to
47
+ be replaced (`senren:locales <name> --force`, or `--force-locales` on
48
+ `senren:install`). The `--force` that refreshes Senren's own files leaves them
49
+ alone, so a re-run after a gem upgrade does not undo your wording.
50
+
51
+ A copied `senren.en.yml` has two side effects, which is why it is not copied by
52
+ default. Rails loads `config/locales` in name order, so it loads after your
53
+ `en.yml` and overrides any `senren.*` key you set there. And it pins today's
54
+ English: when a later Senren release improves a default, an app holding the copy
55
+ keeps the old wording. Copy it to translate from, and delete it once your own
56
+ language file exists.
57
+
58
+ A translation is only loaded when its file is in `config/locales`, and a locale
59
+ becomes known to I18n when a file for it is loaded. If your app builds a
60
+ language switcher from `I18n.available_locales`, copying `senren.vi.yml` is what
61
+ makes Vietnamese appear in it, which is why it is opt-in.
62
+
63
+ ## Change one word
64
+
65
+ Define just that key in any locale file you already have. You do not need to
66
+ copy the whole file:
67
+
68
+ ```yaml
69
+ # config/locales/en.yml
70
+ en:
71
+ senren:
72
+ pagination:
73
+ next: "Newer"
74
+ previous: "Older"
75
+ ```
76
+
77
+ Everything you do not mention keeps its English. If you copied
78
+ `config/locales/senren.en.yml`, edit the value there instead: that file loads
79
+ after `en.yml` and wins.
80
+
81
+ ## Add a language
82
+
83
+ 1. Run `bin/rails senren:locales en`, then copy `config/locales/senren.en.yml` to
84
+ `config/locales/senren.fr.yml`.
85
+ 2. Change the top-level key from `en:` to `fr:`.
86
+ 3. Translate the values.
87
+
88
+ ```yaml
89
+ fr:
90
+ senren:
91
+ pagination:
92
+ label: "Pagination"
93
+ previous: "Précédent"
94
+ next: "Suivant"
95
+ cart:
96
+ remove_item: "Retirer %{name}"
97
+ ```
98
+
99
+ Keep every `%{name}` spelled exactly as it is. They are filled in when the
100
+ component renders. A translation that names a variable the component does not
101
+ pass (a typo such as `%{nmae}`) cannot be filled in, so that string falls back to
102
+ its English rather than raising. You can
103
+ move a variable anywhere in the sentence, and leave one out if the sentence does
104
+ not need it. A key you delete falls back to English, so a partial translation is
105
+ a working translation.
106
+
107
+ Then select the locale the way your app already does, for example
108
+ `I18n.locale = :fr` in an `around_action`. A word you did not translate shows in
109
+ English, because the English is the default.
110
+
111
+ If you would like the language included in the gem, send the file in a pull
112
+ request: `templates/locales/senren.<locale>.yml`, with the same keys as
113
+ `senren.en.yml`. `test/i18n_catalog_test.rb` checks that the keys and the
114
+ `%{}` variables match.
115
+
116
+ ## Text you pass in yourself
117
+
118
+ An argument you pass is used exactly as given and is never looked up:
119
+
120
+ ```erb
121
+ <%= render Senren::PaginationComponent.new(label: "Pages", current_page: 2, total_pages: 5) %>
122
+ ```
123
+
124
+ Only the argument you leave out gets the translated default. Two details:
125
+
126
+ - `label: nil` still means "no label". For an attribute such as `aria-label` that
127
+ omits it, as it always has. Pass `""` for an empty string and leave the
128
+ argument out for the default.
129
+ - Words that arrive as *data* are yours to supply: a column header built from the
130
+ key `:created_at` ("Created at"), the items of a menu, the text in a slot. Give
131
+ `TableComponent` and `DataTableComponent` a `label:` per column to control it.
132
+
133
+ Values you interpolate into a translation are text. A name containing `<b>`
134
+ shows as the characters `<b>`, not as an element.
135
+
136
+ ## What a controller says
137
+
138
+ Five controllers write a sentence into the page after it loads. They no longer
139
+ carry the English in JavaScript: the component renders each one into a Stimulus
140
+ value, and the controller reads it.
141
+
142
+ | Controller | Value | Key |
143
+ | --- | --- | --- |
144
+ | `clipboard` | `copied-status` | `senren.clipboard.copied_status` |
145
+ | `api_key_field` | `copied-status` | `senren.api_key_field.copied_status` |
146
+ | `carousel` | `status-template` | `senren.carousel.status` |
147
+ | `theme_toggle` | `light-label`, `dark-label` | `senren.theme_toggle.light`, `.dark` |
148
+ | `rich_text_editor_lite` | `link-prompt` | `senren.rich_text_editor_lite.link_prompt` |
149
+
150
+ The carousel's value is a template. Its `%{current}` and `%{total}` are left in
151
+ for the controller to fill in on every slide change, so a translation has to keep
152
+ them.
153
+
154
+ ## Components you copied before this existed
155
+
156
+ `bin/rails senren:add <anything>` adds the `senren_t` helper to your
157
+ `app/components/senren/base_component.rb` if it is missing, so components you add
158
+ from now on work.
159
+
160
+ Components you copied earlier still have their English written into them. To
161
+ translate one, copy it again with `bin/rails senren:add pagination --force`
162
+ (which replaces any edits you made to that component), or change its strings by
163
+ hand. It is one call per string: swap `"Next"` for
164
+ `senren_t('pagination.next', default: 'Next')`. `senren.en.yml` lists the key for
165
+ every string.
166
+
167
+ ## Writing a component or an edit that has text
168
+
169
+ Anything a person reads or a screen reader announces goes through `senren_t`
170
+ with a literal key and a literal English default:
171
+
172
+ ```ruby
173
+ def initialize(label: senren_t('tabs.label', default: 'Tabs'), **html)
174
+ ```
175
+
176
+ ```erb
177
+ <button><%= senren_t('date_picker.today', default: 'Today') %></button>
178
+ <button aria-label="<%= senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name]) %>">
179
+ ```
180
+
181
+ - Use the component's own namespace. Use `shared` only for a word that more than
182
+ one component says.
183
+ - Put the English where the call is. Do not write it in a locale file first.
184
+ - Give an argument its default in the signature, as above. Ruby evaluates it
185
+ when the component is built, in the locale that is current then.
186
+ - A string a controller needs goes to it as a Stimulus value, never as text in the
187
+ JavaScript.
188
+ - Symbols that are not words (`×`, `+`, `*`) stay as they are.
189
+
190
+ If you are working on the gem itself, `bin/i18n-sync` regenerates
191
+ `templates/locales/senren.en.yml` from the calls, and `bin/i18n-sync --check`
192
+ reports whether it is current. `senren.vi.yml` is written by hand. The tests fail
193
+ if the two files disagree with the code or with each other, if a call is not
194
+ literal, and if a template shows a word that did not go through `senren_t`.
@@ -22,7 +22,10 @@ module Senren
22
22
  force: options[:force]
23
23
  )
24
24
  rescue ArgumentError => e
25
- raise ::Rails::Command::Base::Error, e.message
25
+ # The message alone, as the rake task prints it. Rails does not rescue
26
+ # an error raised from a command, so raising printed a Thor backtrace
27
+ # with the one line that says what to type buried at the top of it.
28
+ abort e.message
26
29
  end
27
30
 
28
31
  private
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/command'
4
+ require 'senren/rails'
5
+ require 'senren/rails/locale_installer'
6
+
7
+ module Senren
8
+ module Command
9
+ # `bin/rails senren:locales vi` as the docs write it. As a rake task only,
10
+ # Rails handed `vi` to rake as a second task name and failed with
11
+ # `Unrecognized command "vi"`; the only spelling that worked was
12
+ # `bin/rails 'senren:locales[vi]'`. A Rails command receives the words after
13
+ # it as arguments, the same way senren:add does.
14
+ class LocalesCommand < ::Rails::Command::Base
15
+ class_option :force, type: :boolean, default: false,
16
+ desc: 'Replace a locale file that already exists with the shipped one.'
17
+
18
+ desc 'locales LOCALE [LOCALE...]',
19
+ 'Copy shipped Senren translations into config/locales.'
20
+ def perform(*names)
21
+ require_application!
22
+
23
+ locale_installer.install(locales: names.flat_map { |name| name.split(',') }, force: options[:force])
24
+ rescue ArgumentError => e
25
+ # The message alone, as the rake task prints it. Rails does not rescue
26
+ # an error raised from a command, so raising printed a Thor backtrace
27
+ # with the one line that says what to type buried at the top of it.
28
+ abort e.message
29
+ end
30
+
31
+ private
32
+
33
+ def locale_installer
34
+ Senren::Rails::LocaleInstaller.new
35
+ end
36
+ end
37
+ end
38
+ end
@@ -11,6 +11,24 @@ module Senren
11
11
  class_option :force, type: :boolean, default: false,
12
12
  desc: 'Overwrite existing Senren-managed files.'
13
13
 
14
+ # Nothing by default: English renders with no file. Every locale, English
15
+ # included, is asked for by name. A shipped translation adds its locale to
16
+ # I18n.available_locales, which a language switcher built from that list
17
+ # will then offer; a copied English file overrides the app's own en.yml.
18
+ #
19
+ # `enum:` makes Thor refuse an unknown locale while it parses the options,
20
+ # before any step has run. Refusing in the middle would leave an app with the
21
+ # components copied and no instruction files.
22
+ class_option :locales, type: :array, default: [],
23
+ enum: Senren::Rails::LocaleInstaller.available,
24
+ desc: 'Translations to copy into config/locales.'
25
+
26
+ # --force refreshes the files Senren owns. A locale file stops being one
27
+ # the moment it is copied: it is the app's translation. So replacing it is
28
+ # its own flag, and a routine --force re-run cannot undo someone's wording.
29
+ class_option :force_locales, type: :boolean, default: false,
30
+ desc: 'Replace locale files that already exist with the shipped ones.'
31
+
14
32
  def create_senren_dir
15
33
  empty_directory '.senren'
16
34
  end
@@ -41,6 +59,23 @@ module Senren
41
59
  copy_file Senren::Rails.registry_path, '.senren/registry.yml'
42
60
  end
43
61
 
62
+ # Components work without these: the English is in the code. Nothing is
63
+ # copied unless asked for, English included -- a copied senren.en.yml would
64
+ # override the app's own en.yml and pin today's wording (see
65
+ # LocaleInstaller). An existing file is a translation someone wrote, so it
66
+ # is kept unless --force-locales is given.
67
+ def copy_locale_files
68
+ locales = options[:locales]
69
+ if locales.empty?
70
+ say_status :senren, 'no locale files copied: English needs none (bin/rails senren:locales vi to add one)'
71
+ return
72
+ end
73
+
74
+ say_status :senren, "copying locale files (#{locales.join(', ')}) to config/locales"
75
+ installer = Senren::Rails::LocaleInstaller.new(paths: host_paths)
76
+ installer.install(locales: locales, force: options[:force_locales])
77
+ end
78
+
44
79
  # Switches Stimulus to on-demand loading instead of documenting it.
45
80
  #
46
81
  # Rails' default is `eagerLoadControllersFrom("controllers", application)`,
@@ -75,6 +110,7 @@ module Senren
75
110
  def print_next_steps
76
111
  say "\nSenren installed."
77
112
  say 'Next: bin/rails senren:add button card badge alert dialog'
113
+ say 'Translate the components: bin/rails senren:locales vi (see docs/i18n.md)'
78
114
  say 'Then: bin/rails senren:agents:sync'
79
115
  end
80
116
 
@@ -1,4 +1,5 @@
1
1
  require 'uri'
2
+ require 'active_support/i18n'
2
3
  require 'view_component'
3
4
 
4
5
  module Senren
@@ -105,6 +106,44 @@ module Senren
105
106
  "#{base}-#{seed.parameterize}"
106
107
  end
107
108
 
109
+ # Translates a string the component shows or announces: a visible label, an
110
+ # aria-label, sr-only text, a default placeholder, an announcement a Stimulus
111
+ # controller will read out.
112
+ #
113
+ # senren_t('pagination.next', default: 'Next')
114
+ # senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name])
115
+ #
116
+ # `default:` is the English text and it is required. It is what renders when
117
+ # the host app has no `senren.<key>` translation, which is why installing
118
+ # Senren needs no locale file, why a key nobody translated never raises (not
119
+ # even under `config.i18n.raise_on_missing_translations`, which only fires
120
+ # for a key with no default), and why the output is the same as it was before
121
+ # any string was translatable.
122
+ #
123
+ # This is I18n.t on purpose, not ViewComponent's `t`. `t` needs a view
124
+ # context, so it cannot run in a unit test that renders a template without
125
+ # Rails; it resolves a leading-dot key against the component's own sidecar
126
+ # scope, which is the lookup this namespace must not have; and it marks keys
127
+ # ending in `_html` as safe, which no Senren string is.
128
+ #
129
+ # Keys are `senren.<component>.<key>`; `senren.shared.<key>` is for a word
130
+ # that is the same word in more than one component. Interpolate with `%{}`.
131
+ # With no values passed the raw template comes back untouched, which is how a
132
+ # Stimulus controller is handed a string it fills in itself.
133
+ #
134
+ # A translation that names a variable the call does not pass (`%{nmae}`
135
+ # for `%{name}`) would raise I18n::MissingInterpolationArgument and turn a
136
+ # typo in a locale file into a 500. It falls back to the English instead,
137
+ # interpolated with the values the call did pass.
138
+ #
139
+ # Public, because the unit tests that render a template without a view
140
+ # delegate every public component method to it.
141
+ def senren_t(key, default:, **options)
142
+ I18n.t("senren.#{key}", default: default, **options)
143
+ rescue I18n::MissingInterpolationArgument
144
+ I18n.interpolate(default, options)
145
+ end
146
+
108
147
  private
109
148
 
110
149
  # Callers write `data: { controller: "x" }` and `data: { "controller" => "x" }`
@@ -83,6 +83,22 @@ and obey it strictly.
83
83
  ```
84
84
  Classes from families the component does not use need no modifier.
85
85
 
86
+ 12. **Every word a component shows or announces goes through `senren_t`.**
87
+ Visible text, `aria-label`, `sr-only` text, a default `label:` or
88
+ `placeholder:`, and any sentence a Stimulus controller will say. The English
89
+ is the `default:`, the key is `<component>.<key>`, and both are literals:
90
+ ```ruby
91
+ senren_t('pagination.next', default: 'Next')
92
+ senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name])
93
+ def initialize(label: senren_t('tabs.label', default: 'Tabs'), **html)
94
+ ```
95
+ Never hard-code a sentence in a template, and never write one in a Stimulus
96
+ controller: hand the controller a translated string as a value. An app with
97
+ no locale file renders the English unchanged; to change a word, set its
98
+ `senren.*` key in a locale file, and to add a language, start from
99
+ `bin/rails senren:locales en` (`bin/rails senren:locales vi` installs a
100
+ shipped translation).
101
+
86
102
  ## File ownership
87
103
 
88
104
  | Path | Owned by |
@@ -1,4 +1,4 @@
1
1
  # This file is the ledger of Senren components installed in this app.
2
- # It is updated by `bin/rails senren:add` and `bin/rails senren:install`.
2
+ # It is updated by `bin/rails senren:add` and `bin/rails generate senren:install`.
3
3
  # Do not edit by hand unless you know what you are doing.
4
4
  installed: []
@@ -109,6 +109,10 @@ module Senren
109
109
  - A button inside a form needs no `type:`. ButtonComponent omits the
110
110
  attribute so the browser submits, as plain HTML does. Pass
111
111
  `type: :button` only for a trigger that must not submit.
112
+ - Text a component shows or announces goes through
113
+ `senren_t('<component>.<key>', default: 'English')`, with a literal key
114
+ and default. Never hard-code a sentence in a template or in a Stimulus
115
+ controller; hand the controller a translated string as a value.
112
116
 
113
117
  ## Important Files
114
118