phlex-forms 0.3.1 → 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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +16 -6
  4. data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +2 -3
  5. data/app/javascript/phlex_forms/controllers/validations/base_controller.js +12 -1
  6. data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +1 -2
  7. data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +1 -2
  8. data/app/javascript/phlex_forms/controllers/validations/format_controller.js +1 -2
  9. data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +1 -2
  10. data/app/javascript/phlex_forms/controllers/validations/length_controller.js +15 -4
  11. data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +10 -11
  12. data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +1 -2
  13. data/app/javascript/phlex_forms/i18n.js +1 -1
  14. data/lib/forms/choices_select.rb +1 -1
  15. data/lib/forms/collection_check_box.rb +1 -1
  16. data/lib/forms/field_error.rb +1 -1
  17. data/lib/forms/field_hint.rb +1 -1
  18. data/lib/forms/form.rb +3 -4
  19. data/lib/forms/form_control.rb +2 -3
  20. data/lib/forms/rich_textarea.rb +1 -1
  21. data/lib/forms/row.rb +1 -2
  22. data/lib/forms/tag_field.rb +1 -1
  23. data/lib/forms/validations/introspector.rb +24 -1
  24. data/lib/forms/validations/manual_rules.rb +3 -3
  25. data/lib/forms/validations/message.rb +77 -0
  26. data/lib/phlex_forms/configuration.rb +2 -2
  27. data/lib/phlex_forms/delegated_field.rb +3 -4
  28. data/lib/phlex_forms/theme.rb +1 -8
  29. data/lib/phlex_forms/version.rb +1 -1
  30. data/lib/phlex_forms.rb +2 -9
  31. metadata +16 -2
  32. data/lib/phlex_forms/class_merge.rb +0 -66
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9541561e98ecd2afda34daf77d2eeebd725aac0badde75fdbf51401628d081f6
4
- data.tar.gz: 5d4008e8e6e5748ae12fdbcd12a667d016ab30549da71498dbc2e286fe202d81
3
+ metadata.gz: c9b781532660ae609037596969274d95acbb9eac449c60475c51a91329a9fcb5
4
+ data.tar.gz: e242d6ecb20a6eb2238b5a2dfb1bb86bd68ad5f2b7a783bdb9b23e2d6cc1c42c
5
5
  SHA512:
6
- metadata.gz: f10fd9155f6ff9841bdae9c34fb2ac89dbbdbc49df33c012af7efad526fa914d21c6379430e1ee66a1aadeff387dcc9bab353d33cb017ce8b07798dd8058dd2c
7
- data.tar.gz: 7ea1bdaa7fea101bde79b7975759c79b30bc502fb50c2b18e279a5cf4032b294745055e23f2f66ed22cba33c4e43c637c6499ea3c62059f425a3b3aec141ad83
6
+ metadata.gz: c9bbc43663fb51f9e8f414702a4aaf7349870ca23f854ce3b9b38220dc32abe42f77820ab840a78708c473d5e30bbb9f918f6ae39025d94b5e94103e9b2ae3de
7
+ data.tar.gz: 9435b6699cab6da09d974077c8b2ff4f6f83d4f28b0cc44d2fd56419fea1b5bca2cb7347ef86b16a6ede417fddda62ce2d4c03c1e02d9e2ae58b315272701969
data/CHANGELOG.md CHANGED
@@ -7,8 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Upgrading to 0.4
11
+
12
+ phlex-forms now depends on **daisyui `~> 2.0`** and merges every default +
13
+ caller `class:` through `DaisyUI::ClassMerge` (a port of tailwind_merge 1.5.6
14
+ plus daisyUI's modifier groups). `PhlexForms::ClassMerge` is gone.
15
+
16
+ - **Bundle daisyui 2.** It's a hard dependency now, so a `gem "daisyui", "~> 1.x"`
17
+ pin in your Gemfile must move to 2.x (or be dropped).
18
+ - **Register your custom Tailwind utilities.** The old merger only knew daisy
19
+ sizes/colors and `w-*`, and passed everything else through. The new one
20
+ classifies every utility, so an unregistered custom one can be dropped as a
21
+ conflict. For example, a `text-h1` you pass next to `FieldError`'s
22
+ `text-error` reads as a colour:
23
+
24
+ ```ruby
25
+ # config/initializers/daisy_ui.rb
26
+ DaisyUI.configure do |config|
27
+ config.class_merge.utility("text-h1", like: "text-lg")
28
+ end
29
+ ```
30
+
31
+ - **`CollectionCheckBox` merges a caller `class:` instead of replacing its
32
+ base class.** `class: "checkbox-primary"` now renders `checkbox
33
+ checkbox-primary` (it used to drop `checkbox`). A caller who used `class:` to
34
+ swap the base class entirely (`class: "toggle"`) now gets both classes.
35
+ - **More conflicts resolve now.** A caller's `mt-4` / `text-xs` replaces a
36
+ component's default `mt-1` / `text-sm` instead of stacking beside it.
37
+ - **Daisy leaves lean on daisyui's own merge.** Input, Select, Textarea and the
38
+ other delegated leaves hand `class:` to the daisyui component, so
39
+ `DaisyUI.configure { |c| c.class_merge.enabled = false }` applies to them too.
40
+ The components that render plain HTML (FieldHint, FieldError, Row, Form,
41
+ FormControl, TagField, ChoicesSelect) merge through `DaisyUI::ClassMerge.merge`
42
+ directly, which that switch doesn't affect.
43
+
44
+ ### Removed
45
+
46
+ - **`PhlexForms::ClassMerge`**: use `DaisyUI::ClassMerge.merge`.
47
+ - **The daisyui soft-dependency path.** `require "phlex_forms"` loads daisyui
48
+ unconditionally, the daisy theme is always the default, and `Theme.daisy` no
49
+ longer raises `FeatureUnavailable`. The Plain theme is unchanged and still
50
+ emits zero daisy classes.
51
+
10
52
  ### Fixed
11
53
 
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`).
12
66
  - **`rich_textarea` forwards its block into `<lexxy-editor>`**: Lexxy configures
13
67
  an editor through CHILD elements — `<lexxy-prompt>` for @mentions,
14
68
  `<lexxy-code-language-picker>` for code blocks — but the component rendered
data/README.md CHANGED
@@ -42,7 +42,6 @@ end
42
42
  ```ruby
43
43
  # Gemfile
44
44
  gem "phlex-forms"
45
- gem "daisyui" # optional — the daisy theme; omit for plain semantic HTML
46
45
  gem "phlex-reactive" # optional — the `live` server-truth validation
47
46
  ```
48
47
 
@@ -254,11 +253,10 @@ f.field(:search).wrapped_input(:primary) do
254
253
  end
255
254
  ```
256
255
 
257
- ## Themes — using phlex-forms without daisyui
256
+ ## Themes — using phlex-forms without daisyUI styling
258
257
 
259
- Every component resolves through a theme (a role → component-class map). With
260
- the daisyui gem loaded, the daisy theme is the default. Without it — or on
261
- demand — the **Plain theme** renders bare semantic HTML: the same binding
258
+ Every component resolves through a theme (a role → component-class map). The
259
+ daisy theme is the default. On demand, the **Plain theme** renders bare semantic HTML: the same binding
262
260
  (names, ids, values, required, errors), variants accepted and ignored, no
263
261
  styling classes, and stable hooks (`aria-invalid`, `role="alert"`,
264
262
  `data-field-error`, `data-field-hint`, `data-form-row`) for your own CSS.
@@ -347,7 +345,19 @@ the form-level `validations--form` coordinator), which `lazyLoadControllersFrom`
347
345
  resolves to `phlex_forms/controllers/validations/*_controller` — the path the gem
348
346
  ships them at.
349
347
 
350
- Messages ship for `en` / `fr` / `af`; override via `window.PhlexForms.messages`.
348
+ A validator's own `message:` reaches the browser too, so the client shows what
349
+ the server would: a String as is (`%{value}` is filled in, and `%{count}` for
350
+ rules that have one: length and the numericality bounds), a Symbol resolved on
351
+ the server through your app's i18n, a Proc skipped (it needs a record).
352
+ Length's `too_long:` / `too_short:` / `wrong_length:` win over `message:`.
353
+
354
+ ```ruby
355
+ validates :code, format: { with: /\A[a-z]*\z/, message: "may only contain lowercase letters" }
356
+ f.field :note, validate: { length: { maximum: 30, too_long: "keep it under %{count}" } }
357
+ ```
358
+
359
+ Without a `message:`, the bundled messages apply: they ship for `en` / `fr` /
360
+ `af`; override them page-wide via `window.PhlexForms.messages`.
351
361
 
352
362
  ## Nested attributes, collections & escape valves
353
363
 
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::AcceptanceValidator. For
5
4
  // checkboxes, asserts `.checked`. For other inputs, asserts the
@@ -11,11 +10,11 @@ export default class extends FieldValidatorController {
11
10
 
12
11
  check(value) {
13
12
  if (this.element.type === "checkbox") {
14
- return this.element.checked ? null : t("js.forms.validations.acceptance.accepted")
13
+ return this.element.checked ? null : this.messageOr("js.forms.validations.acceptance.accepted")
15
14
  }
16
15
  try {
17
16
  const list = JSON.parse(this.acceptValue)
18
- return list.map(String).includes(String(value ?? "")) ? null : t("js.forms.validations.acceptance.accepted")
17
+ return list.map(String).includes(String(value ?? "")) ? null : this.messageOr("js.forms.validations.acceptance.accepted")
19
18
  } catch (_e) {
20
19
  return null
21
20
  }
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { interpolate, t } from "phlex_forms/i18n"
2
3
 
3
4
  // Shared base for every field-level validator. Subclasses implement
4
5
  // `check(value)` and return either `null` (valid) or a translation
@@ -8,7 +9,8 @@ import { Controller } from "@hotwired/stimulus"
8
9
  //
9
10
  // Each subclass declares its own `static values` for the rules it
10
11
  // reads from data attributes; the base class only knows about the
11
- // `allowBlank` / `allowNil` short-circuits.
12
+ // `allowBlank` / `allowNil` short-circuits and the `message` value,
13
+ // which carries the validator's own `message:` option (issue #41).
12
14
  export class FieldValidatorController extends Controller {
13
15
  // `error` is opt-in: callers that pre-render a `<p data-validations--<validator>-target="error">`
14
16
  // get a stable slot the controller toggles. Inputs without an
@@ -18,6 +20,7 @@ export class FieldValidatorController extends Controller {
18
20
  static values = {
19
21
  allowBlank: { type: Boolean, default: false },
20
22
  allowNil: { type: Boolean, default: false },
23
+ message: String,
21
24
  }
22
25
 
23
26
  // Element must be the input itself — these controllers are attached
@@ -64,6 +67,14 @@ export class FieldValidatorController extends Controller {
64
67
  return null
65
68
  }
66
69
 
70
+ // The validator's own `message:` when the server rendered one, else the
71
+ // bundled translation for `key`. Either way `%{count}` & co. are filled in;
72
+ // a custom message may also carry `%{value}`, as on the server.
73
+ messageOr(key, vars = {}) {
74
+ if (!(this.hasMessageValue && this.messageValue)) return t(key, vars)
75
+ return interpolate(this.messageValue, { value: this.fieldValue(), ...vars })
76
+ }
77
+
67
78
  shouldSkip(value) {
68
79
  if (this.allowNilValue && (value === null || value === undefined)) return true
69
80
  if (this.allowBlankValue && this.isBlank(value)) return true
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::ConfirmationValidator. Looks
5
4
  // for the `<name>_confirmation` field by name within the same form
@@ -15,7 +14,7 @@ export default class extends FieldValidatorController {
15
14
  if (!form) return null
16
15
  const partner = form.elements[this.matchValue] || this.partnerByAttribute(form)
17
16
  if (!partner) return null
18
- return String(partner.value) === String(value ?? "") ? null : t("js.forms.validations.confirmation.confirmation")
17
+ return String(partner.value) === String(value ?? "") ? null : this.messageOr("js.forms.validations.confirmation.confirmation")
19
18
  }
20
19
 
21
20
  partnerByAttribute(form) {
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::ExclusionValidator.
5
4
  export default class extends FieldValidatorController {
@@ -11,7 +10,7 @@ export default class extends FieldValidatorController {
11
10
  if (!this.hasInValue) return null
12
11
  try {
13
12
  const list = JSON.parse(this.inValue)
14
- return list.map(String).includes(String(value ?? "")) ? t("js.forms.validations.exclusion.exclusion") : null
13
+ return list.map(String).includes(String(value ?? "")) ? this.messageOr("js.forms.validations.exclusion.exclusion") : null
15
14
  } catch (_e) {
16
15
  return null
17
16
  }
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::FormatValidator.
5
4
  export default class extends FieldValidatorController {
@@ -12,7 +11,7 @@ export default class extends FieldValidatorController {
12
11
  if (!this.hasPatternValue) return null
13
12
  try {
14
13
  const regex = new RegExp(this.patternValue, this.flagsValue)
15
- return regex.test(String(value ?? "")) ? null : t("js.forms.validations.format.invalid")
14
+ return regex.test(String(value ?? "")) ? null : this.messageOr("js.forms.validations.format.invalid")
16
15
  } catch (_e) {
17
16
  // If Rails' regex doesn't translate cleanly to JS, fall back to
18
17
  // letting the server be the authority — don't block submit on a
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::InclusionValidator.
5
4
  export default class extends FieldValidatorController {
@@ -11,7 +10,7 @@ export default class extends FieldValidatorController {
11
10
  if (!this.hasInValue) return null
12
11
  try {
13
12
  const list = JSON.parse(this.inValue)
14
- return list.map(String).includes(String(value ?? "")) ? null : t("js.forms.validations.inclusion.inclusion")
13
+ return list.map(String).includes(String(value ?? "")) ? null : this.messageOr("js.forms.validations.inclusion.inclusion")
15
14
  } catch (_e) {
16
15
  return null
17
16
  }
@@ -1,8 +1,9 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
2
+ import { interpolate } from "phlex_forms/i18n"
3
3
 
4
4
  // Mirrors ActiveModel::Validations::LengthValidator. Reads:
5
5
  // - maximum / minimum / is
6
+ // - too-long / too-short / wrong-length messages (win over `message`)
6
7
  // - allow-blank / allow-nil
7
8
  //
8
9
  // Counter is rendered next to the input when `maximum` is set, so
@@ -20,6 +21,9 @@ export default class extends FieldValidatorController {
20
21
  maximum: Number,
21
22
  minimum: Number,
22
23
  is: Number,
24
+ tooLongMessage: String,
25
+ tooShortMessage: String,
26
+ wrongLengthMessage: String,
23
27
  }
24
28
 
25
29
  connect() {
@@ -41,17 +45,24 @@ export default class extends FieldValidatorController {
41
45
  const length = this.codepointLength(value)
42
46
 
43
47
  if (this.hasIsValue && length !== this.isValue) {
44
- return t("js.forms.validations.length.wrong_length", { count: this.isValue })
48
+ return this.lengthMessage(this.wrongLengthMessageValue, "wrong_length", this.isValue)
45
49
  }
46
50
  if (this.hasMaximumValue && length > this.maximumValue) {
47
- return t("js.forms.validations.length.too_long", { count: this.maximumValue })
51
+ return this.lengthMessage(this.tooLongMessageValue, "too_long", this.maximumValue)
48
52
  }
49
53
  if (this.hasMinimumValue && length < this.minimumValue) {
50
- return t("js.forms.validations.length.too_short", { count: this.minimumValue })
54
+ return this.lengthMessage(this.tooShortMessageValue, "too_short", this.minimumValue)
51
55
  }
52
56
  return null
53
57
  }
54
58
 
59
+ // A per-case message (`too_long:` & co.) wins over `message:`, as on the
60
+ // server; the bundled translation is the final fallback.
61
+ lengthMessage(caseMessage, key, count) {
62
+ if (caseMessage) return interpolate(caseMessage, { value: this.fieldValue(), count })
63
+ return this.messageOr(`js.forms.validations.length.${key}`, { count })
64
+ }
65
+
55
66
  updateCounter = () => {
56
67
  const counter = this.counterElement()
57
68
  if (!counter) return
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::NumericalityValidator.
5
4
  export default class extends FieldValidatorController {
@@ -20,40 +19,40 @@ export default class extends FieldValidatorController {
20
19
  if (raw === "") return null
21
20
 
22
21
  const number = Number(raw)
23
- if (Number.isNaN(number)) return t("js.forms.validations.numericality.not_a_number")
22
+ if (Number.isNaN(number)) return this.messageOr("js.forms.validations.numericality.not_a_number")
24
23
 
25
24
  if (this.onlyIntegerValue && !Number.isInteger(number)) {
26
- return t("js.forms.validations.numericality.not_an_integer")
25
+ return this.messageOr("js.forms.validations.numericality.not_an_integer")
27
26
  }
28
27
  if (this.hasGreaterThanValue && !(number > this.greaterThanValue)) {
29
- return t("js.forms.validations.numericality.greater_than", { count: this.greaterThanValue })
28
+ return this.messageOr("js.forms.validations.numericality.greater_than", { count: this.greaterThanValue })
30
29
  }
31
30
  if (this.hasGreaterThanOrEqualToValue && !(number >= this.greaterThanOrEqualToValue)) {
32
- return t("js.forms.validations.numericality.greater_than_or_equal_to", {
31
+ return this.messageOr("js.forms.validations.numericality.greater_than_or_equal_to", {
33
32
  count: this.greaterThanOrEqualToValue,
34
33
  })
35
34
  }
36
35
  if (this.hasLessThanValue && !(number < this.lessThanValue)) {
37
- return t("js.forms.validations.numericality.less_than", { count: this.lessThanValue })
36
+ return this.messageOr("js.forms.validations.numericality.less_than", { count: this.lessThanValue })
38
37
  }
39
38
  if (this.hasLessThanOrEqualToValue && !(number <= this.lessThanOrEqualToValue)) {
40
- return t("js.forms.validations.numericality.less_than_or_equal_to", {
39
+ return this.messageOr("js.forms.validations.numericality.less_than_or_equal_to", {
41
40
  count: this.lessThanOrEqualToValue,
42
41
  })
43
42
  }
44
43
  if (this.hasEqualToValue && number !== this.equalToValue) {
45
- return t("js.forms.validations.numericality.equal_to", { count: this.equalToValue })
44
+ return this.messageOr("js.forms.validations.numericality.equal_to", { count: this.equalToValue })
46
45
  }
47
46
  if (this.hasOtherThanValue && number === this.otherThanValue) {
48
- return t("js.forms.validations.numericality.other_than", { count: this.otherThanValue })
47
+ return this.messageOr("js.forms.validations.numericality.other_than", { count: this.otherThanValue })
49
48
  }
50
49
  // Rails' NumericalityValidator coerces with `.to_i` before
51
50
  // checking parity, so `2.5` is treated as `2` (even). JS `%` on
52
51
  // a float diverges from that — truncate first to keep client and
53
52
  // server in agreement.
54
53
  const truncated = Math.trunc(number)
55
- if (this.oddValue && truncated % 2 === 0) return t("js.forms.validations.numericality.odd")
56
- if (this.evenValue && truncated % 2 !== 0) return t("js.forms.validations.numericality.even")
54
+ if (this.oddValue && truncated % 2 === 0) return this.messageOr("js.forms.validations.numericality.odd")
55
+ if (this.evenValue && truncated % 2 !== 0) return this.messageOr("js.forms.validations.numericality.even")
57
56
  return null
58
57
  }
59
58
  }
@@ -1,5 +1,4 @@
1
1
  import { FieldValidatorController } from "phlex_forms/controllers/validations/base_controller"
2
- import { t } from "phlex_forms/i18n"
3
2
 
4
3
  // Mirrors ActiveModel::Validations::PresenceValidator.
5
4
  export default class extends FieldValidatorController {
@@ -9,6 +8,6 @@ export default class extends FieldValidatorController {
9
8
 
10
9
  check(value) {
11
10
  if (!this.requiredValue) return null
12
- return this.isBlank(value) ? t("js.forms.validations.presence.blank") : null
11
+ return this.isBlank(value) ? this.messageOr("js.forms.validations.presence.blank") : null
13
12
  }
14
13
  }
@@ -26,7 +26,7 @@ function lookup(scope, locale) {
26
26
  return scope.split(".").reduce((node, key) => (node == null ? undefined : node[key]), table)
27
27
  }
28
28
 
29
- function interpolate(string, vars) {
29
+ export function interpolate(string, vars = {}) {
30
30
  return string.replace(/%\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `%{${key}}`))
31
31
  }
32
32
 
@@ -44,7 +44,7 @@ module Forms
44
44
  a = {
45
45
  name: select_name,
46
46
  id: @id,
47
- class: PhlexForms::ClassMerge.merge("choices-select w-full", @options[:class]),
47
+ class: DaisyUI::ClassMerge.merge("choices-select w-full", @options[:class]),
48
48
  data: stimulus_data,
49
49
  **@options.except(:class, :value, :data)
50
50
  }
@@ -18,7 +18,7 @@ module Forms
18
18
  name: @name,
19
19
  id: @id,
20
20
  value: @value,
21
- class: @options[:class] || "checkbox",
21
+ class: DaisyUI::ClassMerge.merge("checkbox", @options[:class]),
22
22
  checked: @checked || nil
23
23
  )
24
24
  end
@@ -20,7 +20,7 @@ module Forms
20
20
  private
21
21
 
22
22
  def classes
23
- PhlexForms::ClassMerge.merge("text-error text-sm mt-1", @options[:class])
23
+ DaisyUI::ClassMerge.merge("text-error text-sm mt-1", @options[:class])
24
24
  end
25
25
  end
26
26
  end
@@ -18,7 +18,7 @@ module Forms
18
18
  private
19
19
 
20
20
  def classes
21
- PhlexForms::ClassMerge.merge("text-base-content/60 text-sm mt-1", @options[:class])
21
+ DaisyUI::ClassMerge.merge("text-base-content/60 text-sm mt-1", @options[:class])
22
22
  end
23
23
  end
24
24
  end
data/lib/forms/form.rb CHANGED
@@ -224,10 +224,9 @@ module Forms
224
224
  end
225
225
 
226
226
  def form_classes
227
- classes = []
228
- classes << "space-y-4" if @base_modifiers.include?(:spaced)
229
- classes << "space-y-6" if @base_modifiers.include?(:spacious)
230
- PhlexForms::ClassMerge.merge(classes.join(" "), @options[:class]).presence
227
+ spaced = "space-y-4" if @base_modifiers.include?(:spaced)
228
+ spacious = "space-y-6" if @base_modifiers.include?(:spacious)
229
+ DaisyUI::ClassMerge.merge(spaced, spacious, @options[:class]).presence
231
230
  end
232
231
 
233
232
  def authenticity_token_field
@@ -40,9 +40,8 @@ module Forms
40
40
  private
41
41
 
42
42
  def control_classes
43
- base = %w[form-control w-full]
44
- base << "flex flex-row items-center gap-4" if @modifiers.include?(:horizontal)
45
- PhlexForms::ClassMerge.merge(base.join(" "), @options[:class])
43
+ horizontal = "flex flex-row items-center gap-4" if @modifiers.include?(:horizontal)
44
+ DaisyUI::ClassMerge.merge("form-control w-full", horizontal, @options[:class])
46
45
  end
47
46
  end
48
47
  end
@@ -70,7 +70,7 @@ module Forms
70
70
  end
71
71
 
72
72
  def editor_classes
73
- [@options[:class], "lexxy-content"].compact.join(" ")
73
+ DaisyUI::ClassMerge.merge(@options[:class], "lexxy-content")
74
74
  end
75
75
  end
76
76
  end
data/lib/forms/row.rb CHANGED
@@ -27,8 +27,7 @@ module Forms
27
27
  private
28
28
 
29
29
  def row_classes
30
- base = ["grid grid-cols-1 gap-4", COLUMN_CLASSES[@columns]].compact.join(" ")
31
- PhlexForms::ClassMerge.merge(base, @options[:class])
30
+ DaisyUI::ClassMerge.merge("grid grid-cols-1 gap-4", COLUMN_CLASSES[@columns], @options[:class])
32
31
  end
33
32
  end
34
33
  end
@@ -135,6 +135,6 @@ module Forms
135
135
  def option_classes = nil
136
136
  def chip_classes = "badge badge-primary gap-1"
137
137
  def remove_classes = "cursor-pointer"
138
- def input_classes = PhlexForms::ClassMerge.merge("input w-full", @attributes[:class])
138
+ def input_classes = DaisyUI::ClassMerge.merge("input w-full", @attributes[:class])
139
139
  end
140
140
  end
@@ -46,6 +46,11 @@ module Forms
46
46
  only_integer odd even
47
47
  ].freeze
48
48
 
49
+ # LengthValidator's per-case messages; each wins over `message:` on the
50
+ # server, so the length controller checks them first. Each maps to the
51
+ # bound it reports, so a Symbol resolves with that count.
52
+ LENGTH_MESSAGE_KEYS = { too_long: :maximum, too_short: :minimum, wrong_length: :is }.freeze
53
+
49
54
  # Convenience: returns a null introspector for nil models so
50
55
  # callers don't need to guard.
51
56
  def self.for(model_or_class)
@@ -83,7 +88,8 @@ module Forms
83
88
  next unless suffix
84
89
 
85
90
  controllers << "#{CONTROLLER_PREFIX}--#{suffix}"
86
- value_attributes(suffix, validator, attribute).each do |key, value|
91
+ value_attributes(suffix, validator, attribute).merge(message_attrs(suffix, validator, attribute))
92
+ .each do |key, value|
87
93
  attrs[data_key(suffix, key)] = value
88
94
  end
89
95
  end
@@ -131,6 +137,23 @@ module Forms
131
137
  end
132
138
  end
133
139
 
140
+ # `message` (and length's `too_long_message` & co.) values, for the
141
+ # validator's own `message:` options. Omitted when there is none, or when
142
+ # it can't be resolved without a record, so the generic message stays.
143
+ def message_attrs(suffix, validator, attribute)
144
+ counts = suffix == "length" ? length_message_counts(validator) : {}
145
+ options = counts.keys + [:message]
146
+ options.each_with_object({}) do |option, attrs|
147
+ text = Message.resolve(validator.options[option], model_class:, attribute:, count: counts[option])
148
+ attrs[option == :message ? :message : :"#{option}_message"] = text if text
149
+ end
150
+ end
151
+
152
+ def length_message_counts(validator)
153
+ bounds = length_attrs(validator)
154
+ LENGTH_MESSAGE_KEYS.to_h { |option, bound| [option, bounds[bound]&.to_i] }
155
+ end
156
+
134
157
  def presence_attrs
135
158
  { required: "true" }
136
159
  end
@@ -13,7 +13,7 @@ module Forms
13
13
  # ManualRules.new(
14
14
  # length: { maximum: 60, minimum: 3 },
15
15
  # presence: true,
16
- # format: { with: /\A[a-z]+\z/i },
16
+ # format: { with: /\A[a-z]+\z/i, message: "letters only" },
17
17
  # numericality: { greater_than_or_equal_to: 0 },
18
18
  # inclusion: { in: %w[a b c] },
19
19
  # exclusion: { in: %w[admin] },
@@ -49,7 +49,7 @@ module Forms
49
49
  captured_rules.each do |key, opts|
50
50
  case key
51
51
  when :presence
52
- validates :value, presence: true if opts
52
+ validates :value, presence: opts.is_a?(Hash) ? opts : true if opts
53
53
  when :length
54
54
  validates :value, length: opts
55
55
  when :format
@@ -61,7 +61,7 @@ module Forms
61
61
  when :exclusion
62
62
  validates :value, exclusion: opts
63
63
  when :confirmation
64
- validates :value, confirmation: true if opts
64
+ validates :value, confirmation: opts.is_a?(Hash) ? opts : true if opts
65
65
  when :acceptance
66
66
  validates :value, acceptance: opts.is_a?(Hash) ? opts : { accept: %w[1 true] }
67
67
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Validations
5
+ # Turns a validator's `message:` option into the text the browser should
6
+ # show, so the client mirror says what the server would (issue #41).
7
+ #
8
+ # - a String is emitted as is; `%{count}`-style placeholders stay in place
9
+ # for the Stimulus controller to fill, as it does for its own messages.
10
+ # A blank String resolves to nil: the browser reads "" as valid.
11
+ # - a Symbol is resolved on the server through ActiveModel's lookup chain
12
+ # (`<scope>.errors.models.<model>.attributes.<attr>.<key>` ... then
13
+ # `errors.messages.<key>`), in the current locale. With a known `count:`
14
+ # (length's per-case messages) it is passed to I18n, so a pluralised
15
+ # translation picks the right form; otherwise `%{count}` stays a
16
+ # placeholder and the plural `:other` form is used.
17
+ # - anything else (a Proc needs a record) resolves to nil, so the
18
+ # controller keeps its generic message.
19
+ #
20
+ # Mirrors `ActiveModel::Error.generate_message` against the class alone: the
21
+ # introspector never instantiates the model.
22
+ class Message
23
+ # Interpolations the browser fills in, kept as literal placeholders.
24
+ PLACEHOLDERS = { count: "%{count}", value: "%{value}" }.freeze
25
+
26
+ def self.resolve(message, model_class:, attribute:, count: nil)
27
+ case message
28
+ when String then message unless message.empty?
29
+ when Symbol then new(model_class, attribute).translate(message, count:)
30
+ end
31
+ end
32
+
33
+ def initialize(model_class, attribute)
34
+ @model_class = model_class
35
+ @attribute = attribute.to_s
36
+ end
37
+
38
+ def translate(key, count: nil)
39
+ return unless defined?(I18n)
40
+
41
+ first, *rest = lookup_keys(key)
42
+ vars = interpolations
43
+ vars[:count] = count unless count.nil?
44
+ I18n.translate(first, **vars, default: rest, raise: true)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :model_class, :attribute
52
+
53
+ def lookup_keys(key)
54
+ model_keys(key) + [:"errors.attributes.#{attribute}.#{key}", :"errors.messages.#{key}"]
55
+ end
56
+
57
+ def model_keys(key)
58
+ return [] unless model_class.respond_to?(:i18n_scope) && model_class.respond_to?(:lookup_ancestors)
59
+
60
+ scope = model_class.i18n_scope
61
+ keys = model_class.lookup_ancestors.flat_map do |klass|
62
+ i18n_key = klass.model_name.i18n_key
63
+ [:"#{scope}.errors.models.#{i18n_key}.attributes.#{attribute}.#{key}",
64
+ :"#{scope}.errors.models.#{i18n_key}.#{key}"]
65
+ end
66
+ keys << :"#{scope}.errors.messages.#{key}"
67
+ end
68
+
69
+ def interpolations
70
+ names = {}
71
+ names[:model] = model_class.model_name.human if model_class.respond_to?(:model_name)
72
+ names[:attribute] = model_class.human_attribute_name(attribute) if model_class.respond_to?(:human_attribute_name)
73
+ PLACEHOLDERS.merge(names)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -51,11 +51,11 @@ module PhlexForms
51
51
  end
52
52
 
53
53
  # The default theme: :daisy, :plain, or a PhlexForms::Theme instance.
54
- # Defaults to daisy when the daisyui gem is loaded, plain otherwise.
54
+ # Defaults to daisy.
55
55
  attr_writer :theme
56
56
 
57
57
  def theme
58
- @theme ||= defined?(DaisyUI) ? Theme.daisy : Theme.plain
58
+ @theme ||= Theme.daisy
59
59
  end
60
60
 
61
61
  def infer_from_model
@@ -42,13 +42,12 @@ module PhlexForms
42
42
  attrs.compact
43
43
  end
44
44
 
45
- # Merged via ClassMerge so a caller width (`w-36`) REPLACES the default
46
- # instead of stacking with it (`w-full w-36` leaves the winner to stylesheet
47
- # source order — that's how admin filter selects went full-width, zazu#2934).
45
+ # Handed to the daisyui component as parts: its class merge lets a caller
46
+ # width (`w-36`) REPLACE the default instead of stacking with it (zazu#2934).
48
47
  def width_class
49
48
  return @attributes[:class] unless @full_width
50
49
 
51
- ClassMerge.merge("w-full", @attributes[:class])
50
+ ["w-full", *@attributes[:class]]
52
51
  end
53
52
 
54
53
  # Unstyled variant of binding_attributes for the Plain theme: caller classes
@@ -9,8 +9,7 @@ module PhlexForms
9
9
  # visual variant nor an error state.
10
10
  #
11
11
  # Built-ins:
12
- # Theme.daisy — the DaisyUI-delegating Forms::* leaves (default when the
13
- # daisyui gem is loaded)
12
+ # Theme.daisy — the DaisyUI-delegating Forms::* leaves (the default)
14
13
  # Theme.plain — bare semantic HTML (Forms::Plain::*): variants accepted and
15
14
  # ignored, zero styling classes, aria/data hooks only
16
15
  #
@@ -47,12 +46,6 @@ module PhlexForms
47
46
  end
48
47
 
49
48
  def daisy
50
- unless defined?(DaisyUI)
51
- raise PhlexForms::FeatureUnavailable,
52
- "the daisy theme requires the daisyui gem. Add `gem \"daisyui\"` to your " \
53
- "Gemfile, or use the plain theme."
54
- end
55
-
56
49
  @daisy ||= new({
57
50
  input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
58
51
  textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/phlex_forms.rb CHANGED
@@ -7,14 +7,7 @@ require "active_support/core_ext/string" # underscore/pluralize/humanize (scope/
7
7
  require "phlex"
8
8
  require "glyphs"
9
9
  require "zeitwerk"
10
-
11
- begin
12
- require "daisy_ui"
13
- rescue LoadError
14
- # daisyui is a soft dependency: without it the Plain theme is the default.
15
- # The daisy leaf components only reference DaisyUI inside view_template, so
16
- # they load fine — they just must not be rendered (Theme.daisy raises).
17
- end
10
+ require "daisy_ui"
18
11
 
19
12
  begin
20
13
  require "phlex/reactive"
@@ -75,7 +68,7 @@ loader.inflector.inflect(
75
68
  )
76
69
 
77
70
  # Two sibling (non-nested) autoload roots:
78
- # lib/phlex_forms -> PhlexForms:: (gem internals: config, class_merge, ...)
71
+ # lib/phlex_forms -> PhlexForms:: (gem internals: config, inference, theme, ...)
79
72
  # lib/forms -> Forms:: (the component kit)
80
73
  # Keeping them as separate top-level dirs avoids the ambiguity of nesting one
81
74
  # namespace root inside another.
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.3.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: daisyui
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: glyphs
34
48
  requirement: !ruby/object:Gem::Requirement
@@ -164,11 +178,11 @@ files:
164
178
  - lib/forms/toggle.rb
165
179
  - lib/forms/validations/introspector.rb
166
180
  - lib/forms/validations/manual_rules.rb
181
+ - lib/forms/validations/message.rb
167
182
  - lib/forms/wrapped_input.rb
168
183
  - lib/phlex-forms.rb
169
184
  - lib/phlex_forms.rb
170
185
  - lib/phlex_forms/builder.rb
171
- - lib/phlex_forms/class_merge.rb
172
186
  - lib/phlex_forms/configuration.rb
173
187
  - lib/phlex_forms/delegated_field.rb
174
188
  - lib/phlex_forms/engine.rb
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module PhlexForms
4
- # Class-string merging for phlex-forms components.
5
- #
6
- # DaisyUI component-class families are mutually exclusive: an element can't be
7
- # both `input-sm` and `input-lg`, or both `input-primary` and `input-error`.
8
- # When a component's default classes and a caller's `class:` override both name
9
- # the same family, the LAST occurrence wins — matching Tailwind/daisyui
10
- # intuition (the later class overrides the earlier).
11
- #
12
- # Width utilities (`w-*`) are the one core-Tailwind family this gem injects a
13
- # default for (DelegatedField's `w-full`), so they conflict the same way: a
14
- # caller's `w-36` must REPLACE the default, or both land on the element and
15
- # stylesheet source order — not author intent — decides which applies.
16
- #
17
- # We deliberately do NOT depend on tailwind_merge: daisyui modifier families
18
- # and the width family are the only conflicts that occur on form fields, so
19
- # tailwind_merge would add a runtime dependency for conflicts this handles.
20
- # Other non-conflicting utilities (`py-0`, `min-w-32`, ...) pass through in
21
- # order.
22
- module ClassMerge
23
- SIZE = /-(xs|sm|md|lg|xl)\z/
24
- COLOR = /-(primary|secondary|accent|neutral|info|success|warning|error)\z/
25
- # Anchored so min-w-*/max-w-* (different CSS properties) stay out of the family.
26
- WIDTH = /\Aw-/
27
-
28
- module_function
29
-
30
- # Merge any number of class strings/nil into one deduped string. Preserves
31
- # order; within a daisyui size/color family only the last token survives.
32
- def merge(*parts)
33
- tokens = parts.flat_map { |p| p.to_s.split }.reject(&:empty?)
34
- dedupe_daisyui_families(tokens).join(" ")
35
- end
36
-
37
- # For each (component-prefix, family) pair, keep only the last token.
38
- def dedupe_daisyui_families(tokens)
39
- last_index = {}
40
- tokens.each_with_index do |tok, idx|
41
- key = family_key(tok)
42
- last_index[key] = idx if key
43
- end
44
- tokens.each_with_index.filter_map do |tok, idx|
45
- key = family_key(tok)
46
- next tok if key.nil? # not a daisyui family token — keep
47
- next tok if last_index[key] == idx # last in its family — keep
48
-
49
- nil # earlier duplicate in the family — drop
50
- end
51
- end
52
-
53
- # A stable bucket key like "width" / "input:size" / "select:color", or nil if
54
- # the token is not a recognized conflicting family. WIDTH is checked first so
55
- # a `w-*` token can never be mis-bucketed as a daisyui modifier.
56
- def family_key(token)
57
- return "width" if WIDTH.match?(token)
58
-
59
- if (m = token.match(SIZE))
60
- "#{token[0...m.begin(0)]}:size"
61
- elsif (m = token.match(COLOR))
62
- "#{token[0...m.begin(0)]}:color"
63
- end
64
- end
65
- end
66
- end