react-luna-form 0.0.87 → 0.0.89

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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/client/cjs/index.js +1 -1
  3. package/dist/client/esm/index.js +1 -1
  4. package/dist/config/cjs/index.js +1 -1
  5. package/dist/config/esm/index.js +1 -1
  6. package/dist/schema/cjs/index.js +1 -1
  7. package/dist/schema/esm/index.js +1 -1
  8. package/dist/server/cjs/index.js +1 -1
  9. package/dist/server/esm/index.js +1 -1
  10. package/dist/types/luna-core/handle/source-event.d.ts +2 -1
  11. package/dist/types/luna-core/handle/value-event.d.ts +2 -1
  12. package/dist/types/luna-core/helper/input.d.ts +16 -7
  13. package/dist/types/luna-core/type.d.ts +15 -4
  14. package/dist/types/luna-core/util/build.d.ts +20 -1
  15. package/dist/types/luna-core/util/constant.d.ts +4 -0
  16. package/dist/types/luna-core/util/date.d.ts +147 -6
  17. package/dist/types/luna-core/util/format.d.ts +11 -0
  18. package/dist/types/luna-core/util/is-input.d.ts +3 -2
  19. package/dist/types/luna-core/util/list.d.ts +3 -1
  20. package/dist/types/luna-core/util/pattern.d.ts +28 -0
  21. package/dist/types/luna-core/util/prepare.d.ts +3 -2
  22. package/dist/types/luna-core/util/schema.d.ts +4 -3
  23. package/dist/types/luna-core/util/string.d.ts +7 -0
  24. package/dist/types/luna-react/src/client/component/field/field-list-preview-item.d.ts +3 -3
  25. package/dist/types/luna-react/src/client/component/field/field-preview-item.d.ts +4 -2
  26. package/dist/types/luna-react/src/client/component/field/field-preview-value.d.ts +4 -1
  27. package/dist/types/luna-react/src/client/component/field/field-preview.d.ts +2 -2
  28. package/dist/types/luna-react/src/client/component/formatted-description.d.ts +2 -2
  29. package/dist/types/luna-react/src/client/component/guard/list-guard.d.ts +1 -1
  30. package/dist/types/luna-react/src/client/component/input-strategies.d.ts +9 -1
  31. package/dist/types/luna-react/src/client/component/slot/slot.d.ts +1 -2
  32. package/dist/types/luna-react/src/client/hook/use-input-core.d.ts +2 -2
  33. package/dist/types/luna-react/src/component/column.d.ts +2 -2
  34. package/dist/types/luna-react/src/component/description.d.ts +2 -2
  35. package/dist/types/luna-react/src/component/field/field-list.d.ts +2 -3
  36. package/dist/types/luna-react/src/component/field/field.d.ts +3 -3
  37. package/dist/types/luna-react/src/component/field-description.d.ts +2 -2
  38. package/dist/types/luna-react/src/component/form.d.ts +1 -0
  39. package/dist/types/luna-react/src/component/input/input-base.d.ts +3 -3
  40. package/dist/types/luna-react/src/component/input-group.d.ts +2 -2
  41. package/dist/types/luna-react/src/component/input-label.d.ts +2 -2
  42. package/dist/types/luna-react/src/component/slot/list-slot.d.ts +1 -1
  43. package/dist/types/luna-react/src/component/slot/slot-base.d.ts +2 -3
  44. package/dist/types/luna-react/src/component/slot/slot-create.d.ts +2 -3
  45. package/dist/types/luna-react/src/component/slot/slot-list.d.ts +2 -3
  46. package/dist/types/luna-react/src/component/slot/slot.d.ts +1 -2
  47. package/dist/types/luna-react/src/config/index.d.ts +1 -0
  48. package/dist/types/luna-react/src/server/component/input.d.ts +2 -2
  49. package/dist/types/luna-react/src/server/component/slot.d.ts +1 -2
  50. package/dist/types/luna-react/src/type.d.ts +2 -1
  51. package/docs/events/change.md +4 -1
  52. package/docs/fields/custom-inputs.md +153 -4
  53. package/docs/fields/data-source.md +5 -3
  54. package/docs/fields/input.md +195 -4
  55. package/docs/fields/list.md +2 -1
  56. package/docs/fields/specialized-selectors.md +51 -12
  57. package/docs/forms/submit.md +6 -8
  58. package/docs/index.md +2 -1
  59. package/docs/interpolation/format-filters.md +28 -29
  60. package/docs/interpolation/overview.md +44 -14
  61. package/docs/setup.md +22 -17
  62. package/docs/structure/definition.md +45 -6
  63. package/docs/validation/overview.md +14 -11
  64. package/package.json +3 -3
  65. package/dist/types/luna-react/src/client/hook/use-format.d.ts +0 -5
@@ -4,12 +4,11 @@ export declare const Slot: (props: Readonly<{
4
4
  context?: Record<string, unknown>;
5
5
  disabled?: boolean;
6
6
  fields?: import('../../../../luna-core/index').Fields;
7
- lang?: string;
8
7
  onValueChange?: (input: {
9
8
  name: string;
10
9
  value: unknown;
11
10
  }) => void;
12
11
  style?: import('../../../../luna-core/index').Style;
13
- translations?: Record<string, string>;
12
+ localization?: import('../../../../luna-core/index').Localization;
14
13
  value?: import('../../../../luna-core/index').Nullable<Record<string, unknown>>;
15
14
  }>) => import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { type Environment, type Protocol } from '../../../luna-core/index';
2
2
  import type { AlertProps, Config, InputConfig } from '../type';
3
+ export { readDateProps, type DateProps } from '../../../luna-core/index';
3
4
  export declare function defineConfig<T extends React.ElementType>(options: Readonly<{
4
5
  alert?: React.ComponentType<AlertProps>;
5
6
  env?: Environment;
@@ -1,4 +1,4 @@
1
- import { type AriaAttributes, type CommonProps, type DataAttributes, type Field } from '../../../../luna-core/index';
1
+ import { type AriaAttributes, type CommonProps, type DataAttributes, type Field, type Localization } from '../../../../luna-core/index';
2
2
  import type { Config } from '../../type';
3
3
  export declare function Input(props: Readonly<{
4
4
  ariaAttributes?: AriaAttributes;
@@ -8,6 +8,6 @@ export declare function Input(props: Readonly<{
8
8
  dataAttributes?: DataAttributes;
9
9
  field: Field;
10
10
  horizontal?: boolean;
11
- translations?: Record<string, string>;
11
+ localization?: Localization;
12
12
  value?: Record<string, unknown>;
13
13
  }>): import("react").ReactNode;
@@ -4,12 +4,11 @@ export declare const Slot: (props: Readonly<{
4
4
  context?: Record<string, unknown>;
5
5
  disabled?: boolean;
6
6
  fields?: import('../../../../luna-core/index').Fields;
7
- lang?: string;
8
7
  onValueChange?: (input: {
9
8
  name: string;
10
9
  value: unknown;
11
10
  }) => void;
12
11
  style?: import('../../../../luna-core/index').Style;
13
- translations?: Record<string, string>;
12
+ localization?: import('../../../../luna-core/index').Localization;
14
13
  value?: import('../../../../luna-core/index').Nullable<Record<string, unknown>>;
15
14
  }>) => import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import type { AriaAttributes, BaseConfig, CommonProps, DataAttributes, Field, Fields, FormStateError } from '../../luna-core/index';
1
+ import type { AriaAttributes, BaseConfig, CommonProps, DataAttributes, Field, Fields, FormStateError, Localization } from '../../luna-core/index';
2
2
  export type Slot = (props: {
3
3
  disabled?: boolean;
4
4
  fields?: Fields;
@@ -12,6 +12,7 @@ export type Children = (props: {
12
12
  dataAttributes?: DataAttributes;
13
13
  field: Field;
14
14
  horizontal?: boolean;
15
+ localization?: Localization;
15
16
  readOnly?: boolean;
16
17
  }) => React.ReactNode;
17
18
  export type InputConfig<T extends React.ElementType> = {
@@ -189,7 +189,7 @@ The `state` action modifies the interactive state or visibility of other fields
189
189
  - `action` (string): Must be set to `"state"`.
190
190
  - `target` (string | array of strings): The field name, or an array of field names, that the state modifications should be applied to.
191
191
  - `state` (object): An object containing the new state to apply. Properties are independent of field names — the form may safely contain a field whose `name` is `"description"`, `"disabled"` or `"hidden"`; `target` identifies the field, the keys inside `state` configure it.
192
- - `disabled` (boolean, optional): Whether the field should be disabled.
192
+ - `disabled` (boolean, optional): Whether the field should be disabled. While the condition holds it takes the place of the field's own `disabled`, in either direction: `false` enables a field declared `"disabled": true`. Once the condition stops holding, the field's own `disabled` applies again.
193
193
  - `hidden` (boolean, optional): Whether the field should be hidden from view. **Note:** When a field becomes hidden (`hidden: true`), its current value is automatically cleared from the form state to ensure no non-applicable data is submitted. See [What clearing a hidden target means](#what-clearing-a-hidden-target-means).
194
194
  - `description` (string | object, optional): A new description to render for the target field. Supports interpolation, format filters, and **Markdown** (specifically links). e.g. `"Check our [terms](https://example.com) for details."`.
195
195
  - `when` (string | array of strings | [Condition object](#the-condition-object), optional): A condition that specifies when this state should be applied.
@@ -409,6 +409,9 @@ Two details:
409
409
  `source`, the initial run is held until the data has loaded, so an action
410
410
  keyed on the selected entity sees the entity rather than a bare value. Text
411
411
  and date fields run as soon as the value is valid.
412
+ - **A date range carries its pair.** A [range](../fields/input.md#a-range-of-days)
413
+ runs its events with `[from, to]`, at mount and on a click alike: `{value}` is
414
+ that pair.
412
415
  - **A `chips` field carries its array, not an entity.** No single option answers
413
416
  to a value that can hold several, so a `chips` change carries the array
414
417
  itself, at mount and on a click alike: `{value}` is that array, and there is
@@ -44,16 +44,19 @@ Props are applied in this order:
44
44
 
45
45
  ```tsx
46
46
  <Component
47
- {...commonProps} // id, name, placeholder, disabled, required
48
- {...ariaAttributes} // aria-invalid, aria-errormessage
49
- {...dataAttributes} // data-invalid, data-readonly
50
- {...extraProps} // whatever the field family adds
47
+ {...dataAttributes} // advanced.data, as data-* attributes
48
+ {...commonProps} // id, name, placeholder, disabled, required, and what the field family adds
49
+ {...ariaAttributes} // advanced.aria, and aria-invalid, aria-errormessage
51
50
  {...inputProps} // the prepared value or defaultValue
52
51
  onBlur={onBlur}
53
52
  onChange={onChange}
54
53
  />
55
54
  ```
56
55
 
56
+ `advanced.data` comes first, so an attribute the form writes for its own rules,
57
+ such as the `data-format` of a date, keeps the form's value whatever
58
+ `advanced.data` declares under the same name.
59
+
57
60
  `onChange` and `onBlur` are applied **last**, after every spread. A component
58
61
  that forwards its props unchanged gets them for free. A component that declares
59
62
  its own handlers must call the ones it received, or the form never learns the
@@ -63,6 +66,8 @@ field declares.
63
66
  A `readonly` field arrives `disabled` and without a `name`. Its value is still
64
67
  submitted, but by the form, in hidden inputs of its own: nothing your component
65
68
  renders for it is sent, so it is sent once, whatever your component renders.
69
+ While a field is disabled or read-only, a change your component sends is not
70
+ applied, so a part of it left enabled cannot change the value.
66
71
 
67
72
  ## Forwarding `id` is what gives the field its name
68
73
 
@@ -122,6 +127,147 @@ The same registered component is used by both render paths. If your custom
122
127
  input needs browser APIs, state or effects, mark it `'use client'` as you would
123
128
  any other component — the library does not do it for you.
124
129
 
130
+ ## Date components
131
+
132
+ A component registered for `input/date` gets the field's day and its rules as
133
+ plain props, and only renders. The form reads what it emits, checks every rule
134
+ itself, on submit and on the server, and submits the value. Any calendar can
135
+ render the field, and a native `<input type="date">` needs nothing at all.
136
+
137
+ ### What it receives
138
+
139
+ | Prop | Example | What it is |
140
+ | --------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
141
+ | `value` | `"2026-10-02"`, `["2026-10-01", "2026-10-05"]` | The day as `yyyy-MM-dd`, from the client `Form`. A [range](input.md#a-range-of-days) is `[from, to]`, `''` for an end not picked yet |
142
+ | `defaultValue` | the same | The same, from the server `Form` |
143
+ | `data-format` | `"dd/MM/yyyy"` | How to show the day, in date-fns tokens |
144
+ | `data-mode` | `"range"` | Only on a range |
145
+ | `min`, `max` | `"2026-10-01"` | [The first and the last day](input.md#the-first-and-the-last-day), both included |
146
+ | `data-reserved` | `"2026-12-24,2026-12-25"` | [Days nobody can pick](input.md#days-nobody-can-pick) |
147
+ | `lang` | `"es-CO"` | The form's language, to name months and days in. Absent when the form has none |
148
+ | `name` | `"stay"` | Absent, in the client `Form`, on a range and on a read-only field: the form submits those itself |
149
+
150
+ Read the props with `readDateProps` instead of by attribute name, so the
151
+ component keeps working whatever the form puts on them next:
152
+
153
+ ```ts
154
+ import { readDateProps } from 'react-luna-form/config'
155
+
156
+ const { format, lang, mode, min, max, reserved } = readDateProps(props)
157
+ ```
158
+
159
+ `lang` is the form's language as a canonical tag, and `'en'` when it has
160
+ none. `mode` is `'single'` or `'range'`. `min` and `max` come back only when they are
161
+ days, so a component can test them for `undefined` and nothing else. `reserved`
162
+ is always a list, empty when nothing is reserved, sorted and with each day once.
163
+ It is the same array for as long as the days are, so a component can memoize on
164
+ it.
165
+
166
+ ### What it emits
167
+
168
+ - One day: `onChange({ target: { value } })` with `yyyy-MM-dd` for a day the
169
+ user picked, or with the text as the user typed it. The form reads text in
170
+ the field's format, and keeps anything else as typed so that validation can
171
+ say what is wrong with it.
172
+ - A range: `onChange({ target: { value: [from, to] } })`, with `''` for an end
173
+ not picked yet, or `''` once nothing is picked.
174
+ - `onBlur` when the user leaves the component: that is when the form validates
175
+ the field.
176
+
177
+ A calendar should offer no day outside `min` and `max` nor any in `reserved`,
178
+ and a read-only or disabled field nothing to pick from. The form holds a wrong
179
+ day back anyway, and ignores the change a locked field's component sends, but a
180
+ person should not be able to pick it.
181
+
182
+ ### A calendar, in a few lines
183
+
184
+ One day picked in react-day-picker, which is what shadcn's `Calendar` renders:
185
+
186
+ ```tsx
187
+ 'use client'
188
+
189
+ import { format as formatDay, isValid, parse } from 'date-fns'
190
+ import { DayPicker } from 'react-day-picker'
191
+ import { readDateProps } from 'react-luna-form/config'
192
+
193
+ // A day, read in local time: see the first mistake below.
194
+ function toDay(iso?: string) {
195
+ const day = iso ? parse(iso, 'yyyy-MM-dd', new Date()) : undefined
196
+ return day && isValid(day) ? day : undefined
197
+ }
198
+
199
+ function toIso(day?: Date) {
200
+ return day ? formatDay(day, 'yyyy-MM-dd') : ''
201
+ }
202
+
203
+ export function CalendarDate(
204
+ props: Readonly<{
205
+ 'data-format'?: string
206
+ 'data-reserved'?: string
207
+ defaultValue?: string
208
+ max?: string
209
+ min?: string
210
+ onChange?: (event: { target: { value: string } }) => void
211
+ value?: string
212
+ }>
213
+ ) {
214
+ const { min, max, reserved } = readDateProps(props)
215
+ const booked = new Set(reserved)
216
+ const first = toDay(min)
217
+ const last = toDay(max)
218
+
219
+ return (
220
+ <DayPicker
221
+ mode="single"
222
+ selected={toDay(props.value ?? props.defaultValue)}
223
+ onSelect={(day) => props.onChange?.({ target: { value: toIso(day) } })}
224
+ disabled={[
225
+ ...(first ? [{ before: first }] : []),
226
+ ...(last ? [{ after: last }] : []),
227
+ (day: Date) => booked.has(toIso(day)),
228
+ ]}
229
+ startMonth={first}
230
+ endMonth={last}
231
+ />
232
+ )
233
+ }
234
+ ```
235
+
236
+ A range is the same with `mode="range"`, `selected={{ from, to }}` and
237
+ `onSelect={(range) => ...}` emitting `[toIso(range.from), toIso(range.to)]`.
238
+
239
+ ### Mapping to common libraries
240
+
241
+ | luna-form gives | react-day-picker (shadcn `Calendar`) | MUI X | `<input type="date">` |
242
+ | ------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------- |
243
+ | `value`, one day | `selected={toDay(value)}` | `value={dayjs(value)}` | `value` as it is |
244
+ | `value`, a range | `mode="range"`, `selected={{ from, to }}` | `DateRangePicker` (Pro) with `[dayjs(from), dayjs(to)]` | none: two date fields and a `custom` rule |
245
+ | `onChange` | from `onSelect`, with `format(day, 'yyyy-MM-dd')` | from `onChange`, with `day.format('YYYY-MM-DD')` | native |
246
+ | `min`, `max` | `disabled={[{ before }, { after }]}`, `startMonth`, `endMonth` | `minDate`, `maxDate` | native |
247
+ | `data-reserved` | `disabled` with a function that looks the day up in a set | `shouldDisableDate={(day) => booked.has(day.format('YYYY-MM-DD'))}` | not shown; the form checks it all the same |
248
+ | `data-mode="range"` | `excludeDisabled`, `numberOfMonths={2}` | `DateRangePicker` | not supported |
249
+ | `data-format` | the text box: `format(day, dataFormat)` | `format`, in dayjs tokens | ignored: the browser shows the user's locale |
250
+ | `lang` | `locale` from `react-day-picker/locale`, also for `format` | `adapterLocale` on `LocalizationProvider` | ignored, the same way |
251
+
252
+ ### Mistakes that are easy to make
253
+
254
+ 1. **`new Date("2026-10-02")` is midnight UTC**, which is the day before
255
+ anywhere west of Greenwich: a calendar fed that marks the wrong day. Read a
256
+ `yyyy-MM-dd` day in local time, with `parse(iso, 'yyyy-MM-dd', new Date())`
257
+ or `new Date(year, month - 1, day)`.
258
+ 2. **In range mode, react-day-picker's `min` and `max` are numbers:** how many
259
+ days the range must or may span, not its first and last day. Spread the
260
+ form's props onto `<Calendar>` and a date lands where a count is expected:
261
+ hand it only what the component works out.
262
+ 3. **`startMonth` and `endMonth` only limit navigation.** The days outside the
263
+ bounds are disabled with `disabled`.
264
+ 4. **MUI takes dayjs tokens, `advanced.format` is in date-fns tokens:**
265
+ `dd/MM/yyyy` is `DD/MM/YYYY` there. Translate the format rather than pass it
266
+ through.
267
+ 5. **Disabling a day in the calendar validates nothing.** A typed date, a value
268
+ from the host and a payload posted to the server never pass through the
269
+ calendar; the form checks them, so the component does not have to.
270
+
125
271
  ## Checklist
126
272
 
127
273
  Before shipping a custom input:
@@ -130,3 +276,6 @@ Before shipping a custom input:
130
276
  - The `id` it receives reaches the focusable element
131
277
  - It forwards or calls the `onChange` and `onBlur` it receives
132
278
  - If it is a select variant, it does not rely on `''` to clear
279
+ - If it renders `input/date`, it reads and emits `yyyy-MM-dd`, or `[from, to]`
280
+ for a range, offers no day outside `min` and `max` nor any in `reserved`, and
281
+ nothing to pick while it is disabled
@@ -66,9 +66,11 @@ Four ways to end up with an empty field and no request:
66
66
  - **The URL is blocked** by [`fetcher.remotePatterns`](../setup.md#fetcherremotepatterns).
67
67
  This one is logged; the others are silent. Relative URLs are internal and
68
68
  always allowed.
69
- - **The field is disabled.** Nothing is fetched, and a disabled `select` whose
70
- value is an object renders that object as its only option — so a saved
71
- selection is still readable on a form that cannot be edited.
69
+ - **The field is disabled.** Nothing is fetched, and a disabled `select` with a
70
+ remote source whose value is an object renders that object as its only
71
+ option — so a saved selection is still readable on a form that cannot be
72
+ edited. Inline options need no request and stay, so a disabled `select` with
73
+ an array source still shows the option it holds.
72
74
 
73
75
  Requests are keyed by the source itself, so two fields declaring the same one
74
76
  share a single request and its cache entry.
@@ -29,7 +29,7 @@ Luna Form's input fields inherit the standard logical properties available to al
29
29
  - **`placeholder`** _(string, optional)_: The temporary placeholder text shown when the input is empty.
30
30
  - **`required`** _(boolean, optional)_: If `true`, standard browser/HTML5 validation makes the field mandatory.
31
31
  - **`disabled`** _(boolean, optional)_: If `true`, the field is non-interactive and blocked from events, and its value is not submitted.
32
- - **`readonly`** _(boolean, optional)_: If `true`, the field's value is locked and cannot be modified by the user, but it is still submitted. The control renders disabled, the one lock every input component understands, and the form sends the value itself. A field that is both `readonly` and `disabled` is not submitted.
32
+ - **`readonly`** _(boolean, optional)_: If `true`, the field's value is locked and cannot be modified by the user, but it is still submitted. The control renders disabled, the one lock every input component understands, and the form sends the value itself; a change the control sends anyway is not applied. A field that is both `readonly` and `disabled` is not submitted.
33
33
  - **`hidden`** _(boolean, optional)_: If `true`, the field is hidden from the user interface.
34
34
  - **`order`** _(number, optional)_: A numeric order determining the field's position relative to adjacent fields.
35
35
 
@@ -54,7 +54,7 @@ The `advanced` property dictates finer HTML details, interactive structures, and
54
54
 
55
55
  These basic field types support extra manipulation properties inside the `advanced` block:
56
56
 
57
- - **`length`** _({ min?: number, max?: number })_: Applies HTML structural limits (`minlength` / `maxlength`, or `min` / `max` depending on the input type).
57
+ - **`length`** _({ min?: number, max?: number })_: Applies HTML structural limits (`minlength` / `maxlength`, or `min` / `max` depending on the input type). Each bound is a number, or a `$ref` the host resolves to one; see [A bound that is no number](#a-bound-that-is-no-number). On an `input/date` the bounds are days instead; see [The first and the last day](#the-first-and-the-last-day).
58
58
  - **`step`** _(number)_ (`input/number` only): A number is whole unless it declares a step, which is the browser's own default of 1. The step is rendered on the input, so its arrows move by it, and validation accepts only values on it, counted from `length.min` when there is one, as the browser does: `0.01` for a price, `0.5` for halves, `0.001` for three decimals. A step has to be a number above 0. The browser ignores 0 and below, and so does the form; text such as `"any"` is no step either, and it is never rendered. Either way the number stays whole.
59
59
  - **`transform`** _(string | string[])_: Safely intercepts user inputs and manipulates content dynamically. Options include:
60
60
  - `"lowercase"`
@@ -66,6 +66,22 @@ These basic field types support extra manipulation properties inside the `advanc
66
66
 
67
67
  A required `input/number` accepts `0` and negative numbers: required means a value is present, not that it is at least 1. An optional one left empty is not submitted at all, rather than submitted as `0`, and its `length` bounds only apply to a value that is there. `select/year` and `select/month` read an empty selection the same way.
68
68
 
69
+ ### Empty and required text
70
+
71
+ A text left empty is no value for its `length` bounds to check, as the browser's `minlength` does not apply to an empty value either. An optional field left empty, or holding only spaces, passes as `""`, whatever its `length.min`. A required one left empty asks for a value with `validation.required`, not with `validation.length.min`, and its bounds only apply once there is text. This holds for `input/text`, `input/tel`, `input/password`, a [`textarea`](#textarea), and a type the form does not know, which it checks as text.
72
+
73
+ ### A bound that is no number
74
+
75
+ On the types above, on a [`textarea`](#textarea), and on a type the form does not know, which it checks as text, a `length` bound is a number. A day such as `"2026-10-05"` is not one, nor is text that reads as one, such as `"3"`, nor a `$ref` the context does not hold or holds as `undefined`. Such a bound is a rule the form cannot read, so the field takes no value: anything in it is held back with `This value cannot be checked`, whatever else the field would have checked, such as the address of an `input/email` or the `step` of a number. An optional field left empty still passes, and a required one still asks for a value.
76
+
77
+ That is deliberate, as it is for [a date](#the-first-and-the-last-day): a server that validates with `buildFormSchema` and forgets its `context` would otherwise let through what the bound was there to keep out, with nothing in production to say so. The input is not handed such a bound, and a development build names it in the console, in the browser and on the server alike, once for each field:
78
+
79
+ ```text
80
+ [Luna Form] when: advanced.length.min is "2026-10-05", which is no number, so the field takes no value
81
+ ```
82
+
83
+ A minimum above the maximum, which no value passes, is named the same way.
84
+
69
85
  ### Temporal Options (`input/date`, `input/time`)
70
86
 
71
87
  Date and time inputs omit transformations and instead expose a `format` property directly under `advanced` for displaying values properly:
@@ -73,6 +89,175 @@ Date and time inputs omit transformations and instead expose a `format` property
73
89
  - **Date format** _(used with `input/date`)_: `"yyyy-MM-dd"`, `"MM/dd/yyyy"`, `"dd/MM/yyyy"`, or `"MMMM d, yyyy"`.
74
90
  - **Time format** _(used with `input/time`)_: `"HH:mm"`, `"HH:mm:ss"`, `"hh:mm a"`, or `"hh:mm:ss a"`.
75
91
 
92
+ ### What a date holds
93
+
94
+ An `input/date` holds a day as `yyyy-MM-dd`, whatever its `format`, or two of them in [a range](#a-range-of-days). That is the value the form hands its component, reports through `onValueChange`, submits, and validates on the server with `buildFormSchema`. With [submit validation off](../forms/submit.md#turning-submit-validation-off), the action gets the raw `FormData` instead, where a single day is whatever its control holds; a range and a read-only field still carry `yyyy-MM-dd`, in the hidden inputs the form adds. `format` only says how the field shows the day, and reaches the component as its `data-format` prop (see [Date components](custom-inputs.md#date-components)).
95
+
96
+ - A value the host passes in, or a `defaultValue`, is read in either shape: `yyyy-MM-dd`, or the field's `format`. A host that keeps what the form submitted can pass it straight back.
97
+ - A `format` with month or day names reads them in English or in Spanish, the languages the form writes them in, so `octubre 2, 2026`, which a form in Spanish shows, reads back as `2026-10-02`.
98
+ - Text that is no day, typed in another format, with a year short of four digits, or naming a day that does not exist such as February 30, is kept as typed and holds the submit back with `validation.date`.
99
+ - An optional date left empty is not submitted at all, the way an empty number is not.
100
+ - In a description, `{value}` is the `yyyy-MM-dd` text. To show it another way, use a filter such as `{value | date:long}`.
101
+ - Everything that compares a date sees `yyyy-MM-dd` too: a `when`, a `custom` or `rules` validation, a change event. Write the dates they compare against that way, as in `"value": "2026-10-01"`.
102
+
103
+ ### The first and the last day
104
+
105
+ `advanced.length` bounds an `input/date` the way it bounds a number: `min` is the first day a person may pick and `max` the last, both included, written as `yyyy-MM-dd` whatever the field's `format`. `validation.length.min` and `.max` are the messages for a day outside them; without one, the message names the bound the way the field shows a day.
106
+
107
+ ```json
108
+ {
109
+ "name": "check_in",
110
+ "type": "input/date",
111
+ "advanced": {
112
+ "format": "dd/MM/yyyy",
113
+ "length": { "min": "2026-10-05", "max": "2026-10-20" }
114
+ },
115
+ "validation": { "length": { "min": "We open on October 5" } }
116
+ }
117
+ ```
118
+
119
+ The component gets the bounds as `min` and `max`, which a native `<input type="date">` uses as they are and a calendar reads back with `readDateProps` (see [Date components](custom-inputs.md#date-components)). The form checks them itself, so a day typed, passed in by the host or posted to the server outside them holds the submit back whatever the component allowed.
120
+
121
+ The form keeps no clock, so which day is today is the host's to say. Pass it in [`context`](../structure/definition.md#what-the-host-knows-context) and point the bound at it:
122
+
123
+ ```json
124
+ "advanced": { "length": { "min": { "$ref": "#/context/today" } } }
125
+ ```
126
+
127
+ ```tsx
128
+ const context = useMemo(() => ({ today }), [today])
129
+
130
+ <Form sections={sections} context={context} config={config} />
131
+ ```
132
+
133
+ The host decides the day and the time zone it is counted in, and a server that validates with `buildFormSchema(sections, translations, definition, context)` gets the same answer as the browser. Keep `context` the same object until the day changes: every field renders again when it is a new one (see [What the host knows](../structure/definition.md#what-the-host-knows-context)).
134
+
135
+ A bound that is no `yyyy-MM-dd` day, such as `"05/10/2026"`, or a `$ref` the context does not hold or holds as `undefined`, is a rule the form cannot read, so the field takes no day: every day is held back with `This date cannot be checked`, and an optional field left empty still passes. That is deliberate. A server that validates with `buildFormSchema` and forgets its `context` would otherwise let every day through, with nothing in production to say so. The component is not handed such a bound, and a development build names it in the console wherever the form reads the bounds, in the browser and on the server alike: once for each field, and again whenever a new `context` resolves them anew. A minimum later than the maximum, which no day passes, is named the same way.
136
+
137
+ ### Days nobody can pick
138
+
139
+ `advanced.reserved` lists days nobody may pick, as `yyyy-MM-dd`: a night already booked, a holiday. `validation.reserved` is the message for one of them; without it, the message is `This date is not available`. The bounds are checked first, so a reserved day outside them gets the bound's message.
140
+
141
+ ```json
142
+ {
143
+ "name": "night",
144
+ "type": "input/date",
145
+ "advanced": { "reserved": ["2026-12-24", "2026-12-25"] },
146
+ "validation": { "reserved": "That night is taken" }
147
+ }
148
+ ```
149
+
150
+ What is booked is usually the host's to know, so the list usually comes through [`context`](../structure/definition.md#what-the-host-knows-context):
151
+
152
+ ```json
153
+ "advanced": { "reserved": { "$ref": "#/context/booked" } }
154
+ ```
155
+
156
+ The component gets the days as `data-reserved`, sorted, once each and joined by commas, and reads them back as a list with `readDateProps` (see [Date components](custom-inputs.md#date-components)). The form checks them itself, and a server that validates with `buildFormSchema(sections, translations, definition, context)` checks the list it is given at that moment. That holds back a submit made with an old list on screen; two submits for the same day at the same moment are told apart only by the application, when it saves.
157
+
158
+ An entry that is no `yyyy-MM-dd` day, or a list that is not one, such as a `$ref` the context does not hold, is a rule the form cannot read too: the field takes no day, with `This date cannot be checked`, rather than let a booked night through. A development build names both, the way it names a bound.
159
+
160
+ ### A range of days
161
+
162
+ `advanced.mode: "range"` makes an `input/date` hold two days, the first and the last, as `[from, to]`, both `yyyy-MM-dd`. That pair is what the component gets, what the host is told and what the action receives. A range of one day has the same day at both ends.
163
+
164
+ ```json
165
+ {
166
+ "name": "stay",
167
+ "type": "input/date",
168
+ "advanced": { "mode": "range", "reserved": { "$ref": "#/context/booked" } },
169
+ "validation": { "range": "Pick the first and the last night" }
170
+ }
171
+ ```
172
+
173
+ ```js
174
+ {
175
+ stay: ['2026-11-02', '2026-11-06']
176
+ }
177
+ ```
178
+
179
+ - Each end answers to the rules of a single day: the bounds, the reserved days, and `validation.date` for one that is no day. The days between the ends belong to the range too, so a reserved one anywhere inside holds it back.
180
+ - An end missing, or a last day before the first, holds the submit back with `validation.range`; without it, the message is `Invalid date range`.
181
+ - Nothing picked is no range: an optional one is not submitted, and a required one asks with `validation.required`.
182
+ - The form submits the two days itself, in two hidden inputs, so the component's control has no `name` and nothing it renders is sent. A host passes a range the way it gets one back, as `[from, to]`.
183
+ - A native `<input type="date">` holds one day. A range needs a component that picks two (see [Date components](custom-inputs.md#date-components)), or two date fields compared with a `custom` validation and `gte`.
184
+ - In a description, `{value}` stays as written: it takes one value, and a range is two.
185
+ - A range is a list of two days. `exists`, `empty`, `truthy` and `contains` read it the way they read any list, but the operators that order (`gt`, `gte`, `lt`, `lte`), and `eq` or `in` against a single day, never hold on it, so a `custom` rule that orders a range holds every submit back. Compare single dates instead.
186
+
187
+ ### A booking, end to end
188
+
189
+ A stay that starts today at the earliest, within a year, and on no night already booked. The form declares the rules; the host knows the facts, and hands them over in `context`:
190
+
191
+ ```json
192
+ {
193
+ "name": "stay",
194
+ "type": "input/date",
195
+ "label": "Stay",
196
+ "required": true,
197
+ "advanced": {
198
+ "format": "dd/MM/yyyy",
199
+ "mode": "range",
200
+ "length": {
201
+ "min": { "$ref": "#/context/dates.today" },
202
+ "max": { "$ref": "#/context/dates.inOneYear" }
203
+ },
204
+ "reserved": { "$ref": "#/context/booked" }
205
+ },
206
+ "validation": {
207
+ "required": "Pick your stay",
208
+ "range": "Pick the first and the last night",
209
+ "length": {
210
+ "min": "A stay cannot start in the past",
211
+ "max": "We take bookings a year ahead"
212
+ },
213
+ "reserved": "A night in it is taken"
214
+ }
215
+ }
216
+ ```
217
+
218
+ Which day is today depends on where it is counted, so the host counts it where the business does, not where the server runs:
219
+
220
+ ```ts
221
+ // In the application, not in the form.
222
+ import { addYears, format, parse } from 'date-fns'
223
+
224
+ // 'en-CA' writes a day as yyyy-MM-dd.
225
+ const day = new Intl.DateTimeFormat('en-CA', {
226
+ timeZone: 'America/Bogota',
227
+ year: 'numeric',
228
+ month: '2-digit',
229
+ day: '2-digit',
230
+ })
231
+
232
+ export function formDates(now: Date) {
233
+ const today = day.format(now)
234
+ const next = addYears(parse(today, 'yyyy-MM-dd', new Date()), 1)
235
+ return { today, inOneYear: format(next, 'yyyy-MM-dd') }
236
+ }
237
+ ```
238
+
239
+ The page works the context out once per request, so it is the same object for as long as the form is on screen, and the server validates the submit with a context worked out when it arrives:
240
+
241
+ ```tsx
242
+ const context = {
243
+ dates: formDates(new Date()),
244
+ booked: await bookedNights(roomId),
245
+ }
246
+
247
+ <Form sections={sections} context={context} config={config} action={book} />
248
+ ```
249
+
250
+ ```ts
251
+ // In the action: the same rules, against the nights booked by now.
252
+ const schema = buildFormSchema(sections, translations, definition, {
253
+ dates: formDates(new Date()),
254
+ booked: await bookedNights(roomId),
255
+ })
256
+ const result = schema.safeParse(form) // { stay: ['2026-11-02', '2026-11-06'] }
257
+ ```
258
+
259
+ That holds back a stay chosen from a list that was old by the time it was sent. Two submits for the same night at the same moment are told apart only by the application, when it saves: the form is the first line, not the guarantee.
260
+
76
261
  ---
77
262
 
78
263
  ## Validation (`validation` object)
@@ -81,7 +266,10 @@ The `validation` object resolves form errors overriding generic defaults, mappin
81
266
 
82
267
  - **`required`** _(string)_: Specifies the error message exposed when the element is marked exactly as `required: true` and the field is empty.
83
268
  - **`email`** _(string)_: Error message specifically asserting an invalid email format.
84
- - **`length`** _({ min?: string, max?: string })_: Specific string messages shown when input lengths are breached.
269
+ - **`date`** _(string)_: `input/date` only. The message shown when the field holds text that is no day. Without it, the message is `Invalid date`.
270
+ - **`length`** _({ min?: string, max?: string })_: Specific string messages shown when a value breaches `advanced.length`: a text too short or too long, the address of an `input/email` included, a number out of range, a day before the first or after the last. An address that is no address and is out of its bounds shows both messages, the `email` one first. An optional `input/email` left empty still passes.
271
+ - **`reserved`** _(string)_: `input/date` only. The message shown for a day its `advanced.reserved` lists. Without it, the message is `This date is not available`.
272
+ - **`range`** _(string)_: `input/date` ranges only. The message shown for a range with an end missing, or whose last day comes before its first. Without it, the message is `Invalid date range`.
85
273
  - **`step`** _(string)_: The message shown when an `input/number` is off its step: a decimal on a number that declares no step, or a value off the `advanced.step` it declares. The form's dictionary translates it, as it does every other message.
86
274
  - **`custom`** _(CustomValidation | CustomValidation[])_: Powerful conditional-based logic blocks. An array specifying:
87
275
  - `field`: Optional target reference string.
@@ -153,7 +341,10 @@ Everything else in `advanced` is ignored for this type.
153
341
  ```
154
342
 
155
343
  `length.min` and `length.max` are emitted as the `minLength` and `maxLength`
156
- attributes on the rendered element.
344
+ attributes on the rendered element, and validation checks the same bounds on
345
+ text that is there: see [Empty and required text](#empty-and-required-text). A
346
+ bound that is no number is neither: see
347
+ [A bound that is no number](#a-bound-that-is-no-number).
157
348
 
158
349
  Like the `input/*` family, a declared `change` event on a `textarea` is
159
350
  debounced: it fires 300 ms after the last keystroke, not on every one.
@@ -25,6 +25,7 @@ The `advanced` property customizes structural interaction rules and layout rende
25
25
  A custom label has to be translated by the form; the built-in `Add item` default is already translated by the library — see [Localization](#localization).
26
26
  - **`length`** _({ min?: number, max?: number })_: Configures restrictions on how many items can be generated. Enforces boundary conditions where minimum instances ensure permanent default items and maximum instances automatically disable insertion toggles. The matching error messages are declared under the list's `validation.length` — see the [Validation reference](../validation/overview.md#list-length).
27
27
  Both bounds apply to the rows a list opens with, not only to the ones added by hand: an initial `value` shorter than `min` leaves the remainder standing and empty, and one longer than `max` stops at `max`. A [`value` event](../events/change.md#targeting-a-list) assigning rows is clamped the same way.
28
+ Each bound is a number, or a `$ref` the host resolves to one. A bound that is not, such as a `$ref` the context does not hold, is a rule the form cannot read: the list is held back with `This list cannot be checked`, an empty one included, since a list has no `required` of its own and `length.min` is how it asks for rows. Meanwhile it shows its rows as if that bound were not declared, a `min` of one or no `max` at all, and a development build names the bound in the console, as it does [for a field](input.md#a-bound-that-is-no-number).
28
29
  - **`collapsed`** _(boolean)_: When enabled, list items default to a visually collapsed representation upon mounting or adding.
29
30
  Collapsing only hides a row: its fields keep their values, still validate and are still submitted, and a submit that fails on one of them opens the row so the error can be seen.
30
31
  - **`keepValue`** _(boolean)_: If `true`, hiding the list leaves the values behind instead of taking them. A list holds nothing under its own name, so declaring it here is a statement about the rows: it covers every value they hold. See [Keeping a value through a hide](../events/change.md#keeping-a-value-through-a-hide).
@@ -156,7 +157,7 @@ The built-ins are layered _under_ the form's own block, so declaring a key still
156
157
  }
157
158
  ```
158
159
 
159
- Regional tags resolve to their base language, so `es-MX` and `es-419` both get the Spanish defaults. A form's own entries keep matching on the exact tag, unchanged.
160
+ Regional tags fall back to their base language: `es-MX` and `es-419` get the Spanish defaults and the form's own `es` block, and a block for the exact tag, where the form has one, wins key by key. One `lang` carries the region the form's money and dates are written for without repeating its translations. A tag that is no tag, such as `es_MX`, is English everywhere, its dictionary included.
160
161
 
161
162
  ### Accessible name templates
162
163