react-luna-form 0.0.87 → 0.0.88
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.
- package/dist/client/cjs/index.js +1 -1
- package/dist/client/esm/index.js +1 -1
- package/dist/config/cjs/index.js +1 -1
- package/dist/config/esm/index.js +1 -1
- package/dist/schema/cjs/index.js +1 -1
- package/dist/schema/esm/index.js +1 -1
- package/dist/server/cjs/index.js +1 -1
- package/dist/server/esm/index.js +1 -1
- package/dist/types/luna-core/handle/source-event.d.ts +2 -1
- package/dist/types/luna-core/handle/value-event.d.ts +2 -1
- package/dist/types/luna-core/helper/input.d.ts +16 -7
- package/dist/types/luna-core/type.d.ts +15 -4
- package/dist/types/luna-core/util/constant.d.ts +4 -0
- package/dist/types/luna-core/util/date.d.ts +132 -6
- package/dist/types/luna-core/util/format.d.ts +11 -0
- package/dist/types/luna-core/util/is-input.d.ts +3 -2
- package/dist/types/luna-core/util/pattern.d.ts +28 -0
- package/dist/types/luna-core/util/prepare.d.ts +3 -2
- package/dist/types/luna-core/util/schema.d.ts +2 -1
- package/dist/types/luna-core/util/string.d.ts +7 -0
- package/dist/types/luna-react/src/client/component/field/field-list-preview-item.d.ts +3 -3
- package/dist/types/luna-react/src/client/component/field/field-preview-item.d.ts +4 -2
- package/dist/types/luna-react/src/client/component/field/field-preview-value.d.ts +4 -1
- package/dist/types/luna-react/src/client/component/field/field-preview.d.ts +2 -2
- package/dist/types/luna-react/src/client/component/formatted-description.d.ts +2 -2
- package/dist/types/luna-react/src/client/component/guard/list-guard.d.ts +1 -1
- package/dist/types/luna-react/src/client/component/input-strategies.d.ts +9 -1
- package/dist/types/luna-react/src/client/component/slot/slot.d.ts +1 -2
- package/dist/types/luna-react/src/client/hook/use-input-core.d.ts +2 -2
- package/dist/types/luna-react/src/component/column.d.ts +2 -2
- package/dist/types/luna-react/src/component/description.d.ts +2 -2
- package/dist/types/luna-react/src/component/field/field-list.d.ts +2 -3
- package/dist/types/luna-react/src/component/field/field.d.ts +3 -3
- package/dist/types/luna-react/src/component/field-description.d.ts +2 -2
- package/dist/types/luna-react/src/component/form.d.ts +1 -0
- package/dist/types/luna-react/src/component/input/input-base.d.ts +3 -3
- package/dist/types/luna-react/src/component/input-group.d.ts +2 -2
- package/dist/types/luna-react/src/component/input-label.d.ts +2 -2
- package/dist/types/luna-react/src/component/slot/list-slot.d.ts +1 -1
- package/dist/types/luna-react/src/component/slot/slot-base.d.ts +2 -3
- package/dist/types/luna-react/src/component/slot/slot-create.d.ts +2 -3
- package/dist/types/luna-react/src/component/slot/slot-list.d.ts +2 -3
- package/dist/types/luna-react/src/component/slot/slot.d.ts +1 -2
- package/dist/types/luna-react/src/config/index.d.ts +1 -0
- package/dist/types/luna-react/src/server/component/input.d.ts +2 -2
- package/dist/types/luna-react/src/server/component/slot.d.ts +1 -2
- package/dist/types/luna-react/src/type.d.ts +2 -1
- package/docs/events/change.md +4 -1
- package/docs/fields/custom-inputs.md +153 -4
- package/docs/fields/data-source.md +5 -3
- package/docs/fields/input.md +175 -3
- package/docs/fields/list.md +1 -1
- package/docs/fields/specialized-selectors.md +51 -12
- package/docs/forms/submit.md +6 -8
- package/docs/index.md +2 -1
- package/docs/interpolation/format-filters.md +28 -29
- package/docs/interpolation/overview.md +44 -14
- package/docs/setup.md +17 -16
- package/docs/structure/definition.md +45 -6
- package/docs/validation/overview.md +14 -11
- package/package.json +2 -2
- package/dist/types/luna-react/src/client/hook/use-format.d.ts +0 -5
|
@@ -44,16 +44,19 @@ Props are applied in this order:
|
|
|
44
44
|
|
|
45
45
|
```tsx
|
|
46
46
|
<Component
|
|
47
|
-
{...
|
|
48
|
-
{...
|
|
49
|
-
{...
|
|
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`
|
|
70
|
-
value is an object renders that object as its only
|
|
71
|
-
selection is still readable on a form that cannot be
|
|
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.
|
package/docs/fields/input.md
CHANGED
|
@@ -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). 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"`
|
|
@@ -73,6 +73,175 @@ Date and time inputs omit transformations and instead expose a `format` property
|
|
|
73
73
|
- **Date format** _(used with `input/date`)_: `"yyyy-MM-dd"`, `"MM/dd/yyyy"`, `"dd/MM/yyyy"`, or `"MMMM d, yyyy"`.
|
|
74
74
|
- **Time format** _(used with `input/time`)_: `"HH:mm"`, `"HH:mm:ss"`, `"hh:mm a"`, or `"hh:mm:ss a"`.
|
|
75
75
|
|
|
76
|
+
### What a date holds
|
|
77
|
+
|
|
78
|
+
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)).
|
|
79
|
+
|
|
80
|
+
- 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.
|
|
81
|
+
- 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`.
|
|
82
|
+
- 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`.
|
|
83
|
+
- An optional date left empty is not submitted at all, the way an empty number is not.
|
|
84
|
+
- In a description, `{value}` is the `yyyy-MM-dd` text. To show it another way, use a filter such as `{value | date:long}`.
|
|
85
|
+
- 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"`.
|
|
86
|
+
|
|
87
|
+
### The first and the last day
|
|
88
|
+
|
|
89
|
+
`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.
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"name": "check_in",
|
|
94
|
+
"type": "input/date",
|
|
95
|
+
"advanced": {
|
|
96
|
+
"format": "dd/MM/yyyy",
|
|
97
|
+
"length": { "min": "2026-10-05", "max": "2026-10-20" }
|
|
98
|
+
},
|
|
99
|
+
"validation": { "length": { "min": "We open on October 5" } }
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
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.
|
|
104
|
+
|
|
105
|
+
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:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
"advanced": { "length": { "min": { "$ref": "#/context/today" } } }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
const context = useMemo(() => ({ today }), [today])
|
|
113
|
+
|
|
114
|
+
<Form sections={sections} context={context} config={config} />
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
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)).
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
### Days nobody can pick
|
|
122
|
+
|
|
123
|
+
`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.
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"name": "night",
|
|
128
|
+
"type": "input/date",
|
|
129
|
+
"advanced": { "reserved": ["2026-12-24", "2026-12-25"] },
|
|
130
|
+
"validation": { "reserved": "That night is taken" }
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
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):
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
"advanced": { "reserved": { "$ref": "#/context/booked" } }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
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.
|
|
143
|
+
|
|
144
|
+
### A range of days
|
|
145
|
+
|
|
146
|
+
`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.
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"name": "stay",
|
|
151
|
+
"type": "input/date",
|
|
152
|
+
"advanced": { "mode": "range", "reserved": { "$ref": "#/context/booked" } },
|
|
153
|
+
"validation": { "range": "Pick the first and the last night" }
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
{
|
|
159
|
+
stay: ['2026-11-02', '2026-11-06']
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- 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.
|
|
164
|
+
- 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`.
|
|
165
|
+
- Nothing picked is no range: an optional one is not submitted, and a required one asks with `validation.required`.
|
|
166
|
+
- 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]`.
|
|
167
|
+
- 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`.
|
|
168
|
+
- In a description, `{value}` stays as written: it takes one value, and a range is two.
|
|
169
|
+
- 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.
|
|
170
|
+
|
|
171
|
+
### A booking, end to end
|
|
172
|
+
|
|
173
|
+
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`:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"name": "stay",
|
|
178
|
+
"type": "input/date",
|
|
179
|
+
"label": "Stay",
|
|
180
|
+
"required": true,
|
|
181
|
+
"advanced": {
|
|
182
|
+
"format": "dd/MM/yyyy",
|
|
183
|
+
"mode": "range",
|
|
184
|
+
"length": {
|
|
185
|
+
"min": { "$ref": "#/context/dates.today" },
|
|
186
|
+
"max": { "$ref": "#/context/dates.inOneYear" }
|
|
187
|
+
},
|
|
188
|
+
"reserved": { "$ref": "#/context/booked" }
|
|
189
|
+
},
|
|
190
|
+
"validation": {
|
|
191
|
+
"required": "Pick your stay",
|
|
192
|
+
"range": "Pick the first and the last night",
|
|
193
|
+
"length": {
|
|
194
|
+
"min": "A stay cannot start in the past",
|
|
195
|
+
"max": "We take bookings a year ahead"
|
|
196
|
+
},
|
|
197
|
+
"reserved": "A night in it is taken"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Which day is today depends on where it is counted, so the host counts it where the business does, not where the server runs:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// In the application, not in the form.
|
|
206
|
+
import { addYears, format, parse } from 'date-fns'
|
|
207
|
+
|
|
208
|
+
// 'en-CA' writes a day as yyyy-MM-dd.
|
|
209
|
+
const day = new Intl.DateTimeFormat('en-CA', {
|
|
210
|
+
timeZone: 'America/Bogota',
|
|
211
|
+
year: 'numeric',
|
|
212
|
+
month: '2-digit',
|
|
213
|
+
day: '2-digit',
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
export function formDates(now: Date) {
|
|
217
|
+
const today = day.format(now)
|
|
218
|
+
const next = addYears(parse(today, 'yyyy-MM-dd', new Date()), 1)
|
|
219
|
+
return { today, inOneYear: format(next, 'yyyy-MM-dd') }
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
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:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
const context = {
|
|
227
|
+
dates: formDates(new Date()),
|
|
228
|
+
booked: await bookedNights(roomId),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
<Form sections={sections} context={context} config={config} action={book} />
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// In the action: the same rules, against the nights booked by now.
|
|
236
|
+
const schema = buildFormSchema(sections, translations, definition, {
|
|
237
|
+
dates: formDates(new Date()),
|
|
238
|
+
booked: await bookedNights(roomId),
|
|
239
|
+
})
|
|
240
|
+
const result = schema.safeParse(form) // { stay: ['2026-11-02', '2026-11-06'] }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
76
245
|
---
|
|
77
246
|
|
|
78
247
|
## Validation (`validation` object)
|
|
@@ -81,7 +250,10 @@ The `validation` object resolves form errors overriding generic defaults, mappin
|
|
|
81
250
|
|
|
82
251
|
- **`required`** _(string)_: Specifies the error message exposed when the element is marked exactly as `required: true` and the field is empty.
|
|
83
252
|
- **`email`** _(string)_: Error message specifically asserting an invalid email format.
|
|
84
|
-
- **`
|
|
253
|
+
- **`date`** _(string)_: `input/date` only. The message shown when the field holds text that is no day. Without it, the message is `Invalid date`.
|
|
254
|
+
- **`length`** _({ min?: string, max?: string })_: Specific string messages shown when a value breaches `advanced.length`: a text too short or too long, a number out of range, a day before the first or after the last.
|
|
255
|
+
- **`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`.
|
|
256
|
+
- **`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
257
|
- **`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
258
|
- **`custom`** _(CustomValidation | CustomValidation[])_: Powerful conditional-based logic blocks. An array specifying:
|
|
87
259
|
- `field`: Optional target reference string.
|
package/docs/fields/list.md
CHANGED
|
@@ -156,7 +156,7 @@ The built-ins are layered _under_ the form's own block, so declaring a key still
|
|
|
156
156
|
}
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
-
Regional tags
|
|
159
|
+
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
160
|
|
|
161
161
|
### Accessible name templates
|
|
162
162
|
|
|
@@ -21,10 +21,18 @@ An optional month nobody picked is not submitted at all, and a required one asks
|
|
|
21
21
|
|
|
22
22
|
### 2. Year Selector (`select/year`)
|
|
23
23
|
|
|
24
|
-
Renders a dropdown with
|
|
24
|
+
Renders a dropdown with the years from `advanced.length.min` to `advanced.length.max`, both included, written as whole numbers:
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"name": "graduation_year",
|
|
29
|
+
"type": "select/year",
|
|
30
|
+
"label": "Graduation Year",
|
|
31
|
+
"advanced": { "length": { "min": 1980, "max": 2030 } }
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The form keeps no clock, so which year it is now is the host's to say, the way it says [which day is today](input.md#the-first-and-the-last-day) to a date. Pass the years in [`context`](../structure/definition.md#what-the-host-knows-context) and point the bounds at them:
|
|
28
36
|
|
|
29
37
|
```json
|
|
30
38
|
{
|
|
@@ -33,14 +41,30 @@ Renders a dropdown with a range of years.
|
|
|
33
41
|
"label": "Expiration Year",
|
|
34
42
|
"advanced": {
|
|
35
43
|
"length": {
|
|
36
|
-
"min":
|
|
37
|
-
"max":
|
|
44
|
+
"min": { "$ref": "#/context/years.current" },
|
|
45
|
+
"max": { "$ref": "#/context/years.inTen" }
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
```
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
```ts
|
|
52
|
+
// In the application, counted where the business is, not where the server runs.
|
|
53
|
+
const year = Number(
|
|
54
|
+
new Intl.DateTimeFormat('en', {
|
|
55
|
+
timeZone: 'America/Bogota',
|
|
56
|
+
year: 'numeric',
|
|
57
|
+
}).format(new Date())
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const context = { years: { current: year, inTen: year + 10 } }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
At 03:00 UTC on 1 January the year is already the next one in UTC and still the old one in Bogota, so the host decides where it is counted. The server renders and validates with the years it is given, the same ones the browser gets.
|
|
64
|
+
|
|
65
|
+
A year select needs both bounds. Without one, or with one that is no whole number, such as `"2026"` written as text or a `$ref` the context does not hold, it offers no year, and a development build names it in the console. A minimum after the maximum is named the same way.
|
|
66
|
+
|
|
67
|
+
The schema checks the same bounds, so a year outside them, from a host value or a submit made by hand, is held back with `validation.length.min` or `validation.length.max` as the message, or a default such as `Too small: expected number to be >=2026`. A field that offers no year takes none: any year is held back with `This year is not available`. An optional year nobody picked is not submitted at all, and a required one asks for a value, the way an [`input/number`](input.md#empty-and-required-numbers) does.
|
|
44
68
|
|
|
45
69
|
### 3. Day Selector (`select/day`)
|
|
46
70
|
|
|
@@ -56,17 +80,32 @@ Renders a dropdown with days 1 through 31.
|
|
|
56
80
|
|
|
57
81
|
### 4. Timezone Selector (`select/timezone`)
|
|
58
82
|
|
|
59
|
-
Provides
|
|
83
|
+
Provides every time zone the runtime knows, from `Intl.supportedValuesOf('timeZone')`, grouped by region. A field submits the zone's IANA name, such as `America/Bogota`.
|
|
60
84
|
|
|
61
85
|
```json
|
|
62
86
|
{
|
|
63
|
-
"name": "
|
|
87
|
+
"name": "meeting_zone",
|
|
64
88
|
"type": "select/timezone",
|
|
65
|
-
"label": "
|
|
66
|
-
"
|
|
89
|
+
"label": "Time zone",
|
|
90
|
+
"advanced": { "suggested": { "$ref": "#/context/user.timeZone" } }
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
// Worked out once per request, on the server.
|
|
96
|
+
const context = {
|
|
97
|
+
now: new Date().toISOString(),
|
|
98
|
+
user: { timeZone: session.timeZone },
|
|
67
99
|
}
|
|
100
|
+
|
|
101
|
+
<Form sections={sections} context={context} config={config} />
|
|
68
102
|
```
|
|
69
103
|
|
|
104
|
+
- **`advanced.suggested`** is the zone offered first, alone in a "Suggested" group. The host knows it, from a profile, a cookie or a header, so it usually arrives through [`context`](../structure/definition.md#what-the-host-knows-context), and the server and the browser suggest the same one. Without it there is no "Suggested" group. A zone written another way is suggested, and submitted, by the name the runtime gives it, so it is listed once: `america/bogota` as `America/Bogota`, `US/Eastern` as `America/New_York`. A value that is no zone suggests nothing, an offset such as `+05:00` included, and a development build names it in the console.
|
|
105
|
+
- **[`context.now`](../interpolation/overview.md#the-instant-relative-dates-use)**, an ISO date, time and offset, is the instant the zones are labelled for. A zone's offset depends on it, `Madrid - Central European (UTC+01:00)` in January and `(UTC+02:00)` in July, and so does its name, which some zones have changed over the years. Without it each zone is labelled by its city alone, `Madrid`.
|
|
106
|
+
|
|
107
|
+
The list is the runtime's own, so two engines can list different zones: Node and Chromium list `Asia/Calcutta` and leave out `UTC`, which can still be suggested.
|
|
108
|
+
|
|
70
109
|
---
|
|
71
110
|
|
|
72
111
|
## Utility Selectors
|
|
@@ -123,7 +162,7 @@ Renders chips for the 12 months of the year.
|
|
|
123
162
|
|
|
124
163
|
The option labels of these selectors are produced by the framework, not written in the schema, so most of them are resolved through the form's `lang` rather than against the `translations` dictionary the way an inline [array source](select.md#translating-options) is.
|
|
125
164
|
|
|
126
|
-
- `select/month`, `select/day`, `chips/day` and `chips/month` take their names from the form's `lang`, resolved through `Intl`. Without `lang`
|
|
165
|
+
- `select/month`, `select/day`, `chips/day` and `chips/month` take their names from the form's `lang`, resolved through `Intl`. Without `lang`, with a malformed tag (`es_MX`, `español`), or with one the runtime has no language for (`sp`), they are in English, whatever language the machine rendering them speaks.
|
|
127
166
|
- `select/year` renders plain numbers.
|
|
128
167
|
- `select/timezone` renders its region groups and zone names in English.
|
|
129
168
|
- `select/active` is the exception: `Yes` and `No` are authored copy rather than locale data, so they **are** resolved against the dictionary, using their English text as the key — the same way the built-in `(Optional)` suffix is. The library ships the Spanish for both, so `lang: "es"` alone is enough; see [built-in translations](list.md#built-in-translations).
|
|
@@ -139,7 +178,7 @@ The option labels of these selectors are produced by the framework, not written
|
|
|
139
178
|
}
|
|
140
179
|
```
|
|
141
180
|
|
|
142
|
-
The dropdown lists `enero` through `diciembre`, while the submitted value stays the month number.
|
|
181
|
+
The dropdown lists `enero` through `diciembre`, while the submitted value stays the month number. The server and the client render the same names, with `lang` or without it.
|
|
143
182
|
|
|
144
183
|
`select/active` instead reads its two labels from the dictionary, and the library already ships the Spanish for them:
|
|
145
184
|
|
package/docs/forms/submit.md
CHANGED
|
@@ -28,18 +28,16 @@ On submit, in this order:
|
|
|
28
28
|
|
|
29
29
|
1. **The form data becomes an object.** A name that appears more than once
|
|
30
30
|
arrives as an array; everything else as a single value.
|
|
31
|
-
2. **
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
2. **It is validated** against the schema built from the fields that are
|
|
32
|
+
currently mounted. A date is read in either shape the form accepts and comes
|
|
33
|
+
out of the schema as `yyyy-MM-dd`, so your action sees one shape whatever
|
|
34
34
|
`advanced.format` says.
|
|
35
|
-
3. **
|
|
36
|
-
currently mounted.
|
|
37
|
-
4. **Dotted names are un-flattened.** `basicAuth.username` becomes
|
|
35
|
+
3. **Dotted names are un-flattened.** `basicAuth.username` becomes
|
|
38
36
|
`{ basicAuth: { username } }`, and a numeric segment becomes an array index,
|
|
39
37
|
so `rules.0.value` rebuilds the list — with the gaps of removed rows closed
|
|
40
38
|
up.
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
4. **Your `action` is awaited** with that object and the schema.
|
|
40
|
+
5. **`onSuccess` is called** with what the action returned, and the form clears
|
|
43
41
|
itself.
|
|
44
42
|
|
|
45
43
|
## Only mounted fields are validated
|
package/docs/index.md
CHANGED
|
@@ -22,7 +22,8 @@ between the client and server `Form`.
|
|
|
22
22
|
| Declaring a text, number, date or password field | [fields/input.md](fields/input.md) |
|
|
23
23
|
| Declaring a `select`, `radio` or `chips` field, or filling one from a source | [fields/select.md](fields/select.md) |
|
|
24
24
|
| Declaring a repeatable group of fields | [fields/list.md](fields/list.md) |
|
|
25
|
-
|
|
|
25
|
+
| Picking a range of days, bounding a date or blocking days | [fields/input.md](fields/input.md#a-range-of-days) |
|
|
26
|
+
| Picking a day of the month, a month, a year, weekdays or a time zone | [fields/specialized-selectors.md](fields/specialized-selectors.md) |
|
|
26
27
|
| Implementing a field type this library does not ship | [fields/custom-inputs.md](fields/custom-inputs.md) |
|
|
27
28
|
| Filling a `select` from an API | [fields/data-source.md](fields/data-source.md) |
|
|
28
29
|
| Making a field required, or validating against another field | [validation/overview.md](validation/overview.md) |
|