stimulus_plumbers 0.4.9 → 0.4.10

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.
@@ -61,11 +61,11 @@ Special options on native helpers:
61
61
 
62
62
  Three methods render a complete, accessible field:
63
63
 
64
- | Method | `as:` values |
65
- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
66
- | `f.field(attr, as:, **opts)` | `:text` `:email` `:number` `:url` `:tel` `:color` `:month` `:week` `:range` `:datetime_local` `:text_area` `:file` `:password` `:date` `:time` `:select` `:search` |
67
- | `f.collection_field(attr, as:, collection:, value_method:, text_method:, **opts)` | `:collection_select` `:grouped_collection_select` |
68
- | `f.choice(attr, as:, **opts)` | `:radio` `:check_box` |
64
+ | Method | `as:` values |
65
+ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `f.field(attr, as:, **opts)` | `:text` `:email` `:number` `:url` `:tel` `:color` `:month` `:week` `:range` `:datetime_local` `:text_area` `:file` `:password` `:date` `:time` `:select` `:search` `:code` `:credit_card` |
67
+ | `f.collection_field(attr, as:, collection:, value_method:, text_method:, **opts)` | `:collection_select` `:grouped_collection_select` |
68
+ | `f.choice(attr, as:, **opts)` | `:radio` `:check_box` |
69
69
 
70
70
  ### Shared field options
71
71
 
@@ -89,6 +89,8 @@ Three methods render a complete, accessible field:
89
89
  <%= f.field :bio, as: :text_area, hint: "Tell us about yourself." %>
90
90
  <%= f.field :avatar, as: :file %>
91
91
  <%= f.field :password, as: :password, revealable: true %>
92
+ <%= f.field :verification_code, as: :code, length: 6 %>
93
+ <%= f.field :card_number, as: :credit_card %>
92
94
  <%= f.field :email, as: :email, floating: :filled %>
93
95
  <%= f.field :country, as: :select,
94
96
  choices: [["Australia", "au"], ["Canada", "ca"], ["United States", "us"]],
@@ -147,6 +149,26 @@ Use `f.search_field` for a native `<input type="search">`.
147
149
 
148
150
  Use `f.password_field` for a plain `<input type="password">` (also accepts `revealable:`).
149
151
 
152
+ **Code** (`as: :code`) — character-cell entry backed by `input-formatter` and the `character-cells` plumber. The native input remains the accessible control; cells are decorative.
153
+
154
+ | Option | Values | Default | Description |
155
+ | -------------- | ---------------------------------------- | ---------------------- | ------------------------------------------- |
156
+ | `length` | positive Integer | required | Number of cells and input maximum length |
157
+ | `charset` | `:digits` / `:letters` / `:alphanumeric` | `:digits` | Allowed code characters |
158
+ | `groups` | Array of positive Integers | `[]` | Visual cell groups; must add up to `length` |
159
+ | `autocomplete` | String | `"one-time-code"` | Native autocomplete value |
160
+ | `inputmode` | String | `"numeric"` for digits | Native input mode |
161
+
162
+ **Credit card** (`as: :credit_card`) — grouped card-number entry backed by `input-formatter` and the `character-cells` plumber (grouped mode). Renders one cell per group with a literal dash separator between cells; their sum sets `maxlength`.
163
+
164
+ | Option | Values | Default | Description |
165
+ | -------------- | -------------------------- | -------------- | ---------------------------------------------------------------------------- |
166
+ | `groups` | Array of positive Integers | `[4, 4, 4, 4]` | Cell groups — one cell per group, dash-separated; their sum sets `maxlength` |
167
+ | `autocomplete` | String | `"cc-number"` | Native autocomplete value |
168
+ | `inputmode` | String | `"numeric"` | Native input mode |
169
+
170
+ Character-cell fields do not support `floating:` labels.
171
+
150
172
  ---
151
173
 
152
174
  ## f.collection_field
data/docs/guide.md CHANGED
@@ -37,3 +37,12 @@ generator) inject their CSS directives into the first entry file found, in order
37
37
  4. `app/javascript/entrypoints/application.css` (`jsbundling-rails` esbuild/webpack default)
38
38
 
39
39
  Override with `STIMULUS_PLUMBERS_CSS_ENTRY=/path/to/entry.css` — used by both gems' generators.
40
+
41
+ ## Installed CSS files
42
+
43
+ The core installer copies its token defaults to
44
+ `app/assets/stylesheets/stimulus_plumbers/tokens.css` and imports that app-local file.
45
+
46
+ This file is application-owned: later generator runs create a missing file but never overwrite an
47
+ existing one. They also replace legacy imports that pointed into an installed gem with the new
48
+ relative app-local import.
@@ -9,13 +9,19 @@ module StimulusPlumbers
9
9
  class InstallGenerator < Rails::Generators::Base
10
10
  include StimulusPlumbers::Generators::CssEntrypoint
11
11
 
12
+ TOKENS_CSS_SOURCE = File.join(
13
+ File.expand_path("../../../..", __dir__),
14
+ TokensDirective::TOKENS_CSS_PATH
15
+ )
16
+
12
17
  def install
13
18
  css_file = entry_css_file(**css_file_lookup_options)
14
19
  return warn_entry_css_not_found(label: "CSS", **css_file_lookup_options) unless css_file
20
+ return unless copy_asset(TOKENS_CSS_SOURCE, TokensDirective::TOKENS_CSS_PATH)
15
21
 
16
22
  apply_edit(
17
23
  css_file,
18
- TokensDirective.directive(from: File.dirname(css_file)),
24
+ TokensDirective.directive(from: File.dirname(css_file), destination_root: destination_root),
19
25
  stale_pattern: TokensDirective.stale_pattern
20
26
  )
21
27
  end
@@ -9,10 +9,6 @@ module StimulusPlumbers
9
9
  config.autoload_paths << File.expand_path("../stimulus-plumbers", __dir__)
10
10
  config.i18n.load_path += Dir[File.expand_path("../../config/locales/*.{rb,yml}", __dir__)]
11
11
 
12
- initializer "stimulus_plumbers.assets", after: :set_default_precompile do |app|
13
- app.config.assets.precompile += %w[stimulus_plumbers/tokens.css] if app.config.respond_to?(:assets)
14
- end
15
-
16
12
  initializer "stimulus_plumbers.helpers" do
17
13
  ActiveSupport.on_load(:action_view) do
18
14
  include StimulusPlumbers::Helpers
@@ -11,6 +11,8 @@ require_relative "fields/renderer"
11
11
  require_relative "fields/fieldset"
12
12
  require_relative "fields/inputs/checkbox"
13
13
  require_relative "fields/inputs/combobox"
14
+ require_relative "fields/inputs/code"
15
+ require_relative "fields/inputs/credit_card"
14
16
  require_relative "fields/inputs/datetime"
15
17
  require_relative "fields/inputs/radio"
16
18
  require_relative "fields/inputs/file"
@@ -30,6 +32,8 @@ module StimulusPlumbers
30
32
  include Plumber::Options::Html
31
33
  include Plumber::Options::Aria
32
34
  include Fields::Inputs::Checkbox
35
+ include Fields::Inputs::Code
36
+ include Fields::Inputs::CreditCard
33
37
  include Fields::Inputs::Datetime
34
38
  include Fields::Inputs::Radio
35
39
  include Fields::Inputs::File
@@ -8,7 +8,7 @@ module StimulusPlumbers
8
8
  class Field < Base
9
9
  TYPES = %i[
10
10
  text email number url tel color month week range datetime_local
11
- text_area file password date time select search
11
+ text_area file password date time select search code credit_card
12
12
  ].freeze
13
13
  COLLECTION_TYPES = %i[radio check_box collection_select grouped_collection_select].freeze
14
14
  OPTIONS = (Base::OPTIONS + %i[hide_label]).freeze
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Form
5
+ module Fields
6
+ module Inputs
7
+ module Code
8
+ private
9
+
10
+ def render_code_input(attribute, html_opts, opts, error, length:, charset: :digits, groups: [], **kwargs)
11
+ floating = kwargs.delete(:floating)
12
+ raise ArgumentError, "floating labels are not supported for code fields" if floating.present?
13
+
14
+ validate_code_options!(length, groups)
15
+ inputmode = kwargs.delete(:inputmode) || (charset.to_sym == :digits ? "numeric" : nil)
16
+ autocomplete = kwargs.delete(:autocomplete) || "one-time-code"
17
+ config = { error: error, length: length, charset: charset, groups: groups,
18
+ autocomplete: autocomplete, inputmode: inputmode, kwargs: kwargs
19
+ }
20
+ render_code_field(attribute, html_opts, opts, config)
21
+ end
22
+
23
+ def render_code_field(attribute, html_opts, opts, config)
24
+ @template.content_tag(:div, **code_wrapper_options(config)) do
25
+ @template.safe_join(
26
+ [render_code_cells(config), code_input(attribute, html_opts, opts, config)]
27
+ )
28
+ end
29
+ end
30
+
31
+ def code_wrapper_options(config)
32
+ merge_html_options(
33
+ theme.resolve(:form_field_input_code, error: config[:error]),
34
+ { data: {
35
+ controller: "input-formatter",
36
+ input_formatter_format_value: "code",
37
+ input_formatter_options_value: { charset: config[:charset].to_s, length: config[:length] }.to_json,
38
+ input_formatter_groups_value: config[:groups].presence
39
+ }
40
+ }
41
+ )
42
+ end
43
+
44
+ def code_input(attribute, html_opts, opts, config)
45
+ input_options = merge_html_options(
46
+ theme.resolve(:form_field_input_code_overlay, error: config[:error]),
47
+ opts,
48
+ html_opts,
49
+ config[:kwargs],
50
+ { maxlength: config[:length], autocomplete: config[:autocomplete], inputmode: config[:inputmode],
51
+ data: { input_formatter_target: "input", action: formatter_actions }
52
+ }
53
+ )
54
+ @template.text_field(@object_name, attribute, objectify_options(input_options))
55
+ end
56
+
57
+ def render_code_cells(config)
58
+ count = config[:groups].present? ? config[:groups].sum : config[:length]
59
+ cell_options = merge_html_options(
60
+ theme.resolve(:form_field_input_code_cell, error: config[:error]),
61
+ { data: { input_formatter_target: "cell" } }
62
+ )
63
+ @template.content_tag(:div, **merge_html_options(theme.resolve(:form_field_input_code_cells))) do
64
+ @template.safe_join(Array.new(count) { @template.content_tag(:span, nil, **cell_options) })
65
+ end
66
+ end
67
+
68
+ def validate_code_options!(length, groups)
69
+ raise ArgumentError, "length must be a positive integer" unless positive_integer?(length)
70
+ return if groups.blank?
71
+
72
+ raise ArgumentError, "groups must be an array of positive integers" unless valid_groups?(groups)
73
+ raise ArgumentError, "groups must add up to length" unless groups.sum == length
74
+ end
75
+
76
+ def valid_groups?(groups) = groups.is_a?(Array) && groups.all? { |group| positive_integer?(group) }
77
+ def positive_integer?(value) = value.is_a?(Integer) && value.positive?
78
+
79
+ def formatter_actions
80
+ "input->input-formatter#onInput focus->input-formatter#onFocus blur->input-formatter#onBlur"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Form
5
+ module Fields
6
+ module Inputs
7
+ module CreditCard
8
+ DEFAULT_GROUPS = [4, 4, 4, 4].freeze
9
+
10
+ private
11
+
12
+ def render_credit_card_input(attribute, html_opts, opts, error, groups: DEFAULT_GROUPS, floating: nil, **kwargs)
13
+ raise ArgumentError, "floating labels are not supported for credit card fields" if floating.present?
14
+
15
+ normalized_groups = groups.presence || DEFAULT_GROUPS
16
+ inputmode = kwargs.delete(:inputmode) || "numeric"
17
+ autocomplete = kwargs.delete(:autocomplete) || "cc-number"
18
+ validate_credit_card_groups!(normalized_groups)
19
+ length = normalized_groups.sum
20
+ config = { error: error, groups: normalized_groups, length: length,
21
+ autocomplete: autocomplete, inputmode: inputmode, kwargs: kwargs
22
+ }
23
+ render_credit_card_field(attribute, html_opts, opts, config)
24
+ end
25
+
26
+ def render_credit_card_field(attribute, html_opts, opts, config)
27
+ @template.content_tag(:div, **credit_card_wrapper_options(config)) do
28
+ @template.safe_join(
29
+ [render_credit_card_cells(config), credit_card_input(attribute, html_opts, opts, config)]
30
+ )
31
+ end
32
+ end
33
+
34
+ def credit_card_wrapper_options(config)
35
+ merge_html_options(
36
+ theme.resolve(:form_field_input_credit_card, error: config[:error]),
37
+ { data: {
38
+ controller: "input-formatter",
39
+ input_formatter_format_value: "creditCard",
40
+ input_formatter_groups_value: config[:groups]
41
+ }
42
+ }
43
+ )
44
+ end
45
+
46
+ def credit_card_input(attribute, html_opts, opts, config)
47
+ input_options = merge_html_options(
48
+ theme.resolve(:form_field_input_credit_card_overlay, error: config[:error]),
49
+ opts,
50
+ html_opts,
51
+ config[:kwargs],
52
+ { maxlength: config[:length], autocomplete: config[:autocomplete], inputmode: config[:inputmode],
53
+ data: { input_formatter_target: "input", action: formatter_actions }
54
+ }
55
+ )
56
+ @template.text_field(@object_name, attribute, objectify_options(input_options))
57
+ end
58
+
59
+ def render_credit_card_cells(config)
60
+ cells = config[:groups].each_index.flat_map do |index|
61
+ index.zero? ? [render_credit_card_cell(config)] : [render_credit_card_separator, render_credit_card_cell(config)]
62
+ end
63
+
64
+ @template.content_tag(:div, **merge_html_options(theme.resolve(:form_field_input_credit_card_cells))) do
65
+ @template.safe_join(cells)
66
+ end
67
+ end
68
+
69
+ def render_credit_card_cell(config)
70
+ cell_options = merge_html_options(
71
+ theme.resolve(:form_field_input_credit_card_cell, error: config[:error]),
72
+ { data: { input_formatter_target: "cell" } }
73
+ )
74
+ @template.content_tag(:span, nil, **cell_options)
75
+ end
76
+
77
+ def render_credit_card_separator
78
+ separator_options = merge_html_options(
79
+ theme.resolve(:form_field_input_credit_card_separator),
80
+ { aria: { hidden: true } }
81
+ )
82
+ @template.content_tag(:span, "-", **separator_options)
83
+ end
84
+
85
+ def validate_credit_card_groups!(groups)
86
+ return if groups.is_a?(Array) && groups.all? { |group| group.is_a?(Integer) && group.positive? }
87
+
88
+ raise ArgumentError, "groups must be an array of positive integers"
89
+ end
90
+
91
+ def formatter_actions
92
+ "input->input-formatter#onInput focus->input-formatter#onFocus blur->input-formatter#onBlur"
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -18,6 +18,8 @@ module StimulusPlumbers
18
18
  text_area: :render_text_area_input,
19
19
  file: :render_file_input,
20
20
  password: :render_password_input,
21
+ code: :render_code_input,
22
+ credit_card: :render_credit_card_input,
21
23
  date: :render_combobox_date,
22
24
  time: :render_combobox_time,
23
25
  select: :render_combobox_dropdown,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module StimulusPlumbers
4
6
  module Generators
5
7
  module CssEntrypoint
@@ -48,6 +50,26 @@ module StimulusPlumbers
48
50
  say "Could not update #{relative_to_destination(css_file)}: #{e.message}. Skipping.", :yellow
49
51
  end
50
52
 
53
+ # CSS copied by an installer belongs to the application from this point on.
54
+ # Keep an existing file intact so a generator rerun never overwrites local
55
+ # customizations (the same policy used by tailwindcss-rails' installer).
56
+ def copy_asset(source, destination)
57
+ path = File.join(destination_root, destination)
58
+
59
+ if File.exist?(path)
60
+ say_status :identical, destination
61
+ return true
62
+ end
63
+
64
+ FileUtils.mkdir_p(File.dirname(path))
65
+ FileUtils.cp(source, path)
66
+ say_status :create, destination, :green
67
+ true
68
+ rescue Errno::EROFS, Errno::EACCES => e
69
+ say "Could not copy #{destination}: #{e.message}. Skipping.", :yellow
70
+ false
71
+ end
72
+
51
73
  # `stale_pattern:` is required — every call site needs stale-import detection,
52
74
  # so making it optional would leave an untested "anchor without stale" state.
53
75
  def content_edit(content, directive, stale_pattern:, anchor_pattern: nil)
@@ -5,19 +5,19 @@ require "pathname"
5
5
  module StimulusPlumbers
6
6
  module Generators
7
7
  module TokensDirective
8
- GEM_ROOT = File.expand_path("../../..", __dir__)
9
- TOKENS_CSS_REL_PATH = "app/assets/stylesheets/stimulus_plumbers/tokens.css"
8
+ TOKENS_CSS_PATH = "app/assets/stylesheets/stimulus_plumbers/tokens.css"
10
9
 
11
10
  module_function
12
11
 
13
- def directive(from:)
14
- path = Pathname.new(GEM_ROOT).join(TOKENS_CSS_REL_PATH)
12
+ def directive(from:, destination_root:)
13
+ path = Pathname.new(destination_root).join(TOKENS_CSS_PATH)
15
14
  rel = path.relative_path_from(Pathname.new(from))
15
+ rel = "./#{rel}" unless rel.to_s.start_with?(".", "/")
16
16
  %(@import "#{rel}";)
17
17
  end
18
18
 
19
19
  def stale_pattern
20
- %r{@import "[^"]*#{Regexp.escape(TOKENS_CSS_REL_PATH)}";}
20
+ %r{@import "[^"]*#{Regexp.escape(TOKENS_CSS_PATH)}";}
21
21
  end
22
22
  end
23
23
  end
@@ -119,69 +119,90 @@ module StimulusPlumbers
119
119
  }.freeze
120
120
 
121
121
  FORM = {
122
- form_group: {
122
+ form_group: {
123
123
  layout: { default: :stacked, validate: Form::Ranges::LAYOUT },
124
124
  error: { default: false, validate: Ranges::BOOL }
125
125
  }.freeze,
126
- form_field_label: {
126
+ form_field_label: {
127
127
  required: { default: false, validate: Ranges::BOOL },
128
128
  hidden: { default: false, validate: Ranges::BOOL },
129
129
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] },
130
130
  error: { default: false, validate: Ranges::BOOL }
131
131
  }.freeze,
132
- form_field_required_mark: {}.freeze,
133
- form_field_hint: {}.freeze,
134
- form_field_error: {}.freeze,
135
- form_field_choice_items: {
132
+ form_field_required_mark: {}.freeze,
133
+ form_field_hint: {}.freeze,
134
+ form_field_error: {}.freeze,
135
+ form_field_choice_items: {
136
136
  layout: { default: :stacked, validate: Form::Ranges::LAYOUT }
137
137
  }.freeze,
138
- form_field_checkbox_label: {
138
+ form_field_checkbox_label: {
139
139
  type: { default: :default, validate: Form::Checkbox::Ranges::TYPE },
140
140
  variant: { default: :tertiary, validate: Form::Checkbox::Ranges::VARIANT }
141
141
  }.freeze,
142
- form_field_radio_label: {
142
+ form_field_radio_label: {
143
143
  type: { default: :default, validate: Form::Radio::Ranges::TYPE },
144
144
  variant: { default: :tertiary, validate: Form::Radio::Ranges::VARIANT }
145
145
  }.freeze,
146
- form_field_radio_item_group: {}.freeze,
147
- form_field_input: {
146
+ form_field_radio_item_group: {}.freeze,
147
+ form_field_input: {
148
148
  error: { default: false, validate: Ranges::BOOL },
149
149
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
150
150
  }.freeze,
151
- form_field_input_group: {
151
+ form_field_input_group: {
152
152
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
153
153
  }.freeze,
154
- form_field_input_textarea: {
154
+ form_field_input_textarea: {
155
155
  error: { default: false, validate: Ranges::BOOL },
156
156
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
157
157
  }.freeze,
158
- form_field_input_file: {
158
+ form_field_input_file: {
159
159
  error: { default: false, validate: Ranges::BOOL },
160
160
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
161
161
  }.freeze,
162
- form_field_input_select: {
162
+ form_field_input_select: {
163
163
  error: { default: false, validate: Ranges::BOOL },
164
164
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
165
165
  }.freeze,
166
- form_field_input_checkbox: {
166
+ form_field_input_checkbox: {
167
167
  error: { default: false, validate: Ranges::BOOL },
168
168
  type: { default: :default, validate: Form::Checkbox::Ranges::TYPE },
169
169
  variant: { default: :tertiary, validate: Form::Checkbox::Ranges::VARIANT }
170
170
  }.freeze,
171
- form_field_input_radio: {
171
+ form_field_input_radio: {
172
172
  error: { default: false, validate: Ranges::BOOL },
173
173
  type: { default: :default, validate: Form::Radio::Ranges::TYPE },
174
174
  variant: { default: :tertiary, validate: Form::Radio::Ranges::VARIANT }
175
175
  }.freeze,
176
- form_field_input_combobox: {
176
+ form_field_input_combobox: {
177
177
  error: { default: false, validate: Ranges::BOOL },
178
178
  floating: { default: nil, validate: [nil, *Form::Floating::Ranges::TYPE] }
179
179
  }.freeze,
180
- form_field_input_reveal: { error: { default: false, validate: Ranges::BOOL } }.freeze,
181
- form_field_input_clearable: {}.freeze,
182
- form_field_input_button_reveal: {}.freeze,
183
- form_field_input_button_clear: {}.freeze,
184
- form_submit: {
180
+ form_field_input_reveal: { error: { default: false, validate: Ranges::BOOL } }.freeze,
181
+ form_field_input_clearable: {}.freeze,
182
+ form_field_input_code: {
183
+ error: { default: false, validate: Ranges::BOOL }
184
+ }.freeze,
185
+ form_field_input_code_cells: {}.freeze,
186
+ form_field_input_code_cell: {
187
+ error: { default: false, validate: Ranges::BOOL }
188
+ }.freeze,
189
+ form_field_input_code_overlay: {
190
+ error: { default: false, validate: Ranges::BOOL }
191
+ }.freeze,
192
+ form_field_input_credit_card: {
193
+ error: { default: false, validate: Ranges::BOOL }
194
+ }.freeze,
195
+ form_field_input_credit_card_cells: {}.freeze,
196
+ form_field_input_credit_card_cell: {
197
+ error: { default: false, validate: Ranges::BOOL }
198
+ }.freeze,
199
+ form_field_input_credit_card_separator: {}.freeze,
200
+ form_field_input_credit_card_overlay: {
201
+ error: { default: false, validate: Ranges::BOOL }
202
+ }.freeze,
203
+ form_field_input_button_reveal: {}.freeze,
204
+ form_field_input_button_clear: {}.freeze,
205
+ form_submit: {
185
206
  type: { default: :default, validate: Button::Ranges::TYPE },
186
207
  variant: { default: :primary, validate: Button::Ranges::VARIANT }
187
208
  }.freeze
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StimulusPlumbers
4
- VERSION = "0.4.9"
4
+ VERSION = "0.4.10"
5
5
  end
data/vendor/ARIA.md CHANGED
@@ -62,6 +62,13 @@ Two helper classes handle keyboard navigation in controllers — see [`stimulus-
62
62
  - Toggle button: `aria-label` describes action ("Show password" / "Hide password")
63
63
  - `aria-pressed` managed on toggle button, reflecting revealed state
64
64
 
65
+ #### Code Input (`input_formatter_controller` + `character_cells`)
66
+
67
+ - The real `<input>` is the entire accessible surface; cells are stamped `aria-hidden="true"` automatically.
68
+ - The input requires a `<label>` (visually hidden allowed), `autocomplete="one-time-code"` for OTP (WCAG 1.3.5), `inputmode` matching the charset, and `maxlength` matching the code length.
69
+ - Overlay the input with `opacity: 0` — never `display: none`/`visibility: hidden`, which remove it from the tab order (WCAG 2.1.1).
70
+ - Focus visibility (WCAG 2.4.7): the caret cell (`data-caret`) appears only while the input has focus; style the wrapper with `:focus-within` plus `[data-caret]`.
71
+
65
72
  #### Input Clearable (`input_clearable_controller`)
66
73
  - Clear button: `hidden` attribute while the input is empty, removing it from the keyboard/AT tab order until there's something to clear
67
74
  - Escape inside the input clears it (keyboard equivalent to clicking the clear button, WCAG 2.1.1); default is prevented so it doesn't also close a parent overlay
@@ -127,6 +127,9 @@
127
127
  },
128
128
  "input-formatter": {
129
129
  "actions": [
130
+ "onBlur",
131
+ "onFocus",
132
+ "onInput",
130
133
  "toggle"
131
134
  ],
132
135
  "listens": [],