phlex-forms 0.4.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9b781532660ae609037596969274d95acbb9eac449c60475c51a91329a9fcb5
4
- data.tar.gz: e242d6ecb20a6eb2238b5a2dfb1bb86bd68ad5f2b7a783bdb9b23e2d6cc1c42c
3
+ metadata.gz: 1cbde20cd42869a04e1114e08575a532ea441763b6317893fef4aa9e28bd3902
4
+ data.tar.gz: ef6910db2fdcc82f01b5b61b50b6433c4e41bee9e72ab0acaae7cc3a12ed7446
5
5
  SHA512:
6
- metadata.gz: c9bbc43663fb51f9e8f414702a4aaf7349870ca23f854ce3b9b38220dc32abe42f77820ab840a78708c473d5e30bbb9f918f6ae39025d94b5e94103e9b2ae3de
7
- data.tar.gz: 9435b6699cab6da09d974077c8b2ff4f6f83d4f28b0cc44d2fd56419fea1b5bca2cb7347ef86b16a6ede417fddda62ce2d4c03c1e02d9e2ae58b315272701969
6
+ metadata.gz: 0d4b972c08db3453e7ea345bfa8bacacc18416106a12af4dbdac730a6ebd4a634d26dd3d11cff431c383c41e59c1b352c76e008442095a59bd6e144b1d7cd6d3
7
+ data.tar.gz: bff7f0a358de04a2d41fa4f387025428033591e4d3f94fa4834a6aaa25427a8f0c509b9352d1d04d9752f42b2e887f2f05d9d1b8737041cec57ab100c2a391f5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-11-13
11
+
12
+ ### Added
13
+
14
+ - **An advisory `recommended:` length on the length rule** (#51):
15
+ `validate: { length: { maximum: 50, recommended: 32 } }`, or the same
16
+ options on a model's `validates … length:`, emits
17
+ `data-validations--length-recommended-value`. The counter reads
18
+ `n / recommended` up to it, then `n / maximum` with `text-warning`, and
19
+ `text-error` past the maximum as before. It never adds an error and never
20
+ becomes `maxlength`; the server ignores it. A counter now renders for
21
+ `recommended:` alone, and an inline rule may give `recommended:` without a
22
+ bound. A value that is not a positive Integer below the maximum, or one
23
+ next to an exact `is:`, is ignored.
24
+
25
+ ## [0.5.0] - 2026-10-15
26
+
27
+ ### Upgrading to 0.5
28
+
29
+ The configuration is frozen once the app has booted, so threads and fibers
30
+ read one immutable value, and so can Ractors when every setting can be shared
31
+ (#42, conformance-kit; see the unshareable-value note below).
32
+
33
+ - **It finalizes on its own.** Under Rails the engine calls
34
+ `PhlexForms.finalize!` in `after_initialize`, after every initializer has
35
+ run, with no app code. Without Rails, `PhlexForms.configure` finalizes when
36
+ its block returns; a host that never calls `configure` (or loads Rails
37
+ without booting an app) calls `PhlexForms.finalize!` at boot.
38
+ - **A direct write after boot raises `FrozenError`.** `PhlexForms.config.infer_from_model = false`
39
+ in a Rails initializer still works. After boot (a test's `before` block, or
40
+ after `configure` outside Rails), write through `PhlexForms.configure`
41
+ instead: it edits a copy and swaps it in.
42
+ - **Finalizing never raises over a value it cannot share.** The value is kept
43
+ as given and works on the main Ractor only: an icon renderer lambda at the
44
+ top level of an initializer (its `self` is `main`, and it may close over a
45
+ logger) is one. Define it in a class or module body (or with
46
+ `Ractor.shareable_lambda` on Ruby 4.0) to keep the whole configuration
47
+ shareable. A renderer that is not a Proc (an object with `#call`, a
48
+ `Method`) is never copied or frozen, so one that keeps a cache still works.
49
+ - **`form_options` and the `live` macros keep frozen copies** of what they are
50
+ given, so the values nested in `form_defaults` and the live settings are
51
+ frozen. Your own arrays and hashes are copied, never frozen.
52
+
53
+ ### Added
54
+
55
+ - **`PhlexForms.finalize!`**: freezes the configuration and stores
56
+ Ractor-shareable copies of its settings. Idempotent; returns the
57
+ configuration. `PhlexForms::Theme::DAISY` / `PLAIN` are the built-in themes,
58
+ built once when the theme loads (`Theme.daisy` / `Theme.plain` return them),
59
+ and every `Theme` is frozen.
60
+ - **Ractor support for the non-rendering paths.** On Ruby 4.0, field
61
+ inference, option resolution, theme resolution, the icon renderer and the
62
+ `form_options` / `live` settings work inside a non-main Ractor. Rendering
63
+ there waits on Phlex
64
+ ([yippee-fun/phlex#1055](https://github.com/yippee-fun/phlex/pull/1055)).
65
+ - **conformance-kit 0.4 gates in CI**: the thread-safety RuboCop cops, thread
66
+ and fiber contention specs, a Ractor canary and a strict
67
+ `conformance-audit` of `PhlexForms` and `Forms` with nothing allowlisted.
68
+
69
+ ### Changed
70
+
71
+ - **The configuration is frozen after boot** (see Upgrading to 0.5).
72
+ `Configuration` sets every default in `initialize`; no reader memoizes. The
73
+ default icon renderer and `Configuration.glyphs_renderer` are shareable.
74
+ - `rich_textarea` renders `<lexxy-editor>` through Phlex's `tag` instead of
75
+ `register_element`; the markup is the same.
76
+
77
+ ### Fixed
78
+
79
+ - **The themes build without phlex-reactive.** `Theme.daisy` named
80
+ `Forms::TagField` before checking for the soft dependency, so a host without
81
+ phlex-reactive got `NameError: uninitialized constant Forms::TagField` on its
82
+ first form.
83
+ - **`Theme.daisy` / `Theme.plain` are built once.** Two threads resolving a
84
+ theme for the first time could each build their own.
85
+ - **phlex-forms loads without Rails with phlex-reactive bundled.** phlex-reactive's
86
+ `Sortable` calls `Hash#to_json` while it loads without requiring json, so a
87
+ plain Ruby host got `NoMethodError` on its first form (and, with finalizing,
88
+ from `configure`). phlex-forms requires json before phlex-reactive.
89
+
90
+ ## [0.4.1] - 2026-10-14
91
+
92
+ ### Fixed
93
+
94
+ - **Client-side validators honour the validator's `message:`** (issue #41).
95
+ `validates :code, format: { with: /\A[a-z]*\z/, message: "lowercase only" }`
96
+ used to show the generic "is invalid" in the browser and the specific text
97
+ only after a round trip. The introspector now emits
98
+ `data-validations--<validator>-message-value`: a String as is (`%{count}`
99
+ stays for the browser to fill), a Symbol resolved on the server through
100
+ ActiveModel's lookup chain in the current locale, a Proc skipped (it needs a
101
+ record). Length's `too_long:` / `too_short:` / `wrong_length:` win over
102
+ `message:`, as on the server. Every shipped controller prefers the value and
103
+ falls back to its bundled message; the submit coordinator collects it too. A
104
+ `validate: { presence: { message: "…" } }` / `confirmation: { message: "…" }`
105
+ rules hash now keeps its options (it used to collapse them to `true`).
106
+
107
+ ## [0.4.0] - 2026-09-30
108
+
10
109
  ### Upgrading to 0.4
11
110
 
12
111
  phlex-forms now depends on **daisyui `~> 2.0`** and merges every default +
@@ -49,20 +148,10 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
49
148
  longer raises `FeatureUnavailable`. The Plain theme is unchanged and still
50
149
  emits zero daisy classes.
51
150
 
151
+ ## [0.3.1] - 2026-09-09
152
+
52
153
  ### Fixed
53
154
 
54
- - **Client-side validators honour the validator's `message:`** (issue #41).
55
- `validates :code, format: { with: /\A[a-z]*\z/, message: "lowercase only" }`
56
- used to show the generic "is invalid" in the browser and the specific text
57
- only after a round trip. The introspector now emits
58
- `data-validations--<validator>-message-value`: a String as is (`%{count}`
59
- stays for the browser to fill), a Symbol resolved on the server through
60
- ActiveModel's lookup chain in the current locale, a Proc skipped (it needs a
61
- record). Length's `too_long:` / `too_short:` / `wrong_length:` win over
62
- `message:`, as on the server. Every shipped controller prefers the value and
63
- falls back to its bundled message; the submit coordinator collects it too. A
64
- `validate: { presence: { message: "…" } }` / `confirmation: { message: "…" }`
65
- rules hash now keeps its options (it used to collapse them to `true`).
66
155
  - **`rich_textarea` forwards its block into `<lexxy-editor>`**: Lexxy configures
67
156
  an editor through CHILD elements — `<lexxy-prompt>` for @mentions,
68
157
  `<lexxy-code-language-picker>` for code blocks — but the component rendered
@@ -79,29 +168,59 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
79
168
  pin uploads to whichever host rendered the page first), fully guarded — no
80
169
  Rails dependency is introduced, and a caller-supplied value always wins.
81
170
 
171
+ ## [0.3.0] - 2026-08-12
172
+
173
+ ### Fixed
174
+
82
175
  - **`FileInput` multiple uploads submit an array** (Rails `file_field` parity):
83
176
  with `multiple:` set, the input name now gets `[]` appended (unless already
84
177
  present), for the standalone component, the `Plain` variant, and the form
85
178
  builder path. Without it the browser collapsed a multi-file selection into
86
179
  ONE scalar param, which a host app's `params.expect(attr: [])` silently
87
180
  discarded — uploads no-opped with a success response.
181
+ - **Hidden fields render as bare inputs** (#22): `f.Hidden`, `f.hidden_field`,
182
+ `f.field(..., as: :hidden)` and `f.Input(..., type: :hidden)` went through
183
+ the `:input` theme role, so under the daisy theme a hidden field carried
184
+ `class="input w-full"`, and daisyUI's `display: inline-flex` beat Safari's
185
+ non-`!important` `display: none`: an empty box and a phantom tab stop. They
186
+ now render the new `Forms::Hidden` leaf through a new `:hidden` theme role,
187
+ with no styling classes and no `error:`.
188
+
189
+ ## [0.2.9] - 2026-07-16
190
+
191
+ ### Fixed
192
+
193
+ - **A caller width class stacked with the `w-full` default instead of replacing
194
+ it**: `DelegatedField#width_class` joined `"w-full"` and the caller's
195
+ `class:` verbatim, so `Forms::Select(class: "w-36")` emitted
196
+ `class="... w-full w-36"` and stylesheet source order — not author intent —
197
+ picked the winner (in practice `w-full`, which blew filter selects up to full
198
+ width; zazu#2934). `ClassMerge` gained a width family (anchored `w-*`, so
199
+ `min-w-*`/`max-w-*` still compose) and `width_class` merges through it —
200
+ last width wins, matching the daisyui size/color family semantics.
201
+
202
+ ## [0.2.8] - 2026-07-11
203
+
204
+ ### Added
205
+
206
+ - **`hidden_field`** (#21): a snake_case alias of `f.Hidden` for Rails
207
+ `FormBuilder` migrations, on inline forms and `fields_for` builders. An
208
+ explicit `value:` still wins over the model's value.
209
+
210
+ ## [0.2.7] - 2026-07-11
211
+
212
+ ### Changed
213
+
214
+ - **`tag_field` uses phlex-reactive 0.12.2's escape-hatch sugar**
215
+ (`reactive_tags` / `reactive_filter`) (#20): the wire name is validated at
216
+ render, and the client-side type-ahead filter runs again (under
217
+ phlex-reactive 0.12.x only one of its two selectors was emitted, so it never
218
+ ran).
219
+
220
+ ## [0.2.6] - 2026-07-11
88
221
 
89
222
  ### Added
90
223
 
91
- - **`checkbox_group` — batched checkbox group for array-valued fields** (the
92
- tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id,
93
- label: :name, variant: :pill, size: :sm)`, or via field inference
94
- (`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`).
95
- Shares one array-valued field name with a leading empty-array hidden field,
96
- derives the checked set from the model's current value, and renders under both
97
- themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
98
- - **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
99
- now carry an accessible name/description. HTML/ARIA attributes pass straight
100
- through to the group, so the bare verb is named with plain `aria:`
101
- (`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
102
- the Control's own visible `label:` / `hint:` get stable ids and the field wires
103
- `aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
104
- name, output is unchanged.
105
224
  - **`checkbox_group` `item_label:`** — the per-item text accessor, so a
106
225
  `f.field(:tags, as: :checkbox_group, label: "Tags", item_label: ->(t){…})`
107
226
  renders a visible group heading (`label:`, via the Control) AND custom item
@@ -110,6 +229,30 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
110
229
  item text; on the bare verb `label:` stays the item accessor and `item_label:`
111
230
  is an alias. Absent it, behavior is unchanged.
112
231
 
232
+ ## [0.2.5] - 2026-07-11
233
+
234
+ ### Added
235
+
236
+ - **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
237
+ now carry an accessible name/description. HTML/ARIA attributes pass straight
238
+ through to the group, so the bare verb is named with plain `aria:`
239
+ (`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
240
+ the Control's own visible `label:` / `hint:` get stable ids and the field wires
241
+ `aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
242
+ name, output is unchanged.
243
+
244
+ ## [0.2.4] - 2026-07-11
245
+
246
+ ### Added
247
+
248
+ - **`checkbox_group` — batched checkbox group for array-valued fields** (the
249
+ tag/facet-picker shape): `f.checkbox_group(:tag_ids, Tag.all, value: :id,
250
+ label: :name, variant: :pill, size: :sm)`, or via field inference
251
+ (`f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id`).
252
+ Shares one array-valued field name with a leading empty-array hidden field,
253
+ derives the checked set from the model's current value, and renders under both
254
+ themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
255
+
113
256
  ### Changed
114
257
 
115
258
  - **Client-side validation Stimulus identifiers dropped the `forms--` prefix**:
@@ -124,15 +267,6 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
124
267
 
125
268
  ### Fixed
126
269
 
127
- - **A caller width class stacked with the `w-full` default instead of replacing
128
- it**: `DelegatedField#width_class` joined `"w-full"` and the caller's
129
- `class:` verbatim, so `Forms::Select(class: "w-36")` emitted
130
- `class="... w-full w-36"` and stylesheet source order — not author intent —
131
- picked the winner (in practice `w-full`, which blew filter selects up to full
132
- width; zazu#2934). `ClassMerge` gained a width family (anchored `w-*`, so
133
- `min-w-*`/`max-w-*` still compose) and `width_class` merges through it —
134
- last width wins, matching the daisyui size/color family semantics.
135
-
136
270
  - **`f.Radio` / `Field#radio` rendered the model's current value on every radio
137
271
  instead of each radio's own value**: `field_attributes` carried `value:
138
272
  field_value` and was splatted after the explicit positional value, clobbering
@@ -149,6 +283,37 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
149
283
  instead of a single `scope[assoc][field]`. It is now treated as a single
150
284
  nested scope; only genuine collections (Enumerable, not Hash) iterate.
151
285
 
286
+ ## [0.2.3] - 2026-07-08
287
+
288
+ ### Added
289
+
290
+ - **`tag_field`** (#7): `f.field :tags, as: :tags, suggestions: [...]`, a
291
+ model-bound tag/chip control composed from phlex-reactive's client-only tag
292
+ primitives. It submits one comma-joined param; `live_tags` validates it
293
+ inside a `live` form.
294
+
295
+ ## [0.2.2] - 2026-07-08
296
+
297
+ ### Fixed
298
+
299
+ - **`Forms::TimeZoneSelect` raised at render time** (#5): `NoMethodError` for
300
+ `time_zone_options_for_select` (called on the `DaisyUI::Select` receiver)
301
+ and `Phlex::ArgumentError: Expected a String` (an HTML-safe buffer passed to
302
+ `safe`). It was broken in 0.2.0 and 0.2.1.
303
+
304
+ ## [0.2.1] - 2026-07-08
305
+
306
+ ### Added
307
+
308
+ - **Rails form-builder compat aliases** (#4): `Forms::Form#object` (`model`),
309
+ `#object_name` (`scope`) and `#[]` (`field_object`), and
310
+ `Forms::FieldsForBuilder#object` / `#[]`, for call sites migrating off an
311
+ ActionView `FormBuilder`.
312
+
313
+ ## [0.2.0] - 2026-07-08
314
+
315
+ ### Added
316
+
152
317
  - **`Forms::Base` declarative form classes**: subclass, declare fields in
153
318
  `#fields` where `self` IS the form (bare `field :email`, no `f.` prefix),
154
319
  render with `render UserForm.new(model: @user)`. Class-level `form_options`
@@ -217,7 +382,9 @@ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
217
382
  Pass `as:` explicitly or set `PhlexForms.config.infer_from_model = false`
218
383
  to keep the old rendering.
219
384
 
220
- ### Initial extraction
385
+ ## [0.1.0] - 2026-07-07
386
+
387
+ ### Added
221
388
 
222
389
  - Initial extraction of the `Forms::` Phlex form builder from the Cosmos apps.
223
390
  - Control-first builder API: `f.field :email, label:, hint:, as:, choices:`
data/README.md CHANGED
@@ -359,6 +359,31 @@ f.field :note, validate: { length: { maximum: 30, too_long: "keep it under %{cou
359
359
  Without a `message:`, the bundled messages apply: they ship for `en` / `fr` /
360
360
  `af`; override them page-wide via `window.PhlexForms.messages`.
361
361
 
362
+ The length counter can count against an advisory `recommended:` length below
363
+ the hard `maximum:`. It never adds an error, never becomes `maxlength`, and the
364
+ server ignores it (ActiveModel keeps the unknown option without reading it):
365
+
366
+ ```ruby
367
+ f.field :title, as: :textarea, validate: { length: { maximum: 50, recommended: 32 } }
368
+ validates :title, length: { maximum: 50, recommended: 32 } # the same, from the model
369
+ ```
370
+
371
+ | Length | Counter | Class |
372
+ |---|---|---|
373
+ | up to `recommended` | `n / recommended` | none |
374
+ | past `recommended`, up to `maximum` | `n / maximum` | `text-warning` |
375
+ | past `maximum` | `n / maximum` | `text-error` (and the `too_long` message) |
376
+
377
+ Without a `maximum:`, the counter reads `n / recommended` and turns
378
+ `text-warning` past it. An inline `validate: { length: { recommended: 32 } }`
379
+ needs no bound; a model-level `validates … length:` still needs one, because
380
+ ActiveModel requires it there. A `recommended:` that is not a positive Integer
381
+ below the maximum, or one next to an exact `is:`, is ignored. The counter's
382
+ `text-warning` / `text-error` classes are toggled from the gem's JavaScript, so
383
+ your Tailwind build must include them. The counter comes from these client-side
384
+ rules only: live validation's server round trip (`Forms::Live`) adds none, and
385
+ a live field shows one only when it also carries client-side length rules.
386
+
362
387
  ## Nested attributes, collections & escape valves
363
388
 
364
389
  ```ruby
@@ -437,6 +462,33 @@ PhlexForms.configure do |c|
437
462
  end
438
463
  ```
439
464
 
465
+ ## Configuration is finalized at boot
466
+
467
+ `PhlexForms.configure` writes the configuration; finalizing freezes it and
468
+ stores Ractor-shareable copies of its settings, so threads and fibers read one
469
+ immutable value, and so can Ractors when every setting can be shared (see
470
+ below). It happens on its own:
471
+
472
+ - **Under Rails** the engine finalizes in `after_initialize`, once every
473
+ initializer has run, so initializers can still write (through `configure`
474
+ or directly).
475
+ - **Without Rails** (no `Rails::Engine` when phlex-forms loads), `configure`
476
+ finalizes when its block returns. A host that never calls `configure`, or
477
+ loads Rails without booting an app, calls `PhlexForms.finalize!` at boot.
478
+
479
+ After that, write through `configure` again (it edits a copy and swaps it in);
480
+ a direct `PhlexForms.config.theme = :plain` raises `FrozenError`. Your own
481
+ arrays and hashes are copied, never frozen.
482
+
483
+ Finalizing never raises over a value it cannot share: that value is kept as
484
+ given and works on the main Ractor only. An icon renderer lambda at the top
485
+ level of an initializer (its `self` is `main`, and it may close over a logger)
486
+ is one; define it in a class or module body (or with `Ractor.shareable_lambda`
487
+ on Ruby 4.0) to keep the whole configuration shareable. A renderer that is not
488
+ a Proc (an object with `#call`, a `Method`) is never copied or frozen, so one
489
+ that keeps a cache still works. `PhlexForms.reset_configuration!` restores the
490
+ open defaults (for test suites).
491
+
440
492
  ## RuboCop cops
441
493
 
442
494
  Two cops nudge call sites toward the phlex-forms API. Enable them in your
@@ -3,12 +3,14 @@ import { interpolate } from "phlex_forms/i18n"
3
3
 
4
4
  // Mirrors ActiveModel::Validations::LengthValidator. Reads:
5
5
  // - maximum / minimum / is
6
+ // - recommended (advisory: the counter's target, never an error)
6
7
  // - too-long / too-short / wrong-length messages (win over `message`)
7
8
  // - allow-blank / allow-nil
8
9
  //
9
- // Counter is rendered next to the input when `maximum` is set, so
10
- // the user gets a live "12 / 60" indicator without having to wait
11
- // for blur.
10
+ // Counter is rendered next to the input when `maximum` or `recommended`
11
+ // is set, so the user gets a live "12 / 60" indicator without having to
12
+ // wait for blur. Past `recommended` it counts against `maximum` and turns
13
+ // `text-warning`; past `maximum` it turns `text-error`.
12
14
  export default class extends FieldValidatorController {
13
15
  // Stimulus walks the prototype chain to accumulate `static values`
14
16
  // and `static targets`, so we only declare the validator-specific
@@ -21,6 +23,7 @@ export default class extends FieldValidatorController {
21
23
  maximum: Number,
22
24
  minimum: Number,
23
25
  is: Number,
26
+ recommended: Number,
24
27
  tooLongMessage: String,
25
28
  tooShortMessage: String,
26
29
  wrongLengthMessage: String,
@@ -28,7 +31,7 @@ export default class extends FieldValidatorController {
28
31
 
29
32
  connect() {
30
33
  super.connect()
31
- if (this.hasMaximumValue) {
34
+ if (this.counts) {
32
35
  this.renderCounter()
33
36
  this.element.addEventListener("input", this.updateCounter)
34
37
  }
@@ -36,11 +39,15 @@ export default class extends FieldValidatorController {
36
39
 
37
40
  disconnect() {
38
41
  super.disconnect()
39
- if (this.hasMaximumValue) {
42
+ if (this.counts) {
40
43
  this.element.removeEventListener("input", this.updateCounter)
41
44
  }
42
45
  }
43
46
 
47
+ get counts() {
48
+ return this.hasMaximumValue || this.recommendedLength(null) !== null
49
+ }
50
+
44
51
  check(value) {
45
52
  const length = this.codepointLength(value)
46
53
 
@@ -67,8 +74,35 @@ export default class extends FieldValidatorController {
67
74
  const counter = this.counterElement()
68
75
  if (!counter) return
69
76
  const length = this.codepointLength(this.element.value)
70
- counter.textContent = `${length} / ${this.maximumValue}`
71
- counter.classList.toggle("text-error", length > this.maximumValue)
77
+ const { limit, state, advisory } = this.counterState(length)
78
+ counter.textContent = `${length} / ${limit}`
79
+ // Without a recommended length the counter is exactly 0.5.0's: a caller's
80
+ // own `text-warning` on a pre-rendered counter target is left alone.
81
+ if (advisory) counter.classList.toggle("text-warning", state === "warning")
82
+ counter.classList.toggle("text-error", state === "error")
83
+ }
84
+
85
+ // The counter's denominator and state. `recommended` applies only below
86
+ // `maximum` (the server drops it otherwise; this guards a hand-written
87
+ // attribute): up to it the counter reads "n / recommended", past it
88
+ // "n / maximum" in a warning colour, past `maximum` an error.
89
+ counterState(length) {
90
+ const maximum = this.hasMaximumValue ? this.maximumValue : null
91
+ const recommended = this.recommendedLength(maximum)
92
+
93
+ const advisory = recommended !== null
94
+
95
+ if (maximum !== null && length > maximum) return { limit: maximum, state: "error", advisory }
96
+ if (!advisory) return { limit: maximum, state: null, advisory }
97
+ if (length <= recommended) return { limit: recommended, state: null, advisory }
98
+ return { limit: maximum ?? recommended, state: "warning", advisory }
99
+ }
100
+
101
+ recommendedLength(maximum) {
102
+ if (!this.hasRecommendedValue) return null
103
+ const recommended = this.recommendedValue
104
+ if (!(recommended > 0) || (maximum !== null && recommended >= maximum)) return null
105
+ return recommended
72
106
  }
73
107
 
74
108
  // Ruby's String#length counts Unicode codepoints; JS's String#length
data/lib/forms/base.rb CHANGED
@@ -26,9 +26,14 @@ module Forms
26
26
  # (instance args beat class defaults; subclass defaults beat parents'):
27
27
  #
28
28
  # form_options :spaced, url: "/signup", validate: true
29
+ #
30
+ # A class macro: it runs while the form class body loads (at boot, on the
31
+ # main Ractor) and stores Ractor-shareable copies of its arguments
32
+ # (PhlexForms::Shareable.copy), so the readers work in any Ractor.
33
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
29
34
  def form_options(*modifiers, **defaults)
30
- @form_modifiers = modifiers
31
- @form_defaults = defaults
35
+ @form_modifiers = PhlexForms::Shareable.copy(modifiers)
36
+ @form_defaults = PhlexForms::Shareable.copy(defaults)
32
37
  end
33
38
 
34
39
  def form_modifiers
@@ -40,6 +45,7 @@ module Forms
40
45
  inherited = superclass.respond_to?(:form_defaults) ? superclass.form_defaults : {}
41
46
  inherited.merge(@form_defaults || {})
42
47
  end
48
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
43
49
 
44
50
  # Server-truth live validation via phlex-reactive (a soft dependency):
45
51
  #
data/lib/forms/live.rb CHANGED
@@ -42,10 +42,15 @@ module Forms
42
42
  end
43
43
 
44
44
  class_methods do
45
+ # live (setup_live), live_permit, live_deny and live_tags are class macros:
46
+ # they run while the form class body loads (at boot, on the main Ractor)
47
+ # and store Ractor-shareable values (PhlexForms::Shareable.copy), so the
48
+ # readers work in any Ractor.
49
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
45
50
  def setup_live(model_class:, scope:, debounce:)
46
51
  @live_model_class = model_class
47
- @live_scope = scope.to_s
48
- @live_debounce = debounce
52
+ @live_scope = PhlexForms::Shareable.copy(scope.to_s)
53
+ @live_debounce = PhlexForms::Shareable.copy(debounce)
49
54
  reactive_state :model_gid, :touched
50
55
  action :validate, params: { scope.to_sym => :form_attributes, _touch: :string }
51
56
  # phlex-reactive >= 0.11 defaults verify_authorized ON: an action that
@@ -64,8 +69,8 @@ module Forms
64
69
  # Narrow (live_permit) or trim (live_deny) the attributes the :validate
65
70
  # action may assign. Default: the model's column/attribute names plus
66
71
  # every validated attribute (and its _confirmation twin).
67
- def live_permit(*attrs) = @live_permit = attrs.map(&:to_s)
68
- def live_deny(*attrs) = @live_deny = attrs.map(&:to_s)
72
+ def live_permit(*attrs) = @live_permit = PhlexForms::Shareable.copy(attrs.map(&:to_s))
73
+ def live_deny(*attrs) = @live_deny = PhlexForms::Shareable.copy(attrs.map(&:to_s))
69
74
 
70
75
  # Lift a tag field onto the form's reactive root so live :validate sees its
71
76
  # comma-joined value. The widget renders ROOTLESS (no nested reactive root),
@@ -90,7 +95,7 @@ module Forms
90
95
  "non-live)."
91
96
  end
92
97
 
93
- @live_tags = { name: name.to_sym, suggestions: }
98
+ @live_tags = PhlexForms::Shareable.copy({ name: name.to_sym, suggestions: })
94
99
  end
95
100
 
96
101
  def live_tags_declaration = @live_tags || inherited_live(:live_tags_declaration)
@@ -101,6 +106,7 @@ module Forms
101
106
  permitted |= [tag[:name].to_s] if tag
102
107
  permitted - (@live_deny || [])
103
108
  end
109
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
104
110
 
105
111
  private
106
112
 
@@ -219,5 +225,11 @@ module Forms
219
225
  @model.public_send(setter, value) if @model.respond_to?(setter)
220
226
  end
221
227
  end
228
+
229
+ # ActiveSupport::Concern's own list of the concerns this one includes
230
+ # (Phlex::Reactive::Component): filled while this module body loads and
231
+ # only read after, when a form class includes Forms::Live. Frozen, so the
232
+ # module holds no mutable state.
233
+ @_dependencies.freeze
222
234
  end
223
235
  end
@@ -6,8 +6,6 @@ module Forms
6
6
  # Renders an editor bound to the field; when the value is an ActionText::RichText
7
7
  # its HTML body is used as the initial content.
8
8
  class RichTextarea < Phlex::HTML
9
- register_element :lexxy_editor
10
-
11
9
  def initialize(*modifiers, name:, id: nil, value: nil, **options)
12
10
  @modifiers = modifiers
13
11
  @name = name
@@ -20,9 +18,12 @@ module Forms
20
18
 
21
19
  # Lexxy configures the editor through CHILD elements — `<lexxy-prompt>` for
22
20
  # @mentions, `<lexxy-code-language-picker>` for code blocks — so the block
23
- # has to reach the element, not be dropped.
21
+ # has to reach the element, not be dropped. Rendered through `tag`, not a
22
+ # `register_element` method, so the class keeps no element registry of its
23
+ # own.
24
24
  def view_template(&)
25
- lexxy_editor(
25
+ tag(
26
+ :lexxy_editor,
26
27
  name: @name,
27
28
  id: @id,
28
29
  value: formatted_value,
@@ -166,17 +166,32 @@ module Forms
166
166
  attrs[key] = opts[key].to_s if opts.key?(key)
167
167
  end
168
168
 
169
- if opts.key?(:in) || opts.key?(:within)
170
- range = opts[:in] || opts[:within]
169
+ range = opts[:in] || opts[:within]
170
+ if range
171
171
  attrs[:minimum] = range.min.to_s
172
172
  attrs[:maximum] = range.max.to_s
173
173
  end
174
174
 
175
+ attrs[:recommended] = opts[:recommended].to_s if recommended?(opts, range ? range.max : opts[:maximum])
175
176
  attrs[:allow_blank] = "true" if opts[:allow_blank]
176
177
  attrs[:allow_nil] = "true" if opts[:allow_nil]
177
178
  attrs
178
179
  end
179
180
 
181
+ # `recommended:` is advisory (the counter's target, never an error), so
182
+ # it is kept only when it can sit below the hard limit: a positive
183
+ # Integer under a numeric maximum, or any positive Integer when there is
184
+ # no maximum. An exact `is:` length leaves nothing to recommend. Anything
185
+ # else is dropped without raising.
186
+ def recommended?(opts, maximum)
187
+ recommended = opts[:recommended]
188
+ return false unless recommended.is_a?(Integer) && recommended.positive?
189
+ return false if opts.key?(:is)
190
+ return true if maximum.nil?
191
+
192
+ maximum.is_a?(Numeric) && recommended < maximum
193
+ end
194
+
180
195
  def format_attrs(validator)
181
196
  opts = validator.options
182
197
  regex = opts[:with]
@@ -21,6 +21,20 @@ module Forms
21
21
  # acceptance: true,
22
22
  # )
23
23
  class ManualRules
24
+ # ActiveModel's LengthValidator demands a bound, so `length: { recommended: 32 }`
25
+ # alone (an advisory counter with no limit) can't go through `validates`.
26
+ # This no-op validator carries the options instead; the Introspector matches
27
+ # it by its short name, LengthValidator, and it never adds an error.
28
+ module Advisory
29
+ class LengthValidator < ActiveModel::EachValidator
30
+ def validate_each(_record, _attribute, _value); end
31
+ end
32
+ end
33
+
34
+ # The only keys a bound-less advisory rule may carry. Anything else (a
35
+ # misspelled `maxium:`) goes to ActiveModel, which raises for the missing bound.
36
+ ADVISORY_LENGTH_KEYS = %i[recommended allow_blank allow_nil].freeze
37
+
24
38
  def initialize(rules)
25
39
  @rules = rules
26
40
  end
@@ -51,7 +65,11 @@ module Forms
51
65
  when :presence
52
66
  validates :value, presence: opts.is_a?(Hash) ? opts : true if opts
53
67
  when :length
54
- validates :value, length: opts
68
+ if opts.is_a?(Hash) && opts.key?(:recommended) && (opts.keys - ADVISORY_LENGTH_KEYS).empty?
69
+ validates_with Advisory::LengthValidator, opts.merge(attributes: [:value])
70
+ else
71
+ validates :value, length: opts
72
+ end
55
73
  when :format
56
74
  validates :value, format: opts
57
75
  when :numericality
@@ -22,7 +22,23 @@ module PhlexForms
22
22
  # glyphs is intentionally NOT the default: rails_icons reads SVGs from the host
23
23
  # app's asset paths, which a minimal host (or the gem's own test suite) has not
24
24
  # set up — so defaulting to glyphs would raise Icons::IconNotFound.
25
+ #
26
+ # Finalized at boot: `configure` finalizes when the Rails engine is absent, the
27
+ # engine's after_initialize does under Rails (or call `PhlexForms.finalize!`).
28
+ # Finalizing freezes the configuration and stores Ractor-shareable copies of
29
+ # its settings, so any Ractor can read it. A renderer that cannot be shared (a
30
+ # lambda written at the top level of an initializer has `main` as its self) is
31
+ # kept as given and still works on the main Ractor; build it in a class or
32
+ # module body, or with `Ractor.shareable_lambda` on Ruby 4.0, to keep the
33
+ # whole configuration shareable. A renderer that is not a Proc (an object with
34
+ # #call, a Method) is never copied or frozen: it is shared only if it already
35
+ # is. After finalizing, write through `configure`, which edits a copy and
36
+ # swaps it in.
25
37
  class Configuration
38
+ # The bundled inline SVGs. Built in the class body (self is the class and the
39
+ # lambda captures nothing), so it can be made shareable once, here.
40
+ DEFAULT_ICON_RENDERER = Shareable.copy(->(name, **opts) { InlineIcons.render(name, **opts) })
41
+
26
42
  # A ready-made renderer that delegates to glyphs' LucideIcon. Raises a clear
27
43
  # error if glyphs is not loaded.
28
44
  def self.glyphs_renderer
@@ -31,7 +47,7 @@ module PhlexForms
31
47
  "PhlexForms glyphs_renderer requires the `glyphs` gem to be loaded"
32
48
  end
33
49
 
34
- ->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) }
50
+ Shareable.copy(->(name, **opts) { Glyphs::LucideIcon.new(name, **opts) })
35
51
  end
36
52
 
37
53
  attr_writer :icon_renderer
@@ -43,29 +59,48 @@ module PhlexForms
43
59
 
44
60
  # Global daisyui variants prepended to every `field`'s inner input, e.g.
45
61
  # `c.field_variants = [:primary]`. Form-level `field_variants:` and
46
- # call-site modifiers stack after (later wins).
47
- attr_writer :field_variants
48
-
49
- def field_variants
50
- @field_variants ||= []
62
+ # call-site modifiers stack after (later wins). nil means none, stored as an
63
+ # empty list so `c.field_variants << :sm` keeps working.
64
+ def field_variants=(variants)
65
+ @field_variants = variants || []
51
66
  end
52
67
 
53
68
  # The default theme: :daisy, :plain, or a PhlexForms::Theme instance.
54
69
  # Defaults to daisy.
55
70
  attr_writer :theme
56
71
 
72
+ # Every default is set here, never memoized by a reader, so concurrent
73
+ # readers never race to build one. The theme stays nil until set or
74
+ # finalized (the reader falls back to Theme.daisy), so building a
75
+ # configuration loads no component.
76
+ def initialize
77
+ @theme = nil
78
+ @infer_from_model = true
79
+ @field_variants = []
80
+ @icon_renderer = DEFAULT_ICON_RENDERER
81
+ end
82
+
83
+ # `configure` on a finalized configuration edits a copy: give the copy its
84
+ # own variants list, so `c.field_variants << :sm` works on it.
85
+ def initialize_copy(source)
86
+ super
87
+ @field_variants = @field_variants.dup
88
+ end
89
+
90
+ def field_variants
91
+ @field_variants || []
92
+ end
93
+
57
94
  def theme
58
- @theme ||= Theme.daisy
95
+ @theme || Theme.daisy
59
96
  end
60
97
 
61
98
  def infer_from_model
62
- return @infer_from_model if defined?(@infer_from_model) && !@infer_from_model.nil?
63
-
64
- true
99
+ @infer_from_model.nil? || @infer_from_model
65
100
  end
66
101
 
67
102
  def icon_renderer
68
- @icon_renderer ||= ->(name, **opts) { InlineIcons.render(name, **opts) }
103
+ @icon_renderer || DEFAULT_ICON_RENDERER
69
104
  end
70
105
 
71
106
  # Returns a renderable for the named icon. Components pass the result to Phlex
@@ -73,5 +108,18 @@ module PhlexForms
73
108
  def render_icon(name, **)
74
109
  icon_renderer.call(name.to_s, **)
75
110
  end
111
+
112
+ # Stores Ractor-shareable copies of every setting and freezes the
113
+ # configuration. Resolves the default theme, which builds both built-in
114
+ # themes at boot rather than on the first render. Idempotent; returns self.
115
+ def finalize!
116
+ return self if frozen?
117
+
118
+ @theme = Shareable.copy(theme)
119
+ @infer_from_model = Shareable.copy(@infer_from_model)
120
+ @field_variants = Shareable.copy(@field_variants)
121
+ @icon_renderer = Shareable.callable(@icon_renderer)
122
+ freeze
123
+ end
76
124
  end
77
125
  end
@@ -32,6 +32,10 @@ module PhlexForms
32
32
  app.config.i18n.load_path.unshift(*locales)
33
33
  end
34
34
 
35
+ # PhlexForms.configure leaves the configuration open under Rails; freeze it
36
+ # (with Ractor-shareable settings) once every initializer has run.
37
+ config.after_initialize { PhlexForms.finalize! }
38
+
35
39
  # The raw-hash param type Forms::Live's :validate action schema declares.
36
40
  # Registered from an initializer because phlex-reactive freezes its
37
41
  # param-type registry after boot.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PhlexForms
4
+ # Ractor-shareable copies of boot-time settings (the configuration, the
5
+ # form_options / live class macros), so any Ractor can read them after boot.
6
+ module Shareable
7
+ module_function
8
+
9
+ # A deep-frozen, Ractor-shareable copy of value; value itself is left as the
10
+ # caller has it. A Proc cannot be copied, so it is shared in place, which
11
+ # works only when its self and everything it captures are already shareable
12
+ # (it never freezes them). A value that cannot be shared (a Proc whose self
13
+ # is not shareable, an IO, ...) comes back unchanged: it still works on the
14
+ # main Ractor. Engines without Ractor get the value back as is.
15
+ def copy(value)
16
+ return value unless defined?(::Ractor)
17
+ return value if ::Ractor.shareable?(value)
18
+ return ::Ractor.make_shareable(value) if value.is_a?(::Proc)
19
+
20
+ ::Ractor.make_shareable(value, copy: true)
21
+ rescue ::Ractor::Error, ::TypeError
22
+ value
23
+ end
24
+
25
+ # A callable (the icon renderer) shared only where that leaves it working: a
26
+ # Proc as in copy, any other object kept as given. A frozen copy of an
27
+ # object would break one that keeps state of its own (a cache, a buffer)
28
+ # on its first call, and a Method cannot be copied.
29
+ def callable(value)
30
+ value.is_a?(::Proc) ? copy(value) : value
31
+ end
32
+ end
33
+ end
@@ -19,8 +19,10 @@ module PhlexForms
19
19
  #
20
20
  # PhlexForms::Theme.resolve(:plain).with(input: MyInput)
21
21
  class Theme
22
+ # Frozen, so a theme of component classes is Ractor-shareable.
22
23
  def initialize(components)
23
24
  @components = components.freeze
25
+ freeze
24
26
  end
25
27
 
26
28
  def [](role)
@@ -45,48 +47,53 @@ module PhlexForms
45
47
  end
46
48
  end
47
49
 
48
- def daisy
49
- @daisy ||= new({
50
- input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
51
- textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
52
- checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
53
- checkbox_group: Forms::CheckboxGroup,
54
- file: Forms::FileInput, wrapped_input: Forms::WrappedInput, hidden: Forms::Hidden,
55
- control: Forms::FormControl, label: Forms::Label,
56
- field_error: Forms::FieldError, field_hint: Forms::FieldHint,
57
- submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
58
- # tag_field is phlex-reactive-gated (ClientBindings); only registered
59
- # when the soft dep is present, else :tag_field raises a clear KeyError.
60
- **reactive_roles(Forms::TagField, Forms::RootlessTagField)
61
- })
62
- end
50
+ def daisy = DAISY
63
51
 
64
- # choices_select degrades to the native plain select (no choices.js) and
65
- # rich_textarea to a plain textarea; toggle renders as a checkbox.
66
- def plain
67
- @plain ||= new({
68
- input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
69
- textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
70
- checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
71
- checkbox_group: Forms::Plain::CheckboxGroup,
72
- file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput, hidden: Forms::Hidden,
73
- control: Forms::Plain::Control, label: Forms::Plain::Label,
74
- field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
75
- submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
76
- **reactive_roles(Forms::Plain::TagField, Forms::Plain::RootlessTagField)
77
- })
78
- end
52
+ def plain = PLAIN
79
53
 
80
54
  private
81
55
 
82
56
  # phlex-reactive-gated roles: mapped only when the soft dep is present. When
83
57
  # absent, the leaf classes aren't autoloaded, so these roles are simply not
84
58
  # registered (fetching one raises the theme's own KeyError with the list).
85
- def reactive_roles(tag_field_class, rootless_tag_field_class)
59
+ # Takes the namespace, not the classes: naming Forms::TagField without
60
+ # phlex-reactive would raise NameError before the guard ran.
61
+ def reactive_roles(namespace)
86
62
  return {} unless defined?(Phlex::Reactive)
87
63
 
88
- { tag_field: tag_field_class, rootless_tag_field: rootless_tag_field_class }
64
+ { tag_field: namespace::TagField, rootless_tag_field: namespace::RootlessTagField }
89
65
  end
90
66
  end
67
+
68
+ # Both built-ins are built once, when this file loads (never memoized by a
69
+ # reader, so no two threads race to build one; PhlexForms.finalize! loads
70
+ # it at boot), and the phlex-reactive probe is evaluated once, here.
71
+ DAISY = new({
72
+ input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
73
+ textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
74
+ checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
75
+ checkbox_group: Forms::CheckboxGroup,
76
+ file: Forms::FileInput, wrapped_input: Forms::WrappedInput, hidden: Forms::Hidden,
77
+ control: Forms::FormControl, label: Forms::Label,
78
+ field_error: Forms::FieldError, field_hint: Forms::FieldHint,
79
+ submit: Forms::Submit, row: Forms::Row, group: Forms::Group,
80
+ # tag_field is phlex-reactive-gated (ClientBindings); only registered
81
+ # when the soft dep is present, else :tag_field raises a clear KeyError.
82
+ **reactive_roles(Forms)
83
+ })
84
+
85
+ # choices_select degrades to the native plain select (no choices.js) and
86
+ # rich_textarea to a plain textarea; toggle renders as a checkbox.
87
+ PLAIN = new({
88
+ input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
89
+ textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
90
+ checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
91
+ checkbox_group: Forms::Plain::CheckboxGroup,
92
+ file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput, hidden: Forms::Hidden,
93
+ control: Forms::Plain::Control, label: Forms::Plain::Label,
94
+ field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
95
+ submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group,
96
+ **reactive_roles(Forms::Plain)
97
+ })
91
98
  end
92
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.4.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/phlex_forms.rb CHANGED
@@ -10,6 +10,11 @@ require "zeitwerk"
10
10
  require "daisy_ui"
11
11
 
12
12
  begin
13
+ # phlex-reactive's Sortable calls Hash#to_json while it loads without
14
+ # requiring json (a Rails host has it loaded already). Finalizing builds the
15
+ # themes, which load the tag-field leaves and with them Sortable, so without
16
+ # Rails `configure` would raise NoMethodError.
17
+ require "json"
13
18
  require "phlex/reactive"
14
19
  rescue LoadError
15
20
  # phlex-reactive is a soft dependency: without it the `live` server-truth
@@ -32,17 +37,38 @@ module PhlexForms
32
37
  # used but its backing dependency is not available.
33
38
  class FeatureUnavailable < Error; end
34
39
 
40
+ # @configuration is built when this file loads (see the bottom) and replaced
41
+ # only by configure and reset_configuration!, on the main Ractor at boot or
42
+ # between tests (a non-main Ractor cannot write a module ivar). Once finalized
43
+ # it is frozen and Ractor-shareable, so any Ractor may read it.
44
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
35
45
  class << self
36
- def configuration
37
- @configuration ||= Configuration.new
38
- end
46
+ attr_reader :configuration
39
47
  alias config configuration
40
48
 
49
+ # Yields the configuration, then finalizes it unless the Rails engine is
50
+ # loaded (its after_initialize finalizes once every initializer has run). A
51
+ # finalized configuration is never edited in place: the block gets a copy,
52
+ # which is finalized and swapped in whole. Returns the block's value.
41
53
  def configure
42
- yield(configuration)
54
+ if @configuration.frozen?
55
+ draft = @configuration.dup
56
+ result = yield(draft)
57
+ @configuration = draft.finalize!
58
+ else
59
+ result = yield(@configuration)
60
+ finalize! unless defined?(PhlexForms::Engine)
61
+ end
62
+ result
63
+ end
64
+
65
+ # Freezes the configuration with Ractor-shareable settings (see
66
+ # Configuration#finalize!). Idempotent; returns the configuration.
67
+ def finalize!
68
+ @configuration.finalize!
43
69
  end
44
70
 
45
- # Reset configuration to defaults. Intended for test isolation.
71
+ # Reset configuration to defaults, open again. Intended for test isolation.
46
72
  def reset_configuration!
47
73
  @configuration = Configuration.new
48
74
  end
@@ -97,6 +123,10 @@ end
97
123
 
98
124
  loader.setup
99
125
 
126
+ # The default configuration, built at load (never lazily, so no two readers
127
+ # race to create it).
128
+ PhlexForms.reset_configuration!
129
+
100
130
  # Optional Rails integration (Stimulus controllers via importmap/assets, and
101
131
  # the gem's default locale files). Loaded only under Rails; the gem stays pure
102
132
  # Phlex otherwise.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-forms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -189,6 +189,7 @@ files:
189
189
  - lib/phlex_forms/inference.rb
190
190
  - lib/phlex_forms/inline_icons.rb
191
191
  - lib/phlex_forms/rubocop.rb
192
+ - lib/phlex_forms/shareable.rb
192
193
  - lib/phlex_forms/theme.rb
193
194
  - lib/phlex_forms/version.rb
194
195
  - lib/rubocop/phlex_forms/cop/legacy_form_method.rb