@sorlo/forms 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/dist/index.d.mts +943 -0
- package/dist/index.d.ts +943 -0
- package/dist/index.js +4906 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4843 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles/base.css +218 -0
- package/dist/styles/layout.css +183 -0
- package/dist/styles/themes/dark.css +127 -0
- package/dist/styles/themes/default.css +128 -0
- package/package.json +81 -4
- package/src/styles/base.scss +245 -0
- package/src/styles/layout.scss +196 -0
- package/src/styles/themes/dark.scss +143 -0
- package/src/styles/themes/default.scss +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Levi Cole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,276 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sorlo Forms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An advanced, schema-driven form builder library for React. Forms are described as plain data, rendered from a set of pluggable inputs, and can include infinitely repeatable and nestable groups of fields. A built-in schema builder input lets end users construct form schemas from a UI — including the options of each field, which are themselves rendered as a Sorlo form.
|
|
4
|
+
|
|
5
|
+
## Installing
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @sorlo/forms
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
React 19 and `react-dom` are peer dependencies, so a project that doesn't have them yet installs them too:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @sorlo/forms react react-dom
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then import the styles once, for example in the app's entry file:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import '@sorlo/forms/styles/layout.css'
|
|
21
|
+
import '@sorlo/forms/styles/base.css'
|
|
22
|
+
import '@sorlo/forms/styles/themes/default.css'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The theme applies to forms with its class, `sorlo-theme-default`, as in the example below. [Styling and theming](#styling-and-theming) explains the three files and their SCSS versions.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import Form, { FormHandle, ValidationReport } from '@sorlo/forms'
|
|
31
|
+
import { useRef, useState } from 'react'
|
|
32
|
+
|
|
33
|
+
const schema = [
|
|
34
|
+
{ type: 'text', name: 'first_name', label: 'First name', required: true, width: 50 },
|
|
35
|
+
{ type: 'text', name: 'last_name', label: 'Last name', required: true, width: 50 },
|
|
36
|
+
{ type: 'email', name: 'email', label: 'Your email', required: true },
|
|
37
|
+
{
|
|
38
|
+
type: 'repeater',
|
|
39
|
+
name: 'pets',
|
|
40
|
+
label: 'Pets',
|
|
41
|
+
options: {
|
|
42
|
+
minimum: 1,
|
|
43
|
+
fields: [{ type: 'text', name: 'petname', label: 'Pet name', required: true }],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export default function Example() {
|
|
49
|
+
const formRef = useRef<FormHandle>(null)
|
|
50
|
+
const [values, setValues] = useState<Record<string, any>>({})
|
|
51
|
+
|
|
52
|
+
const onSubmit = () => {
|
|
53
|
+
const report: ValidationReport = formRef.current!.validate()
|
|
54
|
+
if (report.isValid) {
|
|
55
|
+
// send values somewhere
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<Form ref={formRef} className='sorlo-theme-default' schema={schema} values={values} onValuesChange={setValues} />
|
|
62
|
+
<button onClick={onSubmit}>Submit</button>
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`values`, `messages` and `validity` props are optional; the form manages them internally and reports changes through `onValuesChange`, `onMessagesChange` and `onValidityChange`. Passing a prop makes that piece controlled.
|
|
69
|
+
|
|
70
|
+
## Field schema
|
|
71
|
+
|
|
72
|
+
Every field supports `name` (required), `id`, `label`, `required`, `instructions`, `defaultValue`, `readonly`, `disabled`, `width` (percentage), `options`, and `conditions`/`conditionsMode` for conditional logic. The `type` selects the input, and `options` carries input-specific settings:
|
|
73
|
+
|
|
74
|
+
| Type | Options |
|
|
75
|
+
| --------------- | --------------------------------------------------------------------------- |
|
|
76
|
+
| `text` | `placeholder`, `minLength`, `maxLength` |
|
|
77
|
+
| `textarea` | `placeholder`, `rows`, `minLength`, `maxLength` |
|
|
78
|
+
| `password` | `placeholder`, `minLength`, `maxLength` — formatting masks the value |
|
|
79
|
+
| `number` | `placeholder`, `min`, `max` |
|
|
80
|
+
| `range` | `min`, `max`, `step` |
|
|
81
|
+
| `email` | `placeholder`, `multiple`, `minLength`, `maxLength` |
|
|
82
|
+
| `url` | `placeholder` — http(s) addresses only |
|
|
83
|
+
| `tel` | `placeholder` — loose phone number validation |
|
|
84
|
+
| `identifier` | `source`, `format` (`kebab`, `snake`, `camel`, `pascal`, `mixed`), `unicode` |
|
|
85
|
+
| `date` | `min`, `max` — value is an ISO date, e.g. `2026-01-15` |
|
|
86
|
+
| `time` | `min`, `max` — value is a 24 hour time, e.g. `13:30` |
|
|
87
|
+
| `datetime` | `min`, `max` — value is a local ISO datetime, e.g. `2026-01-15T13:30` |
|
|
88
|
+
| `color` | none — value is a hex color, e.g. `#ff0000` |
|
|
89
|
+
| `hidden` | `value` — carried into the form's values without any visible UI |
|
|
90
|
+
| `boolean` | `message` (the checkbox label) |
|
|
91
|
+
| `select` | `choices` (`{ value, label }` items), `placeholder`, `multiple` |
|
|
92
|
+
| `radio` | `choices` — single choice without a dropdown |
|
|
93
|
+
| `checkbox` | `choices` — multiple choice, value is an array |
|
|
94
|
+
| `conditions` | `fieldChoices` — the paths rules test; value is `[{ rules: [...] }]` |
|
|
95
|
+
| `group` | `fields` (nested field schemas) |
|
|
96
|
+
| `repeater` | `fields`, `minimum`, `maximum`, `direction` (`y` vertical / `x` horizontal) |
|
|
97
|
+
| `matrix` | `fields`, `minimumRows`, `maximumRows`, `minimumColumns`, `maximumColumns` |
|
|
98
|
+
| `flexible` | `layouts` (`{ name, label, fields }`) — value is `[{ _layout, ...fields }]` |
|
|
99
|
+
| `content` | `content` — a read only block with no value, for headings and instructions |
|
|
100
|
+
| `schemabuilder` | none — its value is an array of field schemas |
|
|
101
|
+
|
|
102
|
+
Groups nest their values under the group's name, repeaters produce an array of objects, matrices an array of rows of objects, and flexible content an array of blocks. All four can be nested inside each other without limit.
|
|
103
|
+
|
|
104
|
+
The schema builder gives every field it creates an `id`, e.g. `fld_k3x9q2mz`, that never changes when the field is edited, renamed or moved, so a schema diff can tell a rename from a removal plus an addition. Flexible layouts get one the same way, e.g. `lay_k3x9q2mz`. Values stay keyed by `name`, blocks keep naming their layout in `_layout`, and schemas without IDs keep working; editing such a field in the builder gives it and its layouts IDs. A field's name follows its label until edited, and is a `mixed` identifier, since names are the segments of paths like `address.town` and can't hold dots or spaces.
|
|
105
|
+
|
|
106
|
+
A `flexible` block is a layout's field values plus `_layout`, the name of the layout it uses, e.g. `[{ _layout: 'hero', title: 'Welcome' }]`, so `_layout` can't be a field name inside a layout. Its messages are keyed like a repeater's, e.g. `blocks.0.title`, and `pickValues` drops blocks whose layout no longer exists.
|
|
107
|
+
|
|
108
|
+
A `conditions` field edits the value conditional logic takes, groups of rules where any group matches when all of its rules do, e.g. `[{ rules: [{ field: 'country', operator: 'equals', value: 'UK' }] }]`. Its `fieldChoices` lists what rules can test, each `{ value, label, type, choices, example }`: `value` is a dotted path, `type` (`text`, `number`, `date` or `boolean`) picks the operators and the value's control, `choices` (`{ value, label }` items) make the value a select, and `example` shows under the value, e.g. `example.com` as "e.g. example.com". A rule needs a field, an operator that suits it and, except for `empty` and `notEmpty`, a value, and its messages are keyed like `conditions.0.rules.1.value`. A saved rule on a field that isn't listed shows as it is, with a message. Without `fieldChoices`, a rule's field is a dotted path typed in, with all nine operators. Values compare as text, so a boolean's `'false'` shows as No.
|
|
109
|
+
|
|
110
|
+
An `identifier` with a `source`, e.g. `title`, follows that field, found the same way as a condition's `field`, while it is empty or still holds the value it generated. A typed or loaded value never changes by itself. Clearing it picks the source back up when the field loses focus, which is also when an invalid value is tidied into its format. `kebab` (the default) is the URL style and may start with a digit, while `snake`, `camel` and `pascal` are code styles and must start with a letter. `mixed` accepts any of the code styles, or a mix such as `utm_source__c`, and generates snake case. Latin accents are always removed, and letters from other scripts are dropped unless `unicode` is set, e.g. `東京-タワー`.
|
|
111
|
+
|
|
112
|
+
## Validation
|
|
113
|
+
|
|
114
|
+
Inputs validate on change. Calling `validate()` on the form ref validates everything at once — untouched fields included — displays the messages, and returns a `ValidationReport` with a nested breakdown per field. The same walk is available as a pure function, useful on a server:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { inputs, validateValues } from '@sorlo/forms'
|
|
118
|
+
|
|
119
|
+
const report = validateValues(schema, values, inputs)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Container fields aggregate: a group, repeater or matrix is marked invalid while any nested field is invalid, and repeaters/matrices also validate their own item, row and column bounds. A container that fails its own check, such as a repeater over its `maximum` or a flexible field with an unknown layout, reports only that and leaves its items unchecked, so a long list makes one message rather than one per item. `validate()` still shows the messages of the items on the page.
|
|
123
|
+
|
|
124
|
+
To save values that aren't finished, such as a draft or one step of a long form, pass `{ allowIncomplete: true }` to `validate()`, `validateValues` or `validateServerValues`. It leaves out the checks an unfinished value fails, in nested fields too: `required`, and the `minLength`, `minimum`, `minimumRows` and `minimumColumns` options. Formats, maximums and ranges such as a number's `min` still apply, and `validate()` still shows every field's messages. Each input is given its schema with those cleared, so a custom input that uses the same names needs no changes.
|
|
125
|
+
|
|
126
|
+
Validation messages can be overridden per field through `options.messages`, keyed by the built in message names (`required`, `invalid`, `minLength`, `min`, `email`, and so on), with `{{min}}`-style parameters interpolated:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
{
|
|
130
|
+
type: 'email',
|
|
131
|
+
name: 'email',
|
|
132
|
+
required: true,
|
|
133
|
+
options: { messages: { required: 'Please enter your work email' } },
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The form's `messages` and `validity` maps are keyed by path, so nested fields report alongside top-level ones — `address.town`, `pets.0.petname`, `grid.0.1.amount` — which makes error summaries a simple iteration. Entries are removed when a field unmounts, for example when a repeater row is deleted. A custom container input can join in by consuming `FieldReportContext` and providing a wrapped report to its children that prefixes their position, exactly as the matrix does for its cells; the schema builder instead provides `undefined` to keep its temporary editor state out of the maps.
|
|
138
|
+
|
|
139
|
+
## Conditional logic
|
|
140
|
+
|
|
141
|
+
Any field can declare `conditions` — rules that must match for it to be shown. Hidden fields keep their values but are skipped by rendering, validation and `pickValues`, on the client and the server alike:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
{
|
|
145
|
+
type: 'repeater',
|
|
146
|
+
name: 'pets',
|
|
147
|
+
conditions: [{ field: 'has_pets', operator: 'equals', value: true }],
|
|
148
|
+
conditionsMode: 'all', // or 'any'
|
|
149
|
+
options: { ... },
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For and/or logic, conditions can be grouped: the field shows when **any** group has **all** of its rules matching. Groups are written as nested arrays, `{ rules }` objects (which is what the schema builder produces), or bare rules acting as a group of one:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
// (country equals 'UK' AND age greaterThan 18) OR (role equals 'admin')
|
|
157
|
+
conditions: [
|
|
158
|
+
[
|
|
159
|
+
{ field: 'country', operator: 'equals', value: 'UK' },
|
|
160
|
+
{ field: 'age', operator: 'greaterThan', value: 18 },
|
|
161
|
+
],
|
|
162
|
+
[{ field: 'role', operator: 'equals', value: 'admin' }],
|
|
163
|
+
]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Operators: `equals`, `notEquals`, `contains`, `notContains`, `startsWith`, `greaterThan`, `lessThan`, `empty`, `notEmpty`. Equality compares loosely (`'18'` matches `18`), since values written in the schema builder are strings. `greaterThan` and `lessThan` order numbers, and also ISO dates, date-times and times such as `2026-01-15`, `2026-01-15T13:30` and `13:30`, which compare as text, so a date-time falls after the start of its day. Fields are compared by their validated values, with schema defaults applied, so a checkbox posted as `'on'` matches `true` and an untouched one matches `false`, on the client and the server alike.
|
|
167
|
+
|
|
168
|
+
A condition's `field` is a dotted path resolved against the nearest value scope first, then outwards to the form's root — so inside a repeater item, `species` finds the sibling field in the same item, while `address.town` falls through to the root values, and `pets.0.petname` works from the root. The closest scope wins when names collide. The schema builder edits every field's conditions under "Conditional logic", with the `conditions` input listing what they can reach: the fields beside it, the fields in groups beside it as `address.town`, and the fields of each level around it. Conditions written in any of the shapes above show as groups, and stay as written unless they're edited.
|
|
169
|
+
|
|
170
|
+
## TypeScript
|
|
171
|
+
|
|
172
|
+
`Form`, `validateValues`, `validateServerValues` and `pickValues` accept a values type parameter, so a form whose shape you know gets typed values in and out:
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
interface Profile {
|
|
176
|
+
first_name: string
|
|
177
|
+
pets: Array<{ petname: string }>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
<Form<Profile> schema={schema} onValuesChange={(values) => values.pets} />
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Deliberately, there is no compile time typing of individual field paths: schemas are runtime data — often built at runtime by the schema builder itself — so the type system cannot know what fields exist. The generics type the container, not the schema.
|
|
184
|
+
|
|
185
|
+
When a schema is known ahead of time, `schemaToTypeScript` writes that type for you, ready to save in a `.d.ts` file. For the schema in [Usage](#usage):
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { inputs, schemaToTypeScript } from '@sorlo/forms'
|
|
189
|
+
|
|
190
|
+
schemaToTypeScript(schema, inputs, { name: 'profile' })
|
|
191
|
+
// export type Profile = {
|
|
192
|
+
// first_name: string
|
|
193
|
+
// last_name: string
|
|
194
|
+
// email: string
|
|
195
|
+
// pets?: Array<{
|
|
196
|
+
// petname: string
|
|
197
|
+
// }>
|
|
198
|
+
// }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Required fields become required properties, while optional fields and fields that conditions can hide become optional. The name is written in Pascal case, flexible blocks become a union you can narrow by checking `_layout`, and fields whose input has no `typeValue` (see [Custom inputs](#custom-inputs)) are typed as `unknown`.
|
|
202
|
+
|
|
203
|
+
## Server side validation
|
|
204
|
+
|
|
205
|
+
The same schema validates a request body on a server. `pickValues` reduces the posted values to only the fields the schema defines, nested fields included. It keeps each value as its input's `validateValue` returns it, the way a form does, so a posted `'5'` for a number becomes `5`, and it forces hidden fields back to their fixed values. `validateServerValues` returns the same `ValidationReport` shape as the client:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { inputs, pickValues, validateServerValues } from '@sorlo/forms'
|
|
209
|
+
|
|
210
|
+
app.post('/submit', async (request, response) => {
|
|
211
|
+
const values = pickValues(schema, request.body, inputs)
|
|
212
|
+
const report = await validateServerValues(schema, values, inputs)
|
|
213
|
+
|
|
214
|
+
if (!report.isValid) {
|
|
215
|
+
return response.status(422).json(report)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The values are validated and contain only schema defined fields
|
|
219
|
+
})
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
An input's `validateValue` is the shared source of truth, and the optional `validateClientValue` and `validateServerValue` take priority in their own environment — the client resolves `validateClientValue ?? validateValue` and the server resolves `validateServerValue ?? validateValue`, so a client only validator can never weaken the server's checks. Server validators may be asynchronous (for example a database uniqueness check), which is why `validateServerValues` returns a promise; the synchronous `validateValues` accepts an `{ environment }` option for fully synchronous setups and throws if a validator returns a promise.
|
|
223
|
+
|
|
224
|
+
On a public endpoint, cap the body's size and the number of items in lists that have no `maximum`, since `pickValues` and `validateServerValues` walk every item that's posted.
|
|
225
|
+
|
|
226
|
+
## Custom inputs
|
|
227
|
+
|
|
228
|
+
An input is a module implementing the `Input` interface: a default React component plus optional metadata (`name`, `description`, `category`), `validateValue` (with optional `validateClientValue`/`validateServerValue` overrides), `pickValue`, `formatValue`, `renderValue`, `coreFields`/`optionFields` describing how the schema builder should configure it, and `prepareSchema`, which finishes a field's schema when the builder saves it, as the flexible input does to give new layouts their IDs. The component reads everything it needs from `useFieldContext()`:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { useFieldContext, validationSuccess } from '@sorlo/forms'
|
|
232
|
+
|
|
233
|
+
export const name = () => 'Shout'
|
|
234
|
+
export const validateValue = (value: any) => validationSuccess(value)
|
|
235
|
+
|
|
236
|
+
export default function Component() {
|
|
237
|
+
const { id, value = '', handleValidation } = useFieldContext()
|
|
238
|
+
return <input id={id} value={value} onChange={(e) => handleValidation(validateValue(e.target.value.toUpperCase()))} />
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Register it per form: `<Form inputs={{ shout: shoutModule }} ... />`. Custom types are then available to schemas and appear in the schema builder.
|
|
243
|
+
|
|
244
|
+
A custom input that nests other fields can also export `validateChildren(value, schema, validateFields)` — this is how `validateValues` and `validate()` walk into it, and it is exactly what the core group, repeater and matrix inputs do; nothing about containers is special-cased. It's called once the field's own `validateValue` passes. Return the results of `validateFields` without inspecting them, as they may be promises during server validation. The matching `pickValue(value, schema, pickFields)` lets a container recurse for `pickValues` in the same way.
|
|
245
|
+
|
|
246
|
+
For `schemaToTypeScript`, an input describes the type of its value, as `pickValues` keeps it, with `typeValue(schema, typeFields)`. It returns the type as a string, such as `'string'` or `'Array<"red" | "blue">'`, and may name a type you declare beside the generated one. An input without a value returns `undefined` and is left out, as the content input does. A container types its nested fields with `typeFields`, which returns an object type, so the core group's hook is `(schema, typeFields) => typeFields(schema.options?.fields || [])`.
|
|
247
|
+
|
|
248
|
+
## Styling and theming
|
|
249
|
+
|
|
250
|
+
Styles ship in three layers so the library is unstyled scaffolding by default, with themes as an opt-in:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import '@sorlo/forms/styles/layout.css' // scaffolding: layout and structure only
|
|
254
|
+
import '@sorlo/forms/styles/base.css' // the skin: maps --sorlo-* tokens onto class names
|
|
255
|
+
import '@sorlo/forms/styles/themes/default.css' // a theme: defines the tokens
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Each file is also shipped as SCSS, at the same path ending in `.scss`, for projects that compile their own styles. With Sass's `pkg:` importer, give the extension, e.g. `@use 'pkg:@sorlo/forms/styles/base.scss'`, since both files match without it.
|
|
259
|
+
|
|
260
|
+
`layout.css` is structural (flex grids, widths, table layout) and leaves the browser's default styles intact, so an unthemed form looks like a native form that lays out correctly. `base.css` maps every `--sorlo-*` token onto the library's class names, with every rule scoped to an active theme — any class starting with `sorlo-theme-` — so importing it without a theme changes nothing at all; the token contract lives there. Themes define the tokens under their scoping class (`.sorlo-theme-default`, `.sorlo-theme-dark`), which you attach via the `className` prop or any ancestor element:
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
<Form className='sorlo-theme-default' schema={schema} />
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
A custom theme is a copy of `themes/default.css` with a new class name (keeping the `sorlo-theme-` prefix so the skin activates) and different token values — no selectors, just tokens. Because themes are class-scoped, different forms on one page can use different themes, and single tokens can be overridden inline or in state scopes (for example `.sorlo-invalid` remaps `--sorlo-messages-color` to `--sorlo-color-error`).
|
|
267
|
+
|
|
268
|
+
Buttons have two variants beside the plain `sorlo-button`: add `sorlo-button--primary` for a form's or dialog's main action, and `sorlo-button--danger` for one that can't be undone. Their `--sorlo-button-primary-*` and `--sorlo-button-danger-*` tokens set their colours at rest and on hover. `--sorlo-color-accent` colours primary buttons in both built-in themes, and every form's checkboxes, radios and ranges through `accent-color`.
|
|
269
|
+
|
|
270
|
+
## i18n
|
|
271
|
+
|
|
272
|
+
Every string Sorlo Forms shows, validation messages included, comes from its own i18next instance, with the English translations in `src/translations`. It calls that instance directly rather than through react-i18next, so it sets no default instance, provider or defaults there. An app's own i18next and react-i18next setup stays as the app made it, inside forms too, where a custom input's `useTranslation()` gets the app's instance. A field's validation messages can be changed through `options.messages`, as [Validation](#validation) shows.
|
|
273
|
+
|
|
274
|
+
## Contributing
|
|
275
|
+
|
|
276
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) covers the development commands, the conventions and how releases work. Report security issues as [SECURITY.md](SECURITY.md) describes.
|