@krizaka/ui 3.44.0 → 3.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/ui",
3
- "version": "3.44.0",
3
+ "version": "3.46.0",
4
4
  "description": "The Krizaka front-end platform's components: the primitives (button, card, dialog, toast, menu, field, tabs, chip, switch, slider, command palette, theme, countdown…) on the semantic --kz-* tokens, the animated marks (Krizaka, Orazaka, Orochia, Yasakia, Yororis) and the motion signature — web and /native.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -753,7 +753,7 @@
753
753
  ],
754
754
  "peerDependencies": {
755
755
  "@krizaka/query": ">=0.2.1",
756
- "@krizaka/tailwind": ">=3.44.0",
756
+ "@krizaka/tailwind": ">=3.46.0",
757
757
  "@maplibre/maplibre-react-native": ">=11",
758
758
  "@shopify/flash-list": ">=2",
759
759
  "expo-camera": ">=16",
@@ -813,8 +813,8 @@
813
813
  "typescript": "^5.9.0",
814
814
  "vitest": "^4.1.11",
815
815
  "zod": "^4.6.5",
816
- "@krizaka/config": "0.6.0",
817
- "@krizaka/query": "0.2.1"
816
+ "@krizaka/query": "0.2.1",
817
+ "@krizaka/config": "0.7.0"
818
818
  },
819
819
  "engines": {
820
820
  "node": ">=22"
@@ -1281,7 +1281,7 @@
1281
1281
  {
1282
1282
  "name": "./nav-bar (Radix NavigationMenu, Slot, the family's marks)",
1283
1283
  "path": "dist/nav-bar.js",
1284
- "limit": "25.2 kB",
1284
+ "limit": "26.6 kB",
1285
1285
  "gzip": true,
1286
1286
  "ignore": [
1287
1287
  "tailwind-variants",
@@ -2575,7 +2575,7 @@
2575
2575
  "tailwind-variants": "^3.3.1",
2576
2576
  "@krizaka/intl": "^0.2.1",
2577
2577
  "@krizaka/pacer": "^0.1.0",
2578
- "@krizaka/tokens": "^3.44.0"
2578
+ "@krizaka/tokens": "^3.46.0"
2579
2579
  },
2580
2580
  "scripts": {
2581
2581
  "build": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});for(const f of fs.readdirSync('registry/examples',{recursive:true}))if(/\\.(js|d\\.ts)$/.test(f))fs.rmSync('registry/examples/'+f)\" && tsup && node -e \"require('node:fs').copyFileSync('src/motion.css','dist/motion.css');require('node:fs').copyFileSync('src/blocks.css','dist/blocks.css');require('node:fs').copyFileSync('src/story.css','dist/story.css')\" && node scripts/build-registry.mjs",
@@ -141,7 +141,7 @@
141
141
  }
142
142
  ],
143
143
  "dependencies": [
144
- "@krizaka/tokens@^3.44.0"
144
+ "@krizaka/tokens@^3.46.0"
145
145
  ],
146
146
  "registryDependencies": [],
147
147
  "demo": {
@@ -418,13 +418,13 @@
418
418
  },
419
419
  {
420
420
  "component": "FormField",
421
- "description": "A field drawn by the app (a control the package does not have): the form's value, errors and ids, any markup.",
421
+ "description": "A field drawn by the app (a control the package does not have): the form's value and errors, any markup.",
422
422
  "props": [
423
423
  {
424
424
  "name": "children",
425
425
  "type": "(field: KrizakaField<unknown>) => ReactNode",
426
426
  "default": null,
427
- "description": "Draws the field: its value, setter, error and ids (`useKrizakaField`).",
427
+ "description": "Draws the field: its value, setter, error (and the ids, on the web) (`useKrizakaField`).",
428
428
  "required": true
429
429
  },
430
430
  {
@@ -1094,13 +1094,13 @@
1094
1094
  },
1095
1095
  {
1096
1096
  "component": "Watch",
1097
- "description": "Renders from a field's current value (a part shown only when a box is ticked, a total).",
1097
+ "description": "Renders from a field's current value (a part shown only when a choice is made, a total).",
1098
1098
  "props": [
1099
1099
  {
1100
1100
  "name": "children",
1101
1101
  "type": "(value: unknown) => ReactNode",
1102
1102
  "default": null,
1103
- "description": "Draws from its current value: show a part of the form only when a box is ticked.",
1103
+ "description": "Draws from its current value: show a part of the form only when a choice is made.",
1104
1104
  "required": true
1105
1105
  },
1106
1106
  {
@@ -1856,15 +1856,35 @@
1856
1856
  }
1857
1857
  ]
1858
1858
  },
1859
+ {
1860
+ "component": "FieldArray",
1861
+ "description": "A list of fields that grows and shrinks (links, tiers, guests): each item's fields named `links[0].url`.",
1862
+ "props": [
1863
+ {
1864
+ "name": "children",
1865
+ "type": "(array: KrizakaFieldArray<unknown>) => ReactNode",
1866
+ "default": null,
1867
+ "description": "Draws the items: `items`, `push`, `remove`, `move`, `fieldName(i, \"url\")` for the fields of an item.",
1868
+ "required": true
1869
+ },
1870
+ {
1871
+ "name": "name",
1872
+ "type": "string",
1873
+ "default": null,
1874
+ "description": "The array's name in the schema (typed by the form).",
1875
+ "required": true
1876
+ }
1877
+ ]
1878
+ },
1859
1879
  {
1860
1880
  "component": "FormField",
1861
- "description": "A field drawn by the app (a control the package does not have).",
1881
+ "description": "A field drawn by the app (a control the package does not have): the form's value and errors, any markup.",
1862
1882
  "props": [
1863
1883
  {
1864
1884
  "name": "children",
1865
1885
  "type": "(field: KrizakaField<unknown>) => ReactNode",
1866
1886
  "default": null,
1867
- "description": "Draws the field: its value, setter, error (`useKrizakaField`).",
1887
+ "description": "Draws the field: its value, setter, error (and the ids, on the web) (`useKrizakaField`).",
1868
1888
  "required": true
1869
1889
  },
1870
1890
  {
@@ -1890,35 +1910,15 @@
1890
1910
  }
1891
1911
  ]
1892
1912
  },
1893
- {
1894
- "component": "FieldArray",
1895
- "description": "A list of fields that grows and shrinks: each item's fields named `links[0].url`.",
1896
- "props": [
1897
- {
1898
- "name": "children",
1899
- "type": "(array: KrizakaFieldArray<unknown>) => ReactNode",
1900
- "default": null,
1901
- "description": "Draws the items: `items`, `push`, `remove`, `move`, `fieldName(i, \"url\")`.",
1902
- "required": true
1903
- },
1904
- {
1905
- "name": "name",
1906
- "type": "string",
1907
- "default": null,
1908
- "description": "The array's name in the schema (typed by the form).",
1909
- "required": true
1910
- }
1911
- ]
1912
- },
1913
1913
  {
1914
1914
  "component": "Watch",
1915
- "description": "Renders from a field's current value (a part shown only when a choice is made).",
1915
+ "description": "Renders from a field's current value (a part shown only when a choice is made, a total).",
1916
1916
  "props": [
1917
1917
  {
1918
1918
  "name": "children",
1919
1919
  "type": "(value: unknown) => ReactNode",
1920
1920
  "default": null,
1921
- "description": "Draws from its current value.",
1921
+ "description": "Draws from its current value: show a part of the form only when a choice is made.",
1922
1922
  "required": true
1923
1923
  },
1924
1924
  {
@@ -2069,7 +2069,7 @@
2069
2069
  "files": [
2070
2070
  {
2071
2071
  "path": "form/core.tsx",
2072
- "content": "// Platform-neutral React (web and React Native): the engine of a Krizaka form, TanStack Form v1 behind the facade\n// (ADR-001: a product never imports @tanstack/*). A form = a Standard Schema (zod 4) + its default values; the schema\n// validates every change, an error shows once the person leaves the field or tries to send (and goes as soon as it is\n// fixed), a field may check a value on the server (debounced, aborted when the value changes), and a refusal of the\n// server (Problem Details `fields`) lands under its fields. The fields themselves are the platform's (web.tsx, native).\nimport { type AnyFieldApi, type AnyFormApi, type DeepKeys, type StandardSchemaV1, useField, useForm, useStore } from \"@tanstack/react-form\";\nimport * as React from \"react\";\n\nimport { type FormFieldError, type FormI18n, formMessage, type FormProblem, isFormProblem, issuesToFields, problemToErrors, type SchemaIssue } from \"./errors\";\n\n/** A schema the form validates with: any Standard Schema — zod 4 (`z.object`), valibot, arktype. */\nexport type FormSchema<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output>;\n/** The values a form holds: the schema's input. */\nexport type FormValues<S> = S extends { readonly \"~standard\": { readonly types?: { readonly input: infer I } } } ? I : never;\n/** The values a form sends: the schema's output (zod transforms applied). */\nexport type FormOutput<S> = S extends { readonly \"~standard\": { readonly types?: { readonly output: infer O } } } ? O : never;\n/** A field's name: `email`, `address.city`, `links[0].url`. */\nexport type FieldName<V> = DeepKeys<V> & string;\n\n/** What a Server Action (or any submit) answers: done, or a refusal the form shows. */\nexport type FormActionResult<T = unknown> = { ok: true; data?: T } | { ok: false; problem: FormProblem };\n\n/** A value checked on the server as it is typed: an error (or its key), or nothing when the value is fine. */\nexport type FieldCheck<T = unknown> = (value: T, signal: AbortSignal) => Promise<FormFieldError | string | null | undefined> | FormFieldError | string | null | undefined;\n\nexport interface KrizakaFormDefinition<S extends FormSchema> {\n /** The schema of the values (zod 4): the same one the server parses with. */\n schema: S;\n /** The values at first: every field of the schema. */\n defaultValues: FormValues<S>;\n /** The words: the app's translate function, the key prefixes, the locale. */\n i18n?: FormI18n;\n}\n\nexport interface UseKrizakaFormOptions<S extends FormSchema, R = unknown> {\n /** Sends the values (the schema's output): a mutation of @krizaka/query, a Server Action. Throw a `KrizakaApiError` or\n * return `{ ok: false, problem }` to refuse: its `fields` go under their fields, the rest above the button. */\n onSubmit?: (values: FormOutput<S>) => R | Promise<R>;\n /** Called after a successful submit with what `onSubmit` returned (`data` of a `{ ok: true, data }`). */\n onSuccess?: (result: R extends FormActionResult<infer T> ? T | undefined : R) => void;\n /** Values that replace the definition's (a profile loaded from the server). */\n defaultValues?: FormValues<S>;\n /** Overrides the definition's words (the translate function of a hook). */\n i18n?: FormI18n;\n /** The server's codes that belong to a field (`{ UNDERAGE: \"dateOfBirth\", USERNAME_RESERVED: \"username\" }`): a refusal\n * with that code shows under the field, as `form.errors.<CODE>`, instead of above the button. */\n codeFields?: Readonly<Record<string, string>>;\n}\n\n/** The state of a form, for its fields and its button. */\nexport interface FormContextValue {\n /** TanStack's form, through the facade: never import @tanstack/* in a product. */\n readonly api: AnyFormApi;\n readonly i18n: FormI18n;\n /** The whole form's error (a refusal without a field), or null. */\n readonly error: FormFieldError | null;\n /** Sends the form: validates, focuses (web) or announces (native) the first error, else calls `onSubmit`. */\n readonly submit: () => Promise<boolean>;\n}\n\nexport const FormContext = React.createContext<FormContextValue | null>(null);\n\n/** The form around a field: inside `form.Form`. */\nexport function useFormContext(): FormContextValue {\n const ctx = React.useContext(FormContext);\n if (!ctx) throw new Error(\"A Krizaka form field must be inside its form's `form.Form`.\");\n return ctx;\n}\n\ntype StandardResult = { readonly value?: unknown; readonly issues?: readonly SchemaIssue[] };\n\nconst run = (schema: FormSchema, value: unknown) => schema[\"~standard\"].validate(value) as StandardResult | Promise<StandardResult>;\n\n/** The engine of a form: TanStack's form wired to the schema, the server's refusals and the submit. */\nexport function useKrizakaFormCore<S extends FormSchema, R>(definition: KrizakaFormDefinition<S>, options: UseKrizakaFormOptions<S, R> = {}): FormContextValue {\n const i18n = { ...definition.i18n, ...options.i18n };\n const latest = React.useRef({ options, i18n });\n // The latest options for the engine's callbacks (a submit, a change), which outlive the render that made them.\n React.useLayoutEffect(() => {\n latest.current = { options, i18n };\n });\n const [error, setError] = React.useState<FormFieldError | null>(null);\n const refused = React.useRef(false);\n\n const api = useForm({\n defaultValues: options.defaultValues ?? definition.defaultValues,\n validators: {\n // Every change: the schema, synchronously (an async refinement waits for the submit). Errors are computed for\n // every field; each field shows its own once the person left it or tried to send.\n onChange: ({ value }: { value: unknown }) => {\n const result = run(definition.schema, value);\n if (result instanceof Promise || !result.issues?.length) return undefined;\n const fields = issuesToFields(result.issues, latest.current.i18n.prefix);\n delete fields[\"\"];\n return { fields };\n },\n },\n onSubmit: async ({ value }: { value: unknown }) => {\n const parsed = await run(definition.schema, value);\n if (parsed.issues?.length) {\n apply({ fields: issuesToFields(parsed.issues, latest.current.i18n.prefix) as FormProblem[\"fields\"] });\n return;\n }\n let result: unknown;\n try {\n result = await latest.current.options.onSubmit?.(parsed.value as FormOutput<S>);\n } catch (thrown) {\n apply(isFormProblem(thrown) ? thrown : { code: \"server\" });\n return;\n }\n if (result && typeof result === \"object\" && \"ok\" in result && (result as FormActionResult).ok === false) {\n apply((result as unknown as { problem: FormProblem }).problem);\n return;\n }\n const data = result && typeof result === \"object\" && \"ok\" in result ? (result as { data?: unknown }).data : result;\n latest.current.options.onSuccess?.(data as never);\n },\n });\n\n const known = (name: string) => name in api.fieldInfo;\n function apply(problem: FormProblem) {\n refused.current = true;\n const { fields, form } = problemToErrors(problem, latest.current.i18n, known, latest.current.options.codeFields);\n for (const [name, fieldError] of Object.entries(fields)) {\n api.setFieldMeta(name as never, (meta) => ({ ...meta, isBlurred: true, errorMap: { ...meta.errorMap, onServer: fieldError } }));\n }\n setError(form);\n }\n\n const submit = React.useCallback(async () => {\n setError(null);\n refused.current = false;\n for (const name of Object.keys(api.fieldInfo)) {\n if ((api.getFieldMeta(name as never) as { errorMap?: Record<string, unknown> } | undefined)?.errorMap?.onServer) api.setFieldMeta(name as never, (meta) => ({ ...meta, errorMap: { ...meta.errorMap, onServer: undefined } }));\n }\n await api.handleSubmit();\n const state = api.state;\n return !refused.current && state.isSubmitSuccessful && state.isValid && !(Object.values(state.fieldMeta) as { errorMap?: Record<string, unknown> }[]).some((meta) => meta?.errorMap?.onServer);\n }, [api]);\n\n return React.useMemo(() => ({ api, i18n, error, submit }), [api, error, submit, i18n.t, i18n.prefix, i18n.apiPrefix, i18n.locale]); // eslint-disable-line react-hooks/exhaustive-deps\n}\n\nconst UNCHECKED = Symbol(\"unchecked\");\n\nexport interface FieldOptions<T> {\n /** Checks the value on the server as it is typed (a username's availability): debounced, aborted when it changes. */\n validateAsync?: FieldCheck<T>;\n /** The wait, in ms, between the last change and `validateAsync`. Default 350. */\n asyncDebounceMs?: number;\n}\n\n/** A field as its component draws it: the value, its setter, its error in words, its state. */\nexport interface KrizakaField<T = unknown> {\n /** The id of the control (`htmlFor` of its label). */\n readonly id: string;\n readonly name: string;\n readonly value: T;\n /** Sets the value (clears a refusal of the server on it). */\n setValue(value: T): void;\n /** Call when the control loses focus: from then on, its errors show. */\n readonly onBlur: () => void;\n /** The error to show, in words — only once it should show. */\n readonly error: string | undefined;\n /** The error itself, with its suggestion. */\n readonly errorDetail: FormFieldError | undefined;\n /** A check (`validateAsync`) is running. */\n readonly validating: boolean;\n /** The check passed for the current value. */\n readonly available: boolean;\n /** The ids of the hint and the error (`aria-describedby`). */\n readonly hintId: string;\n readonly errorId: string;\n /** The form's locale (numbers, money, dates). */\n readonly locale: string;\n /** TanStack's field, through the facade. */\n readonly api: AnyFieldApi;\n}\n\n/** Binds a field of the form around it (inside `form.Form`): what the platform's field components are made of. */\nexport function useKrizakaField<T = unknown>(name: string, options: FieldOptions<T> = {}): KrizakaField<T> {\n const ctx = useFormContext();\n const id = React.useId();\n const [checked, setChecked] = React.useState<unknown>(UNCHECKED);\n const check = options.validateAsync;\n const field = useField({\n form: ctx.api,\n name: name as never,\n ...(check\n ? {\n asyncDebounceMs: options.asyncDebounceMs ?? 350,\n validators: {\n onChangeAsyncDebounceMs: options.asyncDebounceMs ?? 350,\n onChangeAsync: async ({ value, signal }: { value: T; signal: AbortSignal }) => {\n const answer = await check(value, signal);\n if (signal.aborted) return undefined;\n if (!answer) {\n setChecked(value);\n return undefined;\n }\n setChecked(UNCHECKED);\n const error = typeof answer === \"string\" ? { key: answer } : answer;\n return { ...error, immediate: true, source: \"check\" } satisfies FormFieldError;\n },\n },\n }\n : {}),\n } as never) as unknown as AnyFieldApi;\n const attempts = useStore(ctx.api.store, (state: { submissionAttempts: number }) => state.submissionAttempts);\n const meta = field.state.meta;\n const map = meta.errorMap as Record<string, unknown>;\n const all = [map.onServer, map.onChange, map.onBlur, map.onSubmit, map.onMount].filter(Boolean) as (FormFieldError | string)[];\n const first = all[0];\n const detail = typeof first === \"string\" ? { key: first } : first;\n const visible = detail && (detail.immediate || detail.source === \"server\" || meta.isBlurred || attempts > 0) ? detail : undefined;\n const value = field.state.value as T;\n\n return {\n id,\n name,\n value,\n setValue: (next: T) => {\n if (map.onServer) ctx.api.setFieldMeta(name as never, (m: { errorMap: Record<string, unknown> }) => ({ ...m, errorMap: { ...m.errorMap, onServer: undefined } }) as never);\n field.handleChange(next as never);\n },\n onBlur: () => field.handleBlur(),\n error: visible ? formMessage(visible, ctx.i18n) : undefined,\n errorDetail: visible,\n validating: Boolean(meta.isValidating),\n available: Boolean(check) && checked !== UNCHECKED && Object.is(checked, value) && !meta.isValidating && all.length === 0,\n hintId: `${id}-hint`,\n errorId: `${id}-error`,\n locale: ctx.i18n.locale ?? \"en\",\n api: field,\n };\n}\n\n/** The array of a field array, with its operations (`form.FieldArray`). */\nexport interface KrizakaFieldArray<T = unknown> {\n readonly name: string;\n readonly items: readonly T[];\n /** Adds an item at the end. */\n readonly push: (item: T) => void;\n /** Removes the item at `index`. */\n readonly remove: (index: number) => void;\n /** Moves an item. */\n readonly move: (from: number, to: number) => void;\n /** The name of a sub-field of an item: `name(0, \"url\")` → `links[0].url`. */\n readonly fieldName: (index: number, key?: string) => string;\n /** The error of the array itself (too few, too many), in words, once it should show. */\n readonly error: string | undefined;\n}\n\n/** Binds an array of the form (a list of links, of tiers): its items and their operations. */\nexport function useKrizakaFieldArray<T = unknown>(name: string): KrizakaFieldArray<T> {\n const ctx = useFormContext();\n const field = useField({ form: ctx.api, name: name as never, mode: \"array\" } as never) as unknown as AnyFieldApi;\n const attempts = useStore(ctx.api.store, (state: { submissionAttempts: number }) => state.submissionAttempts);\n const map = field.state.meta.errorMap as Record<string, FormFieldError | undefined>;\n const first = map.onServer ?? map.onChange ?? map.onSubmit;\n return {\n name,\n items: (field.state.value as T[] | undefined) ?? [],\n push: (item) => field.pushValue(item as never),\n remove: (index) => void field.removeValue(index),\n move: (from, to) => field.moveValue(from, to),\n fieldName: (index, key) => `${name}[${index}]${key ? `.${key}` : \"\"}`,\n error: first && (attempts > 0 || first.source === \"server\" || field.state.meta.isDirty) ? formMessage(first, ctx.i18n) : undefined,\n };\n}\n\n/** The value of a field, kept up to date (`form.Watch`): show a part of the form only when a box is ticked. */\nexport function useFormValue<T = unknown>(name: string): T {\n const ctx = useFormContext();\n return useStore(ctx.api.store, (state: { values: unknown }) => getPath(state.values, name)) as T;\n}\n\n/** The state of the submit, for a button: sending, and whether the last try succeeded. */\nexport function useFormStatus(): { submitting: boolean; succeeded: boolean; attempts: number } {\n const ctx = useFormContext();\n const submitting = useStore(ctx.api.store, (s: { isSubmitting: boolean }) => s.isSubmitting);\n const succeeded = useStore(ctx.api.store, (s: { isSubmitSuccessful: boolean }) => s.isSubmitSuccessful);\n const attempts = useStore(ctx.api.store, (s: { submissionAttempts: number }) => s.submissionAttempts);\n return { submitting, succeeded: succeeded && !ctx.error, attempts };\n}\n\nfunction getPath(source: unknown, path: string): unknown {\n let current = source;\n for (const part of path.replace(/\\[(\\d+)\\]/g, \".$1\").split(\".\")) {\n if (current === null || typeof current !== \"object\") return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return current;\n}\n\n/** The first visible error of a form, in words (native: announced after a refused submit). */\nexport function firstVisibleError(ctx: FormContextValue): string | undefined {\n if (ctx.error) return formMessage(ctx.error, ctx.i18n);\n for (const meta of Object.values(ctx.api.state.fieldMeta) as { errorMap?: Record<string, FormFieldError | string | undefined> }[]) {\n const error = meta?.errorMap && Object.values(meta.errorMap).find(Boolean);\n if (error) return formMessage(error, ctx.i18n);\n }\n return undefined;\n}\n\n/** The platform's components, typed by the form's field names: `name` completes and is checked against the schema. */\nexport type BoundFields<C, N extends string> = {\n [K in keyof C]: C[K] extends (props: infer P) => infer R ? (P extends { name: string } ? (props: Omit<P, \"name\"> & { name: N }) => R : C[K]) : C[K];\n};\n\n/** A form in a component: its fields (typed by the schema), its submit and its state. */\nexport type KrizakaFormInstance<S extends FormSchema, C, RootProps> = BoundFields<C, FieldName<FormValues<S>>> & {\n /** The form element (web) or view (native): renders its fields and provides them the form. */\n Form: (props: RootProps) => React.ReactElement;\n /** Sends the form (what `Submit` does): `true` when it went through. */\n submit: () => Promise<boolean>;\n /** Back to the default values (or the given ones), errors cleared. */\n reset: (values?: FormValues<S>) => void;\n /** Sets a field's value from outside (a suggestion, a preset). */\n setValue: (name: FieldName<FormValues<S>>, value: unknown) => void;\n /** The values now. */\n getValues: () => FormValues<S>;\n /** The whole form's error (a refusal without a field), or null. */\n error: FormFieldError | null;\n /** TanStack's form, the escape hatch — never import @tanstack/* in a product. */\n api: AnyFormApi;\n};\n\n/** A form defined once (schema + defaults), used in components: `signUp.useForm({ onSubmit })`. */\nexport interface KrizakaFormFactory<S extends FormSchema, C, RootProps> {\n readonly schema: S;\n readonly defaultValues: FormValues<S>;\n /** The form in a component: `onSubmit` sends the values (a mutation), a thrown `KrizakaApiError` lands on the fields. */\n useForm<R = unknown>(options?: UseKrizakaFormOptions<S, R>): KrizakaFormInstance<S, C, RootProps>;\n /** The form sent by a Server Action: it receives the values and answers `{ ok: true, data }` or `{ ok: false, problem }`. */\n useFormWithAction<T = unknown>(action: (values: FormOutput<S>) => Promise<FormActionResult<T>>, options?: Omit<UseKrizakaFormOptions<S, FormActionResult<T>>, \"onSubmit\">): KrizakaFormInstance<S, C, RootProps>;\n}\n\n/** Builds a platform's `createKrizakaForm` from its field components and its root (web `<form>`, native `View`). */\nexport function makeCreateKrizakaForm<C extends object, RootProps extends object>(components: C, Root: (props: RootProps & { value: FormContextValue }) => React.ReactElement) {\n return function createKrizakaForm<S extends FormSchema>(definition: KrizakaFormDefinition<S>): KrizakaFormFactory<S, C, RootProps> {\n function useForm<R>(options?: UseKrizakaFormOptions<S, R>): KrizakaFormInstance<S, C, RootProps> {\n const ctx = useKrizakaFormCore(definition, options);\n // One `Form` for the life of the form (never remounted, so focus and fields stay): it reads the latest state from a\n // small store the hook updates.\n const [store] = React.useState(() => createStore(ctx));\n React.useLayoutEffect(() => store.set(ctx), [store, ctx]);\n const Form = React.useCallback((props: RootProps) => <Bound store={store} Root={Root} props={props} />, [store]);\n return React.useMemo(\n () =>\n ({\n ...components,\n Form,\n submit: ctx.submit,\n reset: (values?: FormValues<S>) => ctx.api.reset(values),\n setValue: (name: string, value: unknown) => ctx.api.setFieldValue(name as never, value as never),\n getValues: () => ctx.api.state.values as FormValues<S>,\n error: ctx.error,\n api: ctx.api,\n }) as unknown as KrizakaFormInstance<S, C, RootProps>,\n [ctx, Form],\n );\n }\n return {\n schema: definition.schema,\n defaultValues: definition.defaultValues,\n useForm,\n useFormWithAction: (action, options) => useForm({ ...options, onSubmit: action }),\n };\n };\n}\n\ntype Store<T> = { get: () => T; set: (value: T) => void; subscribe: (listener: () => void) => () => void };\n\nfunction createStore<T>(initial: T): Store<T> {\n let value = initial;\n const listeners = new Set<() => void>();\n return {\n get: () => value,\n set: (next) => {\n if (Object.is(next, value)) return;\n value = next;\n listeners.forEach((listener) => listener());\n },\n subscribe: (listener) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n };\n}\n\nfunction Bound<RootProps extends object>({ store, Root, props }: { store: Store<FormContextValue>; Root: (props: RootProps & { value: FormContextValue }) => React.ReactElement; props: RootProps }) {\n const value = React.useSyncExternalStore(store.subscribe, store.get, store.get);\n return <Root {...props} value={value} />;\n}\n"
2072
+ "content": "// Platform-neutral React (web and React Native): the engine of a Krizaka form, TanStack Form v1 behind the facade\n// (ADR-001: a product never imports @tanstack/*). A form = a Standard Schema (zod 4) + its default values; the schema\n// validates every change, an error shows once the person leaves the field or tries to send (and goes as soon as it is\n// fixed), a field may check a value on the server (debounced, aborted when the value changes), and a refusal of the\n// server (Problem Details `fields`) lands under its fields. The fields themselves are the platform's (web.tsx, native).\nimport { type AnyFieldApi, type AnyFormApi, type DeepKeys, type StandardSchemaV1, useField, useForm, useStore } from \"@tanstack/react-form\";\nimport * as React from \"react\";\n\nimport { type FormFieldError, type FormI18n, formMessage, type FormProblem, isFormProblem, issuesToFields, problemToErrors, type SchemaIssue } from \"./errors\";\n\n/** A schema the form validates with: any Standard Schema — zod 4 (`z.object`), valibot, arktype. */\nexport type FormSchema<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output>;\n/** The values a form holds: the schema's input. */\nexport type FormValues<S> = S extends { readonly \"~standard\": { readonly types?: { readonly input: infer I } } } ? I : never;\n/** The values a form sends: the schema's output (zod transforms applied). */\nexport type FormOutput<S> = S extends { readonly \"~standard\": { readonly types?: { readonly output: infer O } } } ? O : never;\n/** A field's name: `email`, `address.city`, `links[0].url`. */\nexport type FieldName<V> = DeepKeys<V> & string;\n\n/** What a Server Action (or any submit) answers: done, or a refusal the form shows. */\nexport type FormActionResult<T = unknown> = { ok: true; data?: T } | { ok: false; problem: FormProblem };\n\n/** A value checked on the server as it is typed: an error (or its key), or nothing when the value is fine. */\nexport type FieldCheck<T = unknown> = (value: T, signal: AbortSignal) => Promise<FormFieldError | string | null | undefined> | FormFieldError | string | null | undefined;\n\nexport interface KrizakaFormDefinition<S extends FormSchema> {\n /** The schema of the values (zod 4): the same one the server parses with. */\n schema: S;\n /** The values at first: every field of the schema. */\n defaultValues: FormValues<S>;\n /** The words: the app's translate function, the key prefixes, the locale. */\n i18n?: FormI18n;\n}\n\nexport interface UseKrizakaFormOptions<S extends FormSchema, R = unknown> {\n /** Sends the values (the schema's output): a mutation of @krizaka/query, a Server Action. Throw a `KrizakaApiError` or\n * return `{ ok: false, problem }` to refuse: its `fields` go under their fields, the rest above the button. */\n onSubmit?: (values: FormOutput<S>) => R | Promise<R>;\n /** Called after a successful submit with what `onSubmit` returned (`data` of a `{ ok: true, data }`). */\n onSuccess?: (result: R extends FormActionResult<infer T> ? T | undefined : R) => void;\n /** Values that replace the definition's (a profile loaded from the server). */\n defaultValues?: FormValues<S>;\n /** Overrides the definition's words (the translate function of a hook). */\n i18n?: FormI18n;\n /** The server's codes that belong to a field (`{ UNDERAGE: \"dateOfBirth\", USERNAME_RESERVED: \"username\" }`): a refusal\n * with that code shows under the field, as `form.errors.<CODE>`, instead of above the button. */\n codeFields?: Readonly<Record<string, string>>;\n}\n\n/** The state of a form, for its fields and its button. */\nexport interface FormContextValue {\n /** TanStack's form, through the facade: never import @tanstack/* in a product. */\n readonly api: AnyFormApi;\n readonly i18n: FormI18n;\n /** The whole form's error (a refusal without a field), or null. */\n readonly error: FormFieldError | null;\n /** Sends the form: validates, focuses (web) or announces (native) the first error, else calls `onSubmit`. */\n readonly submit: () => Promise<boolean>;\n}\n\nexport const FormContext = React.createContext<FormContextValue | null>(null);\n\n/** The form around a field: inside `form.Form`. */\nexport function useFormContext(): FormContextValue {\n const ctx = React.useContext(FormContext);\n if (!ctx) throw new Error(\"A Krizaka form field must be inside its form's `form.Form`.\");\n return ctx;\n}\n\ntype StandardResult = { readonly value?: unknown; readonly issues?: readonly SchemaIssue[] };\n\nconst run = (schema: FormSchema, value: unknown) => schema[\"~standard\"].validate(value) as StandardResult | Promise<StandardResult>;\n\n/** The engine of a form: TanStack's form wired to the schema, the server's refusals and the submit. */\nexport function useKrizakaFormCore<S extends FormSchema, R>(definition: KrizakaFormDefinition<S>, options: UseKrizakaFormOptions<S, R> = {}): FormContextValue {\n const i18n = { ...definition.i18n, ...options.i18n };\n const latest = React.useRef({ options, i18n });\n // The latest options for the engine's callbacks (a submit, a change), which outlive the render that made them.\n React.useLayoutEffect(() => {\n latest.current = { options, i18n };\n });\n const [error, setError] = React.useState<FormFieldError | null>(null);\n const refused = React.useRef(false);\n\n const api = useForm({\n defaultValues: options.defaultValues ?? definition.defaultValues,\n validators: {\n // Every change: the schema, synchronously (an async refinement waits for the submit). Errors are computed for\n // every field; each field shows its own once the person left it or tried to send.\n onChange: ({ value }: { value: unknown }) => {\n const result = run(definition.schema, value);\n if (result instanceof Promise || !result.issues?.length) return undefined;\n const fields = issuesToFields(result.issues, latest.current.i18n.prefix);\n delete fields[\"\"];\n return { fields };\n },\n },\n onSubmit: async ({ value }: { value: unknown }) => {\n const parsed = await run(definition.schema, value);\n if (parsed.issues?.length) {\n apply({ fields: issuesToFields(parsed.issues, latest.current.i18n.prefix) as FormProblem[\"fields\"] });\n return;\n }\n let result: unknown;\n try {\n result = await latest.current.options.onSubmit?.(parsed.value as FormOutput<S>);\n } catch (thrown) {\n apply(isFormProblem(thrown) ? thrown : { code: \"server\" });\n return;\n }\n if (result && typeof result === \"object\" && \"ok\" in result && (result as FormActionResult).ok === false) {\n apply((result as unknown as { problem: FormProblem }).problem);\n return;\n }\n const data = result && typeof result === \"object\" && \"ok\" in result ? (result as { data?: unknown }).data : result;\n latest.current.options.onSuccess?.(data as never);\n },\n });\n\n const known = (name: string) => name in api.fieldInfo;\n function apply(problem: FormProblem) {\n refused.current = true;\n const { fields, form } = problemToErrors(problem, latest.current.i18n, known, latest.current.options.codeFields);\n for (const [name, fieldError] of Object.entries(fields)) {\n api.setFieldMeta(name as never, (meta) => ({ ...meta, isBlurred: true, errorMap: { ...meta.errorMap, onServer: fieldError } }));\n }\n setError(form);\n }\n\n const submit = React.useCallback(async () => {\n setError(null);\n refused.current = false;\n for (const name of Object.keys(api.fieldInfo)) {\n if ((api.getFieldMeta(name as never) as { errorMap?: Record<string, unknown> } | undefined)?.errorMap?.onServer) api.setFieldMeta(name as never, (meta) => ({ ...meta, errorMap: { ...meta.errorMap, onServer: undefined } }));\n }\n await api.handleSubmit();\n const state = api.state;\n return !refused.current && state.isSubmitSuccessful && state.isValid && !(Object.values(state.fieldMeta) as { errorMap?: Record<string, unknown> }[]).some((meta) => meta?.errorMap?.onServer);\n }, [api]);\n\n return React.useMemo(() => ({ api, i18n, error, submit }), [api, error, submit, i18n.t, i18n.prefix, i18n.apiPrefix, i18n.locale]); // eslint-disable-line react-hooks/exhaustive-deps\n}\n\nconst UNCHECKED = Symbol(\"unchecked\");\n\nexport interface FieldOptions<T> {\n /** Checks the value on the server as it is typed (a username's availability): debounced, aborted when it changes. */\n validateAsync?: FieldCheck<T>;\n /** The wait, in ms, between the last change and `validateAsync`. Default 350. */\n asyncDebounceMs?: number;\n}\n\n/** A field as its component draws it: the value, its setter, its error in words, its state. */\nexport interface KrizakaField<T = unknown> {\n /** The id of the control (`htmlFor` of its label). */\n readonly id: string;\n readonly name: string;\n readonly value: T;\n /** Sets the value (clears a refusal of the server on it). */\n setValue(value: T): void;\n /** Call when the control loses focus: from then on, its errors show. */\n readonly onBlur: () => void;\n /** The error to show, in words — only once it should show. */\n readonly error: string | undefined;\n /** The error itself, with its suggestion. */\n readonly errorDetail: FormFieldError | undefined;\n /** A check (`validateAsync`) is running. */\n readonly validating: boolean;\n /** The check passed for the current value. */\n readonly available: boolean;\n /** The ids of the hint and the error (`aria-describedby`). */\n readonly hintId: string;\n readonly errorId: string;\n /** The form's locale (numbers, money, dates). */\n readonly locale: string;\n /** TanStack's field, through the facade. */\n readonly api: AnyFieldApi;\n}\n\n/** Binds a field of the form around it (inside `form.Form`): what the platform's field components are made of. */\nexport function useKrizakaField<T = unknown>(name: string, options: FieldOptions<T> = {}): KrizakaField<T> {\n const ctx = useFormContext();\n const id = React.useId();\n const [checked, setChecked] = React.useState<unknown>(UNCHECKED);\n const check = options.validateAsync;\n const field = useField({\n form: ctx.api,\n name: name as never,\n ...(check\n ? {\n asyncDebounceMs: options.asyncDebounceMs ?? 350,\n validators: {\n onChangeAsyncDebounceMs: options.asyncDebounceMs ?? 350,\n onChangeAsync: async ({ value, signal }: { value: T; signal: AbortSignal }) => {\n const answer = await check(value, signal);\n if (signal.aborted) return undefined;\n if (!answer) {\n setChecked(value);\n return undefined;\n }\n setChecked(UNCHECKED);\n const error = typeof answer === \"string\" ? { key: answer } : answer;\n return { ...error, immediate: true, source: \"check\" } satisfies FormFieldError;\n },\n },\n }\n : {}),\n } as never) as unknown as AnyFieldApi;\n const attempts = useStore(ctx.api.store, (state: { submissionAttempts: number }) => state.submissionAttempts);\n const meta = field.state.meta;\n const map = meta.errorMap as Record<string, unknown>;\n const all = [map.onServer, map.onChange, map.onBlur, map.onSubmit, map.onMount].filter(Boolean) as (FormFieldError | string)[];\n const first = all[0];\n const detail = typeof first === \"string\" ? { key: first } : first;\n const visible = detail && (detail.immediate || detail.source === \"server\" || meta.isBlurred || attempts > 0) ? detail : undefined;\n const value = field.state.value as T;\n\n return {\n id,\n name,\n value,\n setValue: (next: T) => {\n if (map.onServer) ctx.api.setFieldMeta(name as never, (m: { errorMap: Record<string, unknown> }) => ({ ...m, errorMap: { ...m.errorMap, onServer: undefined } }) as never);\n field.handleChange(next as never);\n },\n onBlur: () => field.handleBlur(),\n error: visible ? formMessage(visible, ctx.i18n) : undefined,\n errorDetail: visible,\n validating: Boolean(meta.isValidating),\n available: Boolean(check) && checked !== UNCHECKED && Object.is(checked, value) && !meta.isValidating && all.length === 0,\n hintId: `${id}-hint`,\n errorId: `${id}-error`,\n locale: ctx.i18n.locale ?? \"en\",\n api: field,\n };\n}\n\n/** The array of a field array, with its operations (`form.FieldArray`). */\nexport interface KrizakaFieldArray<T = unknown> {\n readonly name: string;\n readonly items: readonly T[];\n /** Adds an item at the end. */\n readonly push: (item: T) => void;\n /** Removes the item at `index`. */\n readonly remove: (index: number) => void;\n /** Moves an item. */\n readonly move: (from: number, to: number) => void;\n /** The name of a sub-field of an item: `name(0, \"url\")` → `links[0].url`. */\n readonly fieldName: (index: number, key?: string) => string;\n /** The error of the array itself (too few, too many), in words, once it should show. */\n readonly error: string | undefined;\n}\n\n/** Binds an array of the form (a list of links, of tiers): its items and their operations. */\nexport function useKrizakaFieldArray<T = unknown>(name: string): KrizakaFieldArray<T> {\n const ctx = useFormContext();\n const field = useField({ form: ctx.api, name: name as never, mode: \"array\" } as never) as unknown as AnyFieldApi;\n const attempts = useStore(ctx.api.store, (state: { submissionAttempts: number }) => state.submissionAttempts);\n const map = field.state.meta.errorMap as Record<string, FormFieldError | undefined>;\n const first = map.onServer ?? map.onChange ?? map.onSubmit;\n return {\n name,\n items: (field.state.value as T[] | undefined) ?? [],\n push: (item) => field.pushValue(item as never),\n remove: (index) => void field.removeValue(index),\n move: (from, to) => field.moveValue(from, to),\n fieldName: (index, key) => `${name}[${index}]${key ? `.${key}` : \"\"}`,\n error: first && (attempts > 0 || first.source === \"server\" || field.state.meta.isDirty) ? formMessage(first, ctx.i18n) : undefined,\n };\n}\n\n/** The value of a field, kept up to date (`form.Watch`): show a part of the form only when a box is ticked. */\nexport function useFormValue<T = unknown>(name: string): T {\n const ctx = useFormContext();\n return useStore(ctx.api.store, (state: { values: unknown }) => getPath(state.values, name)) as T;\n}\n\nexport type FormFieldProps<T = unknown> = {\n /** The field's name in the schema (typed by the form). */\n name: string;\n /** Checks the value on the server as it changes. */\n validateAsync?: FieldCheck<T>;\n /** The wait, in ms, before `validateAsync`. */\n asyncDebounceMs?: number;\n /** Draws the field: its value, setter, error (and the ids, on the web) (`useKrizakaField`). */\n children: (field: KrizakaField<T>) => React.ReactNode;\n};\n\n/** A field drawn by the app (a control the package does not have): the form's value and errors, any markup. */\nexport function FormField({ name, validateAsync, asyncDebounceMs, children }: FormFieldProps) {\n return <>{children(useKrizakaField(name, { validateAsync, asyncDebounceMs }))}</>;\n}\n\nexport type FieldArrayProps<T = unknown> = {\n /** The array's name in the schema (typed by the form). */\n name: string;\n /** Draws the items: `items`, `push`, `remove`, `move`, `fieldName(i, \"url\")` for the fields of an item. */\n children: (array: KrizakaFieldArray<T>) => React.ReactNode;\n};\n\n/** A list of fields that grows and shrinks (links, tiers, guests): each item's fields named `links[0].url`. */\nexport function FieldArray({ name, children }: FieldArrayProps) {\n return <>{children(useKrizakaFieldArray(name))}</>;\n}\n\nexport type WatchProps<T = unknown> = {\n /** The field to follow (typed by the form). */\n name: string;\n /** Draws from its current value: show a part of the form only when a choice is made. */\n children: (value: T) => React.ReactNode;\n};\n\n/** Renders from a field's current value (a part shown only when a choice is made, a total). */\nexport function Watch({ name, children }: WatchProps) {\n return <>{children(useFormValue(name))}</>;\n}\n\n/** The state of the submit, for a button: sending, and whether the last try succeeded. */\nexport function useFormStatus(): { submitting: boolean; succeeded: boolean; attempts: number } {\n const ctx = useFormContext();\n const submitting = useStore(ctx.api.store, (s: { isSubmitting: boolean }) => s.isSubmitting);\n const succeeded = useStore(ctx.api.store, (s: { isSubmitSuccessful: boolean }) => s.isSubmitSuccessful);\n const attempts = useStore(ctx.api.store, (s: { submissionAttempts: number }) => s.submissionAttempts);\n return { submitting, succeeded: succeeded && !ctx.error, attempts };\n}\n\nfunction getPath(source: unknown, path: string): unknown {\n let current = source;\n for (const part of path.replace(/\\[(\\d+)\\]/g, \".$1\").split(\".\")) {\n if (current === null || typeof current !== \"object\") return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return current;\n}\n\n/** The first visible error of a form, in words (native: announced after a refused submit). */\nexport function firstVisibleError(ctx: FormContextValue): string | undefined {\n if (ctx.error) return formMessage(ctx.error, ctx.i18n);\n for (const meta of Object.values(ctx.api.state.fieldMeta) as { errorMap?: Record<string, FormFieldError | string | undefined> }[]) {\n const error = meta?.errorMap && Object.values(meta.errorMap).find(Boolean);\n if (error) return formMessage(error, ctx.i18n);\n }\n return undefined;\n}\n\n/** The platform's components, typed by the form's field names: `name` completes and is checked against the schema. */\nexport type BoundFields<C, N extends string> = {\n [K in keyof C]: C[K] extends (props: infer P) => infer R ? (P extends { name: string } ? (props: Omit<P, \"name\"> & { name: N }) => R : C[K]) : C[K];\n};\n\n/** A form in a component: its fields (typed by the schema), its submit and its state. */\nexport type KrizakaFormInstance<S extends FormSchema, C, RootProps> = BoundFields<C, FieldName<FormValues<S>>> & {\n /** The form element (web) or view (native): renders its fields and provides them the form. */\n Form: (props: RootProps) => React.ReactElement;\n /** Sends the form (what `Submit` does): `true` when it went through. */\n submit: () => Promise<boolean>;\n /** Back to the default values (or the given ones), errors cleared. */\n reset: (values?: FormValues<S>) => void;\n /** Sets a field's value from outside (a suggestion, a preset). */\n setValue: (name: FieldName<FormValues<S>>, value: unknown) => void;\n /** The values now. */\n getValues: () => FormValues<S>;\n /** The whole form's error (a refusal without a field), or null. */\n error: FormFieldError | null;\n /** TanStack's form, the escape hatch — never import @tanstack/* in a product. */\n api: AnyFormApi;\n};\n\n/** A form defined once (schema + defaults), used in components: `signUp.useForm({ onSubmit })`. */\nexport interface KrizakaFormFactory<S extends FormSchema, C, RootProps> {\n readonly schema: S;\n readonly defaultValues: FormValues<S>;\n /** The form in a component: `onSubmit` sends the values (a mutation), a thrown `KrizakaApiError` lands on the fields. */\n useForm<R = unknown>(options?: UseKrizakaFormOptions<S, R>): KrizakaFormInstance<S, C, RootProps>;\n /** The form sent by a Server Action: it receives the values and answers `{ ok: true, data }` or `{ ok: false, problem }`. */\n useFormWithAction<T = unknown>(action: (values: FormOutput<S>) => Promise<FormActionResult<T>>, options?: Omit<UseKrizakaFormOptions<S, FormActionResult<T>>, \"onSubmit\">): KrizakaFormInstance<S, C, RootProps>;\n}\n\n/** Builds a platform's `createKrizakaForm` from its field components and its root (web `<form>`, native `View`). */\nexport function makeCreateKrizakaForm<C extends object, RootProps extends object>(components: C, Root: (props: RootProps & { value: FormContextValue }) => React.ReactElement) {\n return function createKrizakaForm<S extends FormSchema>(definition: KrizakaFormDefinition<S>): KrizakaFormFactory<S, C, RootProps> {\n function useForm<R>(options?: UseKrizakaFormOptions<S, R>): KrizakaFormInstance<S, C, RootProps> {\n const ctx = useKrizakaFormCore(definition, options);\n // One `Form` for the life of the form (never remounted, so focus and fields stay): it reads the latest state from a\n // small store the hook updates.\n const [store] = React.useState(() => createStore(ctx));\n React.useLayoutEffect(() => store.set(ctx), [store, ctx]);\n const Form = React.useCallback((props: RootProps) => <Bound store={store} Root={Root} props={props} />, [store]);\n return React.useMemo(\n () =>\n ({\n ...components,\n Form,\n submit: ctx.submit,\n reset: (values?: FormValues<S>) => ctx.api.reset(values),\n setValue: (name: string, value: unknown) => ctx.api.setFieldValue(name as never, value as never),\n getValues: () => ctx.api.state.values as FormValues<S>,\n error: ctx.error,\n api: ctx.api,\n }) as unknown as KrizakaFormInstance<S, C, RootProps>,\n [ctx, Form],\n );\n }\n return {\n schema: definition.schema,\n defaultValues: definition.defaultValues,\n useForm,\n useFormWithAction: (action, options) => useForm({ ...options, onSubmit: action }),\n };\n };\n}\n\ntype Store<T> = { get: () => T; set: (value: T) => void; subscribe: (listener: () => void) => () => void };\n\nfunction createStore<T>(initial: T): Store<T> {\n let value = initial;\n const listeners = new Set<() => void>();\n return {\n get: () => value,\n set: (next) => {\n if (Object.is(next, value)) return;\n value = next;\n listeners.forEach((listener) => listener());\n },\n subscribe: (listener) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n };\n}\n\nfunction Bound<RootProps extends object>({ store, Root, props }: { store: Store<FormContextValue>; Root: (props: RootProps & { value: FormContextValue }) => React.ReactElement; props: RootProps }) {\n const value = React.useSyncExternalStore(store.subscribe, store.get, store.get);\n return <Root {...props} value={value} />;\n}\n"
2073
2073
  },
2074
2074
  {
2075
2075
  "path": "form/errors.ts",
@@ -2093,7 +2093,7 @@
2093
2093
  },
2094
2094
  {
2095
2095
  "path": "form/web.tsx",
2096
- "content": "// Client. The form's fields for the web: thin adapters from a form field (useKrizakaField) to the platform's current\n// primitives — Field + Input, Checkbox, Switch, RadioGroup (dots, cards, tactile keys, segments), Combobox, the date\n// pickers (calendar, typed, wheels), TagInput, Dropzone, Slider. A new look for a primitive changes an adapter here,\n// never a form.\nimport { Alert } from \"@krizaka/ui/alert\";\nimport { Button, type ButtonProps } from \"@krizaka/ui/button\";\nimport { Checkbox as CheckboxControl } from \"@krizaka/ui/checkbox\";\nimport { Combobox as ComboboxControl, type ComboboxLabels, type ComboboxOption } from \"@krizaka/ui/combobox\";\nimport { DateField as DatePartsControl, DatePicker, type DatePickerLabels, DateWheel } from \"@krizaka/ui/date-picker\";\nimport { Field, Input, Select as SelectControl, Textarea } from \"@krizaka/ui/field\";\nimport { Dropzone, formatBytes } from \"@krizaka/ui/file-upload\";\nimport { RadioGroup as RadioGroupControl } from \"@krizaka/ui/radio-group\";\nimport { Slider as SliderControl, type SliderMark, type SliderValue } from \"@krizaka/ui/slider\";\nimport { Spinner } from \"@krizaka/ui/spinner\";\nimport { Switch as SwitchControl } from \"@krizaka/ui/switch\";\nimport { TagInput as TagInputControl, type TagInputProps } from \"@krizaka/ui/tag-input\";\nimport * as React from \"react\";\n\nimport type { DatePart } from \"../date-picker/parts\";\nimport {\n type FieldCheck,\n FormContext,\n type FormContextValue,\n type KrizakaField,\n type KrizakaFieldArray,\n makeCreateKrizakaForm,\n useFormContext,\n useFormStatus,\n useFormValue,\n useKrizakaField,\n useKrizakaFieldArray,\n} from \"./core\";\nimport { formMessage } from \"./errors\";\nimport { currencySymbol, moneyInputText, parseMoney } from \"./money\";\n\ntype Base = {\n /** The field's name in the schema: `email`, `address.city`, `links[0].url` (typed by the form). */\n name: string;\n /** The visible label, passed translated (also the control's accessible name). */\n label: string;\n /** A help text under the control, read with it; the error takes its place when there is one. */\n hint?: string;\n /** Not available. */\n disabled?: boolean;\n /** Classes merged on the field's root. */\n className?: string;\n};\n\nconst ids = (f: KrizakaField, hint?: string) => [hint && !f.error ? f.hintId : null, f.error ? f.errorId : null].filter(Boolean).join(\" \") || undefined;\n\nfunction Caption({ field, hint, ok }: { field: KrizakaField; hint?: string; ok?: string }) {\n const suggestion = field.errorDetail?.suggestion;\n if (field.error)\n return (\n <Field.Error id={field.errorId}>\n <span>\n {field.error}\n {suggestion ? (\n <>\n {\" \"}\n <button type=\"button\" className=\"font-semibold text-accent-text underline underline-offset-2\" onClick={() => field.setValue(suggestion.value)}>\n {suggestion.label}\n </button>\n </>\n ) : null}\n </span>\n </Field.Error>\n );\n const text = field.available && ok ? (hint ? `${ok} · ${hint}` : ok) : hint;\n return text ? <Field.Hint id={field.hintId}>{text}</Field.Hint> : null;\n}\n\nconst Check = () => (\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4 text-fg-success\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d=\"M20 6 9 17l-5-5\" />\n </svg>\n);\n\nexport type FormProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"noValidate\"> & {\n /** The fields, the error and the button. */\n children?: React.ReactNode;\n};\n\n/** The `<form>` of a form (`form.Form`): `noValidate` (the schema validates), the first error focused after a refused submit. */\nfunction FormRoot({ value, className, children, ...props }: FormProps & { value: FormContextValue }) {\n const ref = React.useRef<HTMLFormElement>(null);\n return (\n <FormContext.Provider value={value}>\n <form\n ref={ref}\n noValidate\n className={className ?? \"flex flex-col gap-4\"}\n onSubmit={async (event) => {\n event.preventDefault();\n if (!(await value.submit())) requestAnimationFrame(() => ref.current?.querySelector<HTMLElement>('[aria-invalid=\"true\"], [data-invalid] input')?.focus());\n }}\n {...props}\n >\n {children}\n </form>\n </FormContext.Provider>\n );\n}\n\nexport type SubmitProps = Omit<ButtonProps, \"type\" | \"loading\"> & {\n /** The button's words, passed translated (\"Create my account\"). */\n children: React.ReactNode;\n};\n\n/** The submit button: busy while the form is sent (not pressable twice), never disabled for an invalid form. */\nexport function Submit({ variant = \"primary\", children, ...props }: SubmitProps) {\n const { submitting } = useFormStatus();\n return (\n <Button type=\"submit\" variant={variant} loading={submitting} {...props}>\n {children}\n </Button>\n );\n}\n\nexport type FormErrorProps = {\n /** A headline above the message (\"We could not create your account\"), passed translated. */\n title?: string;\n /** Classes merged on the alert. */\n className?: string;\n};\n\n/** The whole form's error — a refusal of the server that belongs to no field (a conflict, too many tries, no network). */\nexport function FormError({ title, className }: FormErrorProps) {\n const ctx = useFormContext();\n if (!ctx.error) return null;\n return (\n <Alert tone=\"danger\" title={title} className={className}>\n {formMessage(ctx.error, ctx.i18n)}\n </Alert>\n );\n}\n\nexport type TextFieldProps = Base & {\n /** `text` (default), `email`, `password`, `url`, `tel`, `search`: the right keyboard on a phone. */\n type?: \"text\" | \"email\" | \"password\" | \"url\" | \"tel\" | \"search\";\n /** The browser's autofill (`email`, `username`, `new-password`, `name`). */\n autoComplete?: string;\n /** An example of a value, passed translated — never the label. */\n placeholder?: string;\n /** The keyboard on a phone (`numeric`, `decimal`). */\n inputMode?: React.HTMLAttributes<HTMLInputElement>[\"inputMode\"];\n /** The most characters. */\n maxLength?: number;\n /** Cleans what is typed before it is stored (`(v) => v.toLowerCase().replace(/\\s+/g, \"_\")`). */\n transform?: (raw: string) => string;\n /** Checks the value on the server as it is typed (availability): an error key, or nothing when it is fine. */\n validateAsync?: FieldCheck<string>;\n /** The wait, in ms, between the last key and `validateAsync`. Default 350. */\n asyncDebounceMs?: number;\n /** The words once `validateAsync` accepted the value (\"@eric is free\"), passed translated. */\n availableHint?: string;\n /** The name of the wait while it checks (\"Checking\"), passed translated. */\n checkingLabel?: string;\n /** Something before the text (an `@`, an icon). */\n start?: React.ReactNode;\n};\n\n/** A text field of the form: label, input, hint or error, an optional check on the server. */\nexport function TextField({ name, label, hint, disabled, className, type = \"text\", transform, validateAsync, asyncDebounceMs, availableHint, checkingLabel = \"Checking\", start, ...input }: TextFieldProps) {\n const f = useKrizakaField<string>(name, { validateAsync, asyncDebounceMs });\n const end = f.validating ? <Spinner size=\"sm\" label={checkingLabel} /> : f.available ? <Check /> : null;\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <span className=\"relative flex items-center\">\n {start ? <span className=\"pointer-events-none absolute left-3 text-fg-muted\">{start}</span> : null}\n <Input\n id={f.id}\n name={name}\n type={type}\n value={f.value ?? \"\"}\n disabled={disabled}\n invalid={Boolean(f.error)}\n aria-describedby={ids(f, hint ?? availableHint)}\n onChange={(event) => f.setValue(transform ? transform(event.target.value) : event.target.value)}\n onBlur={f.onBlur}\n className={[start ? \"pl-9\" : \"\", end ? \"pr-10\" : \"\"].join(\" \").trim() || undefined}\n {...input}\n />\n {end ? <span className=\"absolute right-3 flex\">{end}</span> : null}\n </span>\n <Caption field={f} hint={hint} ok={availableHint} />\n </Field.Root>\n );\n}\n\nexport type TextAreaProps = Base & {\n /** An example of a value, passed translated. */\n placeholder?: string;\n /** The visible lines. Default 4. */\n rows?: number;\n /** The most characters. */\n maxLength?: number;\n};\n\n/** A longer text of the form (a bio, a description, a report). */\nexport function TextArea({ name, label, hint, disabled, className, rows = 4, ...input }: TextAreaProps) {\n const f = useKrizakaField<string>(name);\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <Textarea id={f.id} name={name} rows={rows} value={f.value ?? \"\"} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} {...input} />\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type CheckboxProps = Omit<Base, \"label\"> & {\n /** The words next to the box, clickable with it — text, or a sentence with links (terms, privacy). */\n label: React.ReactNode;\n};\n\n/** A box of the form (`boolean`): terms to accept, an option. */\nexport function Checkbox({ name, label, hint, disabled, className }: CheckboxProps) {\n const f = useKrizakaField<boolean>(name);\n return (\n <div className={className ?? \"flex flex-col gap-1.5\"}>\n <CheckboxControl id={f.id} name={name} checked={f.value === true} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onCheckedChange={(c) => f.setValue(c === true)} onBlur={f.onBlur}>\n {label}\n </CheckboxControl>\n <Caption field={f} hint={hint} />\n </div>\n );\n}\n\n/** An on/off setting of the form (`boolean`): the label and its hint beside the switch. */\nexport function Switch({ name, label, hint, disabled, className }: Base) {\n const f = useKrizakaField<boolean>(name);\n return (\n <div className={className ?? \"flex items-start justify-between gap-4\"}>\n <div className=\"flex min-w-0 flex-col gap-0.5\">\n <Field.Label htmlFor={f.id} className=\"text-sm font-medium text-fg\">\n {label}\n </Field.Label>\n <Caption field={f} hint={hint} />\n </div>\n <SwitchControl id={f.id} name={name} checked={f.value === true} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onCheckedChange={(c) => f.setValue(c)} onBlur={f.onBlur} />\n </div>\n );\n}\n\n/** A choice of the form: the value stored, its words (passed translated), a line under them. */\nexport type FormOption = { value: string; label: string; description?: string; disabled?: boolean };\n\nexport type RadioGroupProps = Base & {\n /** The choices, all visible (two to five). */\n options: readonly FormOption[];\n /**\n * `list` (a dot and its words, default), `cards` (a whole card is the radio: an amount, a plan, an audience),\n * `buttons` (tactile keys that press in: a size, a tip; `description` is the key's second line) or `segmented`\n * (two to four short choices on one line).\n */\n variant?: \"list\" | \"cards\" | \"buttons\" | \"segmented\";\n /** `vertical` (default) or `horizontal`. */\n orientation?: \"vertical\" | \"horizontal\";\n};\n\n/** One choice among a few, all visible. */\nexport function RadioGroup({ name, label, hint, disabled, className, options, variant = \"list\", orientation = \"vertical\" }: RadioGroupProps) {\n const f = useKrizakaField<string>(name);\n return (\n <Field.Root className={className} role=\"group\" aria-labelledby={`${f.id}-label`}>\n <span id={`${f.id}-label`} className=\"text-xs font-semibold text-fg-secondary\">\n {label}\n </span>\n <RadioGroupControl.Root\n aria-labelledby={`${f.id}-label`}\n value={f.value ?? \"\"}\n onValueChange={(v) => f.setValue(v)}\n onBlur={f.onBlur}\n orientation={orientation}\n disabled={disabled}\n invalid={Boolean(f.error)}\n aria-describedby={ids(f, hint)}\n name={name}\n variant={variant}\n className={variant === \"segmented\" ? \"self-start\" : undefined}\n >\n {options.map((o) =>\n variant === \"cards\" ? (\n <RadioGroupControl.Card key={o.value} value={o.value} disabled={o.disabled}>\n <span className=\"font-semibold\">{o.label}</span>\n {o.description ? <span className=\"text-xs text-fg-secondary\">{o.description}</span> : null}\n </RadioGroupControl.Card>\n ) : variant === \"buttons\" ? (\n <RadioGroupControl.Button key={o.value} value={o.value} disabled={o.disabled} hint={o.description}>\n {o.label}\n </RadioGroupControl.Button>\n ) : variant === \"segmented\" ? (\n <RadioGroupControl.Segment key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n </RadioGroupControl.Segment>\n ) : (\n <RadioGroupControl.Item key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n {o.description ? <span className=\"block text-xs text-fg-secondary\">{o.description}</span> : null}\n </RadioGroupControl.Item>\n ),\n )}\n </RadioGroupControl.Root>\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type SelectProps = Base & {\n /** The choices, in the platform's own menu (a country, a category). */\n options: readonly FormOption[];\n /** The first line while nothing is chosen (\"Choose a category\"), passed translated. */\n placeholder?: string;\n};\n\n/** One choice among many, in the platform's menu (`<select>`: accessible and right on phones). */\nexport function Select({ name, label, hint, disabled, className, options, placeholder }: SelectProps) {\n const f = useKrizakaField<string>(name);\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <SelectControl id={f.id} name={name} value={f.value ?? \"\"} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur}>\n {placeholder !== undefined ? (\n <option value=\"\" disabled>\n {placeholder}\n </option>\n ) : null}\n {options.map((o) => (\n <option key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n </option>\n ))}\n </SelectControl>\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type ComboboxProps = Base & {\n /** The options, filtered as the person types. */\n options?: readonly ComboboxOption[];\n /** Fetches the options for a query (debounced, aborted by a newer one). */\n onSearch?: (query: string, signal: AbortSignal) => Promise<readonly ComboboxOption[]>;\n /** Several values (`string[]`), shown as chips; one (`string`) by default. */\n multiple?: boolean;\n /** Offers to create the query as a value. */\n creatable?: boolean;\n /** The input's placeholder, passed translated. */\n placeholder?: string;\n /** The words of the control, passed translated. */\n labels?: Partial<ComboboxLabels>;\n};\n\n/** A choice among many, typed to filter or fetched: one value (`string`) or several (`string[]`). */\nexport function Combobox({ name, label, hint, disabled, className, options = [], onSearch, multiple, creatable, placeholder, labels }: ComboboxProps) {\n const f = useKrizakaField<string | string[]>(name);\n // The options chosen from a search, by value: their labels once the search has moved on.\n const [seen, setSeen] = React.useState<Record<string, ComboboxOption>>({});\n const option = (value: string) => options.find((o) => o.value === value) ?? seen[value] ?? { value, label: value };\n const remember = (o: ComboboxOption) => setSeen((known) => (known[o.value] === o ? known : { ...known, [o.value]: o }));\n const shared = { id: f.id, label, hint, error: f.error, disabled, placeholder, labels, options, onSearch, creatable, name };\n return (\n <div className={className} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur()}>\n {multiple ? (\n <ComboboxControl\n {...shared}\n multiple\n value={((f.value as string[] | undefined) ?? []).map(option)}\n onValueChange={(next) => {\n next.forEach(remember);\n f.setValue(next.map((o) => o.value));\n }}\n />\n ) : (\n <ComboboxControl\n {...shared}\n value={f.value ? option(f.value as string) : null}\n onValueChange={(next) => {\n if (next) remember(next);\n f.setValue(next?.value ?? \"\");\n }}\n />\n )}\n </div>\n );\n}\n\nexport type DateFieldProps = Base & {\n /**\n * `calendar` (default: a date near today — a publication, an appointment), `parts` (day · month · year typed: a\n * memorable date, a birth date) or `wheel` (three wheels bounded by `min` / `max`: a birth date on a phone).\n */\n entry?: \"calendar\" | \"parts\" | \"wheel\";\n /** The earliest date (ISO), for the calendar and the wheels. */\n min?: string;\n /** The latest date (ISO), for the calendar and the wheels (`latestBirthDate(18)` for an age gate). */\n max?: string;\n /** The words of the calendar, or the names of the parts (`day`, `month`, `year`), passed translated. */\n labels?: Partial<DatePickerLabels> & Partial<Record<DatePart, string>>;\n /** `bday` for a date of birth: typed boxes are autofilled; the wheels rest on 1 January 2000 until turned. */\n autoComplete?: \"bday\" | \"off\";\n};\n\n/** A date of the form (`YYYY-MM-DD`, `\"\"` for none): a calendar, day · month · year typed, or wheels. */\nexport function DateField({ name, label, hint, disabled, className, entry = \"calendar\", min, max, labels, autoComplete }: DateFieldProps) {\n const f = useKrizakaField<string>(name);\n const onBlur = (e: React.FocusEvent<HTMLElement>) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur();\n if (entry === \"calendar\")\n return (\n <div className={className} onBlur={onBlur}>\n <DatePicker label={label} hint={hint} error={f.error} disabled={disabled} name={name} locale={f.locale} min={min} max={max} labels={labels} value={f.value || null} onValueChange={(v) => f.setValue(v ?? \"\")} />\n </div>\n );\n if (entry === \"wheel\")\n return (\n <div className={className} onBlur={onBlur}>\n <DateWheel label={label} hint={hint} error={f.error} disabled={disabled} name={name} locale={f.locale} min={min} max={max} labels={labels} defaultView={autoComplete === \"bday\" ? \"2000-01-01\" : undefined} value={f.value || null} onValueChange={(v) => f.setValue(v)} />\n </div>\n );\n return <DatePartsControl label={label} hint={hint} error={f.error} disabled={disabled} name={name} locale={f.locale} labels={labels} autoComplete={autoComplete} className={className} value={f.value ?? \"\"} onValueChange={(v) => f.setValue(v)} onBlur={f.onBlur} />;\n}\n\nexport type TagsProps = Base & Pick<TagInputProps, \"suggestions\" | \"normalize\" | \"max\" | \"separators\" | \"placeholder\" | \"labels\">;\n\n/** Free words of the form as chips (`string[]`): tags, keywords. */\nexport function TagInput({ name, label, hint, disabled, className, ...props }: TagsProps) {\n const f = useKrizakaField<string[]>(name);\n return (\n <div className={className} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur()}>\n <TagInputControl label={label} hint={hint} error={f.error} disabled={disabled} name={name} value={f.value ?? []} onValueChange={(tags) => f.setValue(tags)} {...props} />\n </div>\n );\n}\n\nexport type FileUploadProps = Omit<Base, \"hint\"> & {\n /** The title in the zone (\"Drop your video here\"), passed translated. */\n title: string;\n /** The button that opens the file picker, passed translated. */\n browseLabel: string;\n /** What is accepted, in words (\"MP4 or MOV, up to 4 GB\"). */\n hint?: string;\n /** The types accepted (`video/*`, `image/png,.pdf`). */\n accept?: string;\n /** The largest file, in bytes. */\n maxSize?: number;\n /** Several files (`File[]`, up to `maxFiles`); one by default (still a `File[]` of one). */\n multiple?: boolean;\n /** The most files. */\n maxFiles?: number;\n /** The name of a file's remove button (`(name) => \"Remove \" + name`), passed translated. */\n removeLabel: (fileName: string) => string;\n /** The words of a refused file (`(r) => r.file.name + \" is too large\"`). */\n refusalLabel?: React.ComponentProps<typeof Dropzone>[\"refusalLabel\"];\n};\n\n/** Files of the form (`File[]`): a drop zone, then the chosen files, each removable. */\nexport function FileUpload({ name, label, hint, disabled, className, title, browseLabel, accept, maxSize, multiple, maxFiles, removeLabel, refusalLabel }: FileUploadProps) {\n const f = useKrizakaField<File[]>(name);\n const files = f.value ?? [];\n return (\n <div className={className ?? \"flex flex-col gap-2\"} role=\"group\" aria-label={label} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur()}>\n <span className=\"text-xs font-semibold text-fg-secondary\">{label}</span>\n <Dropzone\n title={title}\n browseLabel={browseLabel}\n hint={hint}\n accept={accept}\n maxSize={maxSize}\n multiple={multiple}\n maxFiles={maxFiles}\n disabled={disabled}\n refusalLabel={refusalLabel}\n onFiles={(chosen) => f.setValue(multiple ? [...files, ...chosen].slice(0, maxFiles ?? Infinity) : chosen.slice(0, 1))}\n />\n {files.length > 0 ? (\n <ul className=\"flex flex-col gap-1.5\">\n {files.map((file, i) => (\n <li key={`${file.name}-${i}`} className=\"flex items-center justify-between gap-3 rounded-lg border border-border-default bg-surface-1 px-3 py-2 text-sm\">\n <span className=\"min-w-0 truncate text-fg\">{file.name}</span>\n <span className=\"flex shrink-0 items-center gap-2\">\n <span className=\"font-mono text-xs text-fg-secondary\">{formatBytes(file.size, f.locale)}</span>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => f.setValue(files.filter((_, j) => j !== i))}>\n {removeLabel(file.name)}\n </Button>\n </span>\n </li>\n ))}\n </ul>\n ) : null}\n {f.error ? <Field.Error id={f.errorId}>{f.error}</Field.Error> : null}\n </div>\n );\n}\n\nexport type MoneyProps = Base & {\n /** The currency (ISO 4217: `USD`, `EUR`, `JPY`): its sign before the amount, its decimals. */\n currency: string;\n /** An example of an amount, passed translated. */\n placeholder?: string;\n};\n\n/** An amount of the form, stored as integer minor units (cents: `1250` = 12.50), typed the locale's way. */\nexport function Money({ name, label, hint, disabled, className, currency, placeholder }: MoneyProps) {\n const f = useKrizakaField<number | null>(name);\n const [text, setText] = React.useState(() => moneyInputText(f.value, currency, f.locale));\n const [editing, setEditing] = React.useState(false);\n const shown = editing ? text : moneyInputText(f.value, currency, f.locale);\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <span className=\"relative flex items-center\">\n <span className=\"pointer-events-none absolute left-3 text-sm text-fg-secondary\" aria-hidden>\n {currencySymbol(currency, f.locale)}\n </span>\n <Input\n id={f.id}\n name={name}\n inputMode=\"decimal\"\n value={shown}\n placeholder={placeholder}\n disabled={disabled}\n invalid={Boolean(f.error)}\n aria-describedby={ids(f, hint)}\n className=\"pl-9 tabular-nums\"\n onFocus={() => {\n setText(moneyInputText(f.value, currency, f.locale));\n setEditing(true);\n }}\n onChange={(e) => {\n setText(e.target.value);\n const units = parseMoney(e.target.value, currency, f.locale);\n f.setValue(units === undefined ? null : units === null ? Number.NaN : units);\n }}\n onBlur={() => {\n setEditing(false);\n f.onBlur();\n }}\n />\n </span>\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type SliderProps = Base & {\n /** The lowest value. Default 0. */\n min?: number;\n /** The highest value. Default 100. */\n max?: number;\n /** The step. Default 1. */\n step?: number;\n /** The value as words (\"15 %\", \"3 min\"), shown and read. */\n formatValue?: (value: number) => string;\n /** For a range (`[number, number]`): each thumb's name, passed translated. */\n thumbLabels?: [string, string];\n /** A graduation per step (`true`) or at the values given. */\n ticks?: boolean | readonly number[];\n /** Words under the track at some values. */\n marks?: readonly SliderMark[];\n};\n\n/** A number (or a range) of the form, chosen by feel: a share, a volume, a price range — the value floats while it is touched. */\nexport function Slider({ name, label, hint, disabled, className, min, max, step, formatValue, thumbLabels, ticks, marks }: SliderProps) {\n const f = useKrizakaField<SliderValue>(name);\n return (\n <div className={className ?? \"flex flex-col gap-1.5\"}>\n <SliderControl label={label} showLabel floatingValue value={f.value} min={min} max={max} step={step} ticks={ticks} marks={marks} disabled={disabled} formatValue={formatValue} thumbLabels={thumbLabels} aria-describedby={ids(f, hint)} onValueChange={(v) => f.setValue(v)} onValueCommit={() => f.onBlur()} />\n <Caption field={f} hint={hint} />\n </div>\n );\n}\n\nexport type FormFieldProps<T = unknown> = {\n /** The field's name in the schema (typed by the form). */\n name: string;\n /** Checks the value on the server as it changes. */\n validateAsync?: FieldCheck<T>;\n /** The wait, in ms, before `validateAsync`. */\n asyncDebounceMs?: number;\n /** Draws the field: its value, setter, error and ids (`useKrizakaField`). */\n children: (field: KrizakaField<T>) => React.ReactNode;\n};\n\n/** A field drawn by the app (a control the package does not have): the form's value, errors and ids, any markup. */\nexport function FormField({ name, validateAsync, asyncDebounceMs, children }: FormFieldProps) {\n return <>{children(useKrizakaField(name, { validateAsync, asyncDebounceMs }))}</>;\n}\n\nexport type FieldArrayProps<T = unknown> = {\n /** The array's name in the schema (typed by the form). */\n name: string;\n /** Draws the items: `items`, `push`, `remove`, `move`, `fieldName(i, \"url\")` for the fields of an item. */\n children: (array: KrizakaFieldArray<T>) => React.ReactNode;\n};\n\n/** A list of fields that grows and shrinks (links, tiers, guests): each item's fields named `links[0].url`. */\nexport function FieldArray({ name, children }: FieldArrayProps) {\n return <>{children(useKrizakaFieldArray(name))}</>;\n}\n\nexport type WatchProps<T = unknown> = {\n /** The field to follow (typed by the form). */\n name: string;\n /** Draws from its current value: show a part of the form only when a box is ticked. */\n children: (value: T) => React.ReactNode;\n};\n\n/** Renders from a field's current value (a part shown only when a box is ticked, a total). */\nexport function Watch({ name, children }: WatchProps) {\n return <>{children(useFormValue(name))}</>;\n}\n\nconst fields = { TextField, TextArea, Checkbox, Switch, RadioGroup, Select, Combobox, DateField, TagInput, FileUpload, Money, Slider, Field: FormField, FieldArray, Watch, Submit, FormError };\n\n/**\n * A form, defined once: its schema (zod 4) and default values — the same schema the server parses with. In a component,\n * `signUp.useForm({ onSubmit })` gives the form and its fields, typed by the schema: `<form.Form>`, `<form.TextField\n * name=\"email\" …/>`, `<form.Submit>`. Errors are i18n keys (`form.errors.tooShort`), never the engine's English.\n */\nexport const createKrizakaForm = makeCreateKrizakaForm(fields, FormRoot);\n\n/** The form sent by a Server Action: `useFormWithAction(signUp, createAccount)` — `signUp.useFormWithAction(createAccount)`. */\nexport function useFormWithAction<F extends { useFormWithAction: (...args: never[]) => unknown }>(form: F, ...args: Parameters<F[\"useFormWithAction\"]>): ReturnType<F[\"useFormWithAction\"]> {\n return form.useFormWithAction(...args) as ReturnType<F[\"useFormWithAction\"]>;\n}\n"
2096
+ "content": "// Client. The form's fields for the web: thin adapters from a form field (useKrizakaField) to the platform's current\n// primitives — Field + Input, Checkbox, Switch, RadioGroup (dots, cards, tactile keys, segments), Combobox, the date\n// pickers (calendar, typed, wheels), TagInput, Dropzone, Slider. A new look for a primitive changes an adapter here,\n// never a form.\nimport { Alert } from \"@krizaka/ui/alert\";\nimport { Button, type ButtonProps } from \"@krizaka/ui/button\";\nimport { Checkbox as CheckboxControl } from \"@krizaka/ui/checkbox\";\nimport { Combobox as ComboboxControl, type ComboboxLabels, type ComboboxOption } from \"@krizaka/ui/combobox\";\nimport { DateField as DatePartsControl, DatePicker, type DatePickerLabels, DateWheel } from \"@krizaka/ui/date-picker\";\nimport { Field, Input, Select as SelectControl, Textarea } from \"@krizaka/ui/field\";\nimport { Dropzone, formatBytes } from \"@krizaka/ui/file-upload\";\nimport { RadioGroup as RadioGroupControl } from \"@krizaka/ui/radio-group\";\nimport { Slider as SliderControl, type SliderMark, type SliderValue } from \"@krizaka/ui/slider\";\nimport { Spinner } from \"@krizaka/ui/spinner\";\nimport { Switch as SwitchControl } from \"@krizaka/ui/switch\";\nimport { TagInput as TagInputControl, type TagInputProps } from \"@krizaka/ui/tag-input\";\nimport * as React from \"react\";\n\nimport type { DatePart } from \"../date-picker/parts\";\nimport {\n FieldArray,\n type FieldArrayProps,\n type FieldCheck,\n FormContext,\n type FormContextValue,\n FormField,\n type FormFieldProps,\n type KrizakaField,\n makeCreateKrizakaForm,\n useFormContext,\n useFormStatus,\n useKrizakaField,\n Watch,\n type WatchProps,\n} from \"./core\";\nimport { formMessage } from \"./errors\";\nimport { currencySymbol, moneyInputText, parseMoney } from \"./money\";\n\nexport { FieldArray, type FieldArrayProps, FormField, type FormFieldProps, Watch, type WatchProps };\n\ntype Base = {\n /** The field's name in the schema: `email`, `address.city`, `links[0].url` (typed by the form). */\n name: string;\n /** The visible label, passed translated (also the control's accessible name). */\n label: string;\n /** A help text under the control, read with it; the error takes its place when there is one. */\n hint?: string;\n /** Not available. */\n disabled?: boolean;\n /** Classes merged on the field's root. */\n className?: string;\n};\n\nconst ids = (f: KrizakaField, hint?: string) => [hint && !f.error ? f.hintId : null, f.error ? f.errorId : null].filter(Boolean).join(\" \") || undefined;\n\nfunction Caption({ field, hint, ok }: { field: KrizakaField; hint?: string; ok?: string }) {\n const suggestion = field.errorDetail?.suggestion;\n if (field.error)\n return (\n <Field.Error id={field.errorId}>\n <span>\n {field.error}\n {suggestion ? (\n <>\n {\" \"}\n <button type=\"button\" className=\"font-semibold text-accent-text underline underline-offset-2\" onClick={() => field.setValue(suggestion.value)}>\n {suggestion.label}\n </button>\n </>\n ) : null}\n </span>\n </Field.Error>\n );\n const text = field.available && ok ? (hint ? `${ok} · ${hint}` : ok) : hint;\n return text ? <Field.Hint id={field.hintId}>{text}</Field.Hint> : null;\n}\n\nconst Check = () => (\n <svg viewBox=\"0 0 24 24\" className=\"h-4 w-4 text-fg-success\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n <path d=\"M20 6 9 17l-5-5\" />\n </svg>\n);\n\nexport type FormProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"noValidate\"> & {\n /** The fields, the error and the button. */\n children?: React.ReactNode;\n};\n\n/** The `<form>` of a form (`form.Form`): `noValidate` (the schema validates), the first error focused after a refused submit. */\nfunction FormRoot({ value, className, children, ...props }: FormProps & { value: FormContextValue }) {\n const ref = React.useRef<HTMLFormElement>(null);\n return (\n <FormContext.Provider value={value}>\n <form\n ref={ref}\n noValidate\n className={className ?? \"flex flex-col gap-4\"}\n onSubmit={async (event) => {\n event.preventDefault();\n if (!(await value.submit())) requestAnimationFrame(() => ref.current?.querySelector<HTMLElement>('[aria-invalid=\"true\"], [data-invalid] input')?.focus());\n }}\n {...props}\n >\n {children}\n </form>\n </FormContext.Provider>\n );\n}\n\nexport type SubmitProps = Omit<ButtonProps, \"type\" | \"loading\"> & {\n /** The button's words, passed translated (\"Create my account\"). */\n children: React.ReactNode;\n};\n\n/** The submit button: busy while the form is sent (not pressable twice), never disabled for an invalid form. */\nexport function Submit({ variant = \"primary\", children, ...props }: SubmitProps) {\n const { submitting } = useFormStatus();\n return (\n <Button type=\"submit\" variant={variant} loading={submitting} {...props}>\n {children}\n </Button>\n );\n}\n\nexport type FormErrorProps = {\n /** A headline above the message (\"We could not create your account\"), passed translated. */\n title?: string;\n /** Classes merged on the alert. */\n className?: string;\n};\n\n/** The whole form's error — a refusal of the server that belongs to no field (a conflict, too many tries, no network). */\nexport function FormError({ title, className }: FormErrorProps) {\n const ctx = useFormContext();\n if (!ctx.error) return null;\n return (\n <Alert tone=\"danger\" title={title} className={className}>\n {formMessage(ctx.error, ctx.i18n)}\n </Alert>\n );\n}\n\nexport type TextFieldProps = Base & {\n /** `text` (default), `email`, `password`, `url`, `tel`, `search`: the right keyboard on a phone. */\n type?: \"text\" | \"email\" | \"password\" | \"url\" | \"tel\" | \"search\";\n /** The browser's autofill (`email`, `username`, `new-password`, `name`). */\n autoComplete?: string;\n /** An example of a value, passed translated — never the label. */\n placeholder?: string;\n /** The keyboard on a phone (`numeric`, `decimal`). */\n inputMode?: React.HTMLAttributes<HTMLInputElement>[\"inputMode\"];\n /** The most characters. */\n maxLength?: number;\n /** Cleans what is typed before it is stored (`(v) => v.toLowerCase().replace(/\\s+/g, \"_\")`). */\n transform?: (raw: string) => string;\n /** Checks the value on the server as it is typed (availability): an error key, or nothing when it is fine. */\n validateAsync?: FieldCheck<string>;\n /** The wait, in ms, between the last key and `validateAsync`. Default 350. */\n asyncDebounceMs?: number;\n /** The words once `validateAsync` accepted the value (\"@eric is free\"), passed translated. */\n availableHint?: string;\n /** The name of the wait while it checks (\"Checking\"), passed translated. */\n checkingLabel?: string;\n /** Something before the text (an `@`, an icon). */\n start?: React.ReactNode;\n};\n\n/** A text field of the form: label, input, hint or error, an optional check on the server. */\nexport function TextField({ name, label, hint, disabled, className, type = \"text\", transform, validateAsync, asyncDebounceMs, availableHint, checkingLabel = \"Checking\", start, ...input }: TextFieldProps) {\n const f = useKrizakaField<string>(name, { validateAsync, asyncDebounceMs });\n const end = f.validating ? <Spinner size=\"sm\" label={checkingLabel} /> : f.available ? <Check /> : null;\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <span className=\"relative flex items-center\">\n {start ? <span className=\"pointer-events-none absolute left-3 text-fg-muted\">{start}</span> : null}\n <Input\n id={f.id}\n name={name}\n type={type}\n value={f.value ?? \"\"}\n disabled={disabled}\n invalid={Boolean(f.error)}\n aria-describedby={ids(f, hint ?? availableHint)}\n onChange={(event) => f.setValue(transform ? transform(event.target.value) : event.target.value)}\n onBlur={f.onBlur}\n className={[start ? \"pl-9\" : \"\", end ? \"pr-10\" : \"\"].join(\" \").trim() || undefined}\n {...input}\n />\n {end ? <span className=\"absolute right-3 flex\">{end}</span> : null}\n </span>\n <Caption field={f} hint={hint} ok={availableHint} />\n </Field.Root>\n );\n}\n\nexport type TextAreaProps = Base & {\n /** An example of a value, passed translated. */\n placeholder?: string;\n /** The visible lines. Default 4. */\n rows?: number;\n /** The most characters. */\n maxLength?: number;\n};\n\n/** A longer text of the form (a bio, a description, a report). */\nexport function TextArea({ name, label, hint, disabled, className, rows = 4, ...input }: TextAreaProps) {\n const f = useKrizakaField<string>(name);\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <Textarea id={f.id} name={name} rows={rows} value={f.value ?? \"\"} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} {...input} />\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type CheckboxProps = Omit<Base, \"label\"> & {\n /** The words next to the box, clickable with it — text, or a sentence with links (terms, privacy). */\n label: React.ReactNode;\n};\n\n/** A box of the form (`boolean`): terms to accept, an option. */\nexport function Checkbox({ name, label, hint, disabled, className }: CheckboxProps) {\n const f = useKrizakaField<boolean>(name);\n return (\n <div className={className ?? \"flex flex-col gap-1.5\"}>\n <CheckboxControl id={f.id} name={name} checked={f.value === true} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onCheckedChange={(c) => f.setValue(c === true)} onBlur={f.onBlur}>\n {label}\n </CheckboxControl>\n <Caption field={f} hint={hint} />\n </div>\n );\n}\n\n/** An on/off setting of the form (`boolean`): the label and its hint beside the switch. */\nexport function Switch({ name, label, hint, disabled, className }: Base) {\n const f = useKrizakaField<boolean>(name);\n return (\n <div className={className ?? \"flex items-start justify-between gap-4\"}>\n <div className=\"flex min-w-0 flex-col gap-0.5\">\n <Field.Label htmlFor={f.id} className=\"text-sm font-medium text-fg\">\n {label}\n </Field.Label>\n <Caption field={f} hint={hint} />\n </div>\n <SwitchControl id={f.id} name={name} checked={f.value === true} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onCheckedChange={(c) => f.setValue(c)} onBlur={f.onBlur} />\n </div>\n );\n}\n\n/** A choice of the form: the value stored, its words (passed translated), a line under them. */\nexport type FormOption = { value: string; label: string; description?: string; disabled?: boolean };\n\nexport type RadioGroupProps = Base & {\n /** The choices, all visible (two to five). */\n options: readonly FormOption[];\n /**\n * `list` (a dot and its words, default), `cards` (a whole card is the radio: an amount, a plan, an audience),\n * `buttons` (tactile keys that press in: a size, a tip; `description` is the key's second line) or `segmented`\n * (two to four short choices on one line).\n */\n variant?: \"list\" | \"cards\" | \"buttons\" | \"segmented\";\n /** `vertical` (default) or `horizontal`. */\n orientation?: \"vertical\" | \"horizontal\";\n};\n\n/** One choice among a few, all visible. */\nexport function RadioGroup({ name, label, hint, disabled, className, options, variant = \"list\", orientation = \"vertical\" }: RadioGroupProps) {\n const f = useKrizakaField<string>(name);\n return (\n <Field.Root className={className} role=\"group\" aria-labelledby={`${f.id}-label`}>\n <span id={`${f.id}-label`} className=\"text-xs font-semibold text-fg-secondary\">\n {label}\n </span>\n <RadioGroupControl.Root\n aria-labelledby={`${f.id}-label`}\n value={f.value ?? \"\"}\n onValueChange={(v) => f.setValue(v)}\n onBlur={f.onBlur}\n orientation={orientation}\n disabled={disabled}\n invalid={Boolean(f.error)}\n aria-describedby={ids(f, hint)}\n name={name}\n variant={variant}\n className={variant === \"segmented\" ? \"self-start\" : undefined}\n >\n {options.map((o) =>\n variant === \"cards\" ? (\n <RadioGroupControl.Card key={o.value} value={o.value} disabled={o.disabled}>\n <span className=\"font-semibold\">{o.label}</span>\n {o.description ? <span className=\"text-xs text-fg-secondary\">{o.description}</span> : null}\n </RadioGroupControl.Card>\n ) : variant === \"buttons\" ? (\n <RadioGroupControl.Button key={o.value} value={o.value} disabled={o.disabled} hint={o.description}>\n {o.label}\n </RadioGroupControl.Button>\n ) : variant === \"segmented\" ? (\n <RadioGroupControl.Segment key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n </RadioGroupControl.Segment>\n ) : (\n <RadioGroupControl.Item key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n {o.description ? <span className=\"block text-xs text-fg-secondary\">{o.description}</span> : null}\n </RadioGroupControl.Item>\n ),\n )}\n </RadioGroupControl.Root>\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type SelectProps = Base & {\n /** The choices, in the platform's own menu (a country, a category). */\n options: readonly FormOption[];\n /** The first line while nothing is chosen (\"Choose a category\"), passed translated. */\n placeholder?: string;\n};\n\n/** One choice among many, in the platform's menu (`<select>`: accessible and right on phones). */\nexport function Select({ name, label, hint, disabled, className, options, placeholder }: SelectProps) {\n const f = useKrizakaField<string>(name);\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <SelectControl id={f.id} name={name} value={f.value ?? \"\"} disabled={disabled} invalid={Boolean(f.error)} aria-describedby={ids(f, hint)} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur}>\n {placeholder !== undefined ? (\n <option value=\"\" disabled>\n {placeholder}\n </option>\n ) : null}\n {options.map((o) => (\n <option key={o.value} value={o.value} disabled={o.disabled}>\n {o.label}\n </option>\n ))}\n </SelectControl>\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type ComboboxProps = Base & {\n /** The options, filtered as the person types. */\n options?: readonly ComboboxOption[];\n /** Fetches the options for a query (debounced, aborted by a newer one). */\n onSearch?: (query: string, signal: AbortSignal) => Promise<readonly ComboboxOption[]>;\n /** Several values (`string[]`), shown as chips; one (`string`) by default. */\n multiple?: boolean;\n /** Offers to create the query as a value. */\n creatable?: boolean;\n /** The input's placeholder, passed translated. */\n placeholder?: string;\n /** The words of the control, passed translated. */\n labels?: Partial<ComboboxLabels>;\n};\n\n/** A choice among many, typed to filter or fetched: one value (`string`) or several (`string[]`). */\nexport function Combobox({ name, label, hint, disabled, className, options = [], onSearch, multiple, creatable, placeholder, labels }: ComboboxProps) {\n const f = useKrizakaField<string | string[]>(name);\n // The options chosen from a search, by value: their labels once the search has moved on.\n const [seen, setSeen] = React.useState<Record<string, ComboboxOption>>({});\n const option = (value: string) => options.find((o) => o.value === value) ?? seen[value] ?? { value, label: value };\n const remember = (o: ComboboxOption) => setSeen((known) => (known[o.value] === o ? known : { ...known, [o.value]: o }));\n const shared = { id: f.id, label, hint, error: f.error, disabled, placeholder, labels, options, onSearch, creatable, name };\n return (\n <div className={className} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur()}>\n {multiple ? (\n <ComboboxControl\n {...shared}\n multiple\n value={((f.value as string[] | undefined) ?? []).map(option)}\n onValueChange={(next) => {\n next.forEach(remember);\n f.setValue(next.map((o) => o.value));\n }}\n />\n ) : (\n <ComboboxControl\n {...shared}\n value={f.value ? option(f.value as string) : null}\n onValueChange={(next) => {\n if (next) remember(next);\n f.setValue(next?.value ?? \"\");\n }}\n />\n )}\n </div>\n );\n}\n\nexport type DateFieldProps = Base & {\n /**\n * `calendar` (default: a date near today — a publication, an appointment), `parts` (day · month · year typed: a\n * memorable date, a birth date) or `wheel` (three wheels bounded by `min` / `max`: a birth date on a phone).\n */\n entry?: \"calendar\" | \"parts\" | \"wheel\";\n /** The earliest date (ISO), for the calendar and the wheels. */\n min?: string;\n /** The latest date (ISO), for the calendar and the wheels (`latestBirthDate(18)` for an age gate). */\n max?: string;\n /** The words of the calendar, or the names of the parts (`day`, `month`, `year`), passed translated. */\n labels?: Partial<DatePickerLabels> & Partial<Record<DatePart, string>>;\n /** `bday` for a date of birth: typed boxes are autofilled; the wheels rest on 1 January 2000 until turned. */\n autoComplete?: \"bday\" | \"off\";\n};\n\n/** A date of the form (`YYYY-MM-DD`, `\"\"` for none): a calendar, day · month · year typed, or wheels. */\nexport function DateField({ name, label, hint, disabled, className, entry = \"calendar\", min, max, labels, autoComplete }: DateFieldProps) {\n const f = useKrizakaField<string>(name);\n const onBlur = (e: React.FocusEvent<HTMLElement>) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur();\n if (entry === \"calendar\")\n return (\n <div className={className} onBlur={onBlur}>\n <DatePicker label={label} hint={hint} error={f.error} disabled={disabled} name={name} locale={f.locale} min={min} max={max} labels={labels} value={f.value || null} onValueChange={(v) => f.setValue(v ?? \"\")} />\n </div>\n );\n if (entry === \"wheel\")\n return (\n <div className={className} onBlur={onBlur}>\n <DateWheel label={label} hint={hint} error={f.error} disabled={disabled} name={name} locale={f.locale} min={min} max={max} labels={labels} defaultView={autoComplete === \"bday\" ? \"2000-01-01\" : undefined} value={f.value || null} onValueChange={(v) => f.setValue(v)} />\n </div>\n );\n return <DatePartsControl label={label} hint={hint} error={f.error} disabled={disabled} name={name} locale={f.locale} labels={labels} autoComplete={autoComplete} className={className} value={f.value ?? \"\"} onValueChange={(v) => f.setValue(v)} onBlur={f.onBlur} />;\n}\n\nexport type TagsProps = Base & Pick<TagInputProps, \"suggestions\" | \"normalize\" | \"max\" | \"separators\" | \"placeholder\" | \"labels\">;\n\n/** Free words of the form as chips (`string[]`): tags, keywords. */\nexport function TagInput({ name, label, hint, disabled, className, ...props }: TagsProps) {\n const f = useKrizakaField<string[]>(name);\n return (\n <div className={className} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur()}>\n <TagInputControl label={label} hint={hint} error={f.error} disabled={disabled} name={name} value={f.value ?? []} onValueChange={(tags) => f.setValue(tags)} {...props} />\n </div>\n );\n}\n\nexport type FileUploadProps = Omit<Base, \"hint\"> & {\n /** The title in the zone (\"Drop your video here\"), passed translated. */\n title: string;\n /** The button that opens the file picker, passed translated. */\n browseLabel: string;\n /** What is accepted, in words (\"MP4 or MOV, up to 4 GB\"). */\n hint?: string;\n /** The types accepted (`video/*`, `image/png,.pdf`). */\n accept?: string;\n /** The largest file, in bytes. */\n maxSize?: number;\n /** Several files (`File[]`, up to `maxFiles`); one by default (still a `File[]` of one). */\n multiple?: boolean;\n /** The most files. */\n maxFiles?: number;\n /** The name of a file's remove button (`(name) => \"Remove \" + name`), passed translated. */\n removeLabel: (fileName: string) => string;\n /** The words of a refused file (`(r) => r.file.name + \" is too large\"`). */\n refusalLabel?: React.ComponentProps<typeof Dropzone>[\"refusalLabel\"];\n};\n\n/** Files of the form (`File[]`): a drop zone, then the chosen files, each removable. */\nexport function FileUpload({ name, label, hint, disabled, className, title, browseLabel, accept, maxSize, multiple, maxFiles, removeLabel, refusalLabel }: FileUploadProps) {\n const f = useKrizakaField<File[]>(name);\n const files = f.value ?? [];\n return (\n <div className={className ?? \"flex flex-col gap-2\"} role=\"group\" aria-label={label} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && f.onBlur()}>\n <span className=\"text-xs font-semibold text-fg-secondary\">{label}</span>\n <Dropzone\n title={title}\n browseLabel={browseLabel}\n hint={hint}\n accept={accept}\n maxSize={maxSize}\n multiple={multiple}\n maxFiles={maxFiles}\n disabled={disabled}\n refusalLabel={refusalLabel}\n onFiles={(chosen) => f.setValue(multiple ? [...files, ...chosen].slice(0, maxFiles ?? Infinity) : chosen.slice(0, 1))}\n />\n {files.length > 0 ? (\n <ul className=\"flex flex-col gap-1.5\">\n {files.map((file, i) => (\n <li key={`${file.name}-${i}`} className=\"flex items-center justify-between gap-3 rounded-lg border border-border-default bg-surface-1 px-3 py-2 text-sm\">\n <span className=\"min-w-0 truncate text-fg\">{file.name}</span>\n <span className=\"flex shrink-0 items-center gap-2\">\n <span className=\"font-mono text-xs text-fg-secondary\">{formatBytes(file.size, f.locale)}</span>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => f.setValue(files.filter((_, j) => j !== i))}>\n {removeLabel(file.name)}\n </Button>\n </span>\n </li>\n ))}\n </ul>\n ) : null}\n {f.error ? <Field.Error id={f.errorId}>{f.error}</Field.Error> : null}\n </div>\n );\n}\n\nexport type MoneyProps = Base & {\n /** The currency (ISO 4217: `USD`, `EUR`, `JPY`): its sign before the amount, its decimals. */\n currency: string;\n /** An example of an amount, passed translated. */\n placeholder?: string;\n};\n\n/** An amount of the form, stored as integer minor units (cents: `1250` = 12.50), typed the locale's way. */\nexport function Money({ name, label, hint, disabled, className, currency, placeholder }: MoneyProps) {\n const f = useKrizakaField<number | null>(name);\n const [text, setText] = React.useState(() => moneyInputText(f.value, currency, f.locale));\n const [editing, setEditing] = React.useState(false);\n const shown = editing ? text : moneyInputText(f.value, currency, f.locale);\n return (\n <Field.Root className={className}>\n <Field.Label htmlFor={f.id}>{label}</Field.Label>\n <span className=\"relative flex items-center\">\n <span className=\"pointer-events-none absolute left-3 text-sm text-fg-secondary\" aria-hidden>\n {currencySymbol(currency, f.locale)}\n </span>\n <Input\n id={f.id}\n name={name}\n inputMode=\"decimal\"\n value={shown}\n placeholder={placeholder}\n disabled={disabled}\n invalid={Boolean(f.error)}\n aria-describedby={ids(f, hint)}\n className=\"pl-9 tabular-nums\"\n onFocus={() => {\n setText(moneyInputText(f.value, currency, f.locale));\n setEditing(true);\n }}\n onChange={(e) => {\n setText(e.target.value);\n const units = parseMoney(e.target.value, currency, f.locale);\n f.setValue(units === undefined ? null : units === null ? Number.NaN : units);\n }}\n onBlur={() => {\n setEditing(false);\n f.onBlur();\n }}\n />\n </span>\n <Caption field={f} hint={hint} />\n </Field.Root>\n );\n}\n\nexport type SliderProps = Base & {\n /** The lowest value. Default 0. */\n min?: number;\n /** The highest value. Default 100. */\n max?: number;\n /** The step. Default 1. */\n step?: number;\n /** The value as words (\"15 %\", \"3 min\"), shown and read. */\n formatValue?: (value: number) => string;\n /** For a range (`[number, number]`): each thumb's name, passed translated. */\n thumbLabels?: [string, string];\n /** A graduation per step (`true`) or at the values given. */\n ticks?: boolean | readonly number[];\n /** Words under the track at some values. */\n marks?: readonly SliderMark[];\n};\n\n/** A number (or a range) of the form, chosen by feel: a share, a volume, a price range — the value floats while it is touched. */\nexport function Slider({ name, label, hint, disabled, className, min, max, step, formatValue, thumbLabels, ticks, marks }: SliderProps) {\n const f = useKrizakaField<SliderValue>(name);\n return (\n <div className={className ?? \"flex flex-col gap-1.5\"}>\n <SliderControl label={label} showLabel floatingValue value={f.value} min={min} max={max} step={step} ticks={ticks} marks={marks} disabled={disabled} formatValue={formatValue} thumbLabels={thumbLabels} aria-describedby={ids(f, hint)} onValueChange={(v) => f.setValue(v)} onValueCommit={() => f.onBlur()} />\n <Caption field={f} hint={hint} />\n </div>\n );\n}\n\nconst fields = { TextField, TextArea, Checkbox, Switch, RadioGroup, Select, Combobox, DateField, TagInput, FileUpload, Money, Slider, Field: FormField, FieldArray, Watch, Submit, FormError };\n\n/**\n * A form, defined once: its schema (zod 4) and default values — the same schema the server parses with. In a component,\n * `signUp.useForm({ onSubmit })` gives the form and its fields, typed by the schema: `<form.Form>`, `<form.TextField\n * name=\"email\" …/>`, `<form.Submit>`. Errors are i18n keys (`form.errors.tooShort`), never the engine's English.\n */\nexport const createKrizakaForm = makeCreateKrizakaForm(fields, FormRoot);\n\n/** The form sent by a Server Action: `useFormWithAction(signUp, createAccount)` — `signUp.useFormWithAction(createAccount)`. */\nexport function useFormWithAction<F extends { useFormWithAction: (...args: never[]) => unknown }>(form: F, ...args: Parameters<F[\"useFormWithAction\"]>): ReturnType<F[\"useFormWithAction\"]> {\n return form.useFormWithAction(...args) as ReturnType<F[\"useFormWithAction\"]>;\n}\n"
2097
2097
  },
2098
2098
  {
2099
2099
  "path": "meta.ts",
@@ -2462,13 +2462,13 @@
2462
2462
  },
2463
2463
  {
2464
2464
  "component": "FormField",
2465
- "description": "A field drawn by the app (a control the package does not have): the form's value, errors and ids, any markup.",
2465
+ "description": "A field drawn by the app (a control the package does not have): the form's value and errors, any markup.",
2466
2466
  "props": [
2467
2467
  {
2468
2468
  "name": "children",
2469
2469
  "type": "(field: KrizakaField<unknown>) => ReactNode",
2470
2470
  "default": null,
2471
- "description": "Draws the field: its value, setter, error and ids (`useKrizakaField`).",
2471
+ "description": "Draws the field: its value, setter, error (and the ids, on the web) (`useKrizakaField`).",
2472
2472
  "required": true
2473
2473
  },
2474
2474
  {
@@ -3138,13 +3138,13 @@
3138
3138
  },
3139
3139
  {
3140
3140
  "component": "Watch",
3141
- "description": "Renders from a field's current value (a part shown only when a box is ticked, a total).",
3141
+ "description": "Renders from a field's current value (a part shown only when a choice is made, a total).",
3142
3142
  "props": [
3143
3143
  {
3144
3144
  "name": "children",
3145
3145
  "type": "(value: unknown) => ReactNode",
3146
3146
  "default": null,
3147
- "description": "Draws from its current value: show a part of the form only when a box is ticked.",
3147
+ "description": "Draws from its current value: show a part of the form only when a choice is made.",
3148
3148
  "required": true
3149
3149
  },
3150
3150
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/ui",
3
- "version": "3.44.0",
3
+ "version": "3.46.0",
4
4
  "templatePurposes": {
5
5
  "convince": {
6
6
  "title": "Convince",
@@ -2148,7 +2148,7 @@
2148
2148
  "native": 0
2149
2149
  },
2150
2150
  "dependencies": [
2151
- "@krizaka/tokens@^3.44.0"
2151
+ "@krizaka/tokens@^3.46.0"
2152
2152
  ],
2153
2153
  "registryDependencies": []
2154
2154
  },
@@ -3175,7 +3175,7 @@
3175
3175
  "native": 2
3176
3176
  },
3177
3177
  "dependencies": [
3178
- "@krizaka/tokens@^3.44.0",
3178
+ "@krizaka/tokens@^3.46.0",
3179
3179
  "@protomaps/basemaps@5.7.2",
3180
3180
  "tailwind-variants@^3.3.1"
3181
3181
  ],
@@ -3614,11 +3614,12 @@
3614
3614
  "native": 0
3615
3615
  },
3616
3616
  "dependencies": [
3617
- "@krizaka/tokens@^3.44.0",
3617
+ "@krizaka/tokens@^3.46.0",
3618
3618
  "radix-ui@^1.7.0",
3619
3619
  "tailwind-variants@^3.3.1"
3620
3620
  ],
3621
3621
  "registryDependencies": [
3622
+ "button",
3622
3623
  "cn",
3623
3624
  "language-switch",
3624
3625
  "sites"
@@ -4278,7 +4279,7 @@
4278
4279
  "native": 0
4279
4280
  },
4280
4281
  "dependencies": [
4281
- "@krizaka/tokens@^3.44.0",
4282
+ "@krizaka/tokens@^3.46.0",
4282
4283
  "radix-ui@^1.7.0",
4283
4284
  "tailwind-variants@^3.3.1"
4284
4285
  ],
@@ -4649,7 +4650,7 @@
4649
4650
  "native": 0
4650
4651
  },
4651
4652
  "dependencies": [
4652
- "@krizaka/tokens@^3.44.0",
4653
+ "@krizaka/tokens@^3.46.0",
4653
4654
  "radix-ui@^1.7.0",
4654
4655
  "tailwind-variants@^3.3.1"
4655
4656
  ],
package/registry/map.json CHANGED
@@ -1074,7 +1074,7 @@
1074
1074
  }
1075
1075
  ],
1076
1076
  "dependencies": [
1077
- "@krizaka/tokens@^3.44.0",
1077
+ "@krizaka/tokens@^3.46.0",
1078
1078
  "@protomaps/basemaps@5.7.2",
1079
1079
  "tailwind-variants@^3.3.1"
1080
1080
  ],
@@ -118,7 +118,7 @@
118
118
  },
119
119
  {
120
120
  "name": "thresholds",
121
- "type": "{ readonly low: number; readonly high: number; }",
121
+ "type": "MeterThresholds",
122
122
  "default": null,
123
123
  "description": "Ratios (0–1) where the tone changes: past `high` it is in danger, past `low` in warning — or the reverse with\n`optimum=\"high\"` (a balance: the lower, the worse). Without thresholds the fill is the brand accent.",
124
124
  "required": false
@@ -271,7 +271,7 @@
271
271
  },
272
272
  {
273
273
  "name": "thresholds",
274
- "type": "{ readonly low: number; readonly high: number; }",
274
+ "type": "MeterThresholds",
275
275
  "default": null,
276
276
  "description": "The shares where the tone changes: below `low` good, up to `high` warning, past it the other end.",
277
277
  "required": false
@@ -342,7 +342,11 @@
342
342
  },
343
343
  {
344
344
  "path": "meter/meter.tsx",
345
- "content": "// Server-safe. A measure within a known range — a quota, a balance, a share — as opposed to the progress of a task.\n// `role=\"meter\"`, its value in words; thresholds turn it to warning then danger (or the reverse when high is good);\n// split into parts (90 % to the creator, 10 % to the platform) it becomes a stacked bar or ring with its legend: the\n// text version of what it draws.\nimport type * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nexport const meter = tv({\n slots: {\n root: \"flex w-full flex-col gap-1.5\",\n header: \"flex items-baseline gap-2 text-xs\",\n caption: \"min-w-0 flex-1 truncate font-medium text-fg\",\n figure: \"shrink-0 tabular-nums text-fg-secondary\",\n track: \"relative flex h-2 w-full gap-0.5 overflow-hidden rounded-sm bg-surface-3\",\n fill: \"h-full rounded-sm transition-[width] duration-700 motion-reduce:transition-none\",\n legend: \"mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-fg-secondary\",\n swatch: \"inline-block h-2 w-2 shrink-0 rounded-full\",\n ring: \"relative inline-flex shrink-0 items-center justify-center\",\n center: \"absolute inset-0 flex flex-col items-center justify-center text-center\",\n },\n variants: {\n size: { sm: { track: \"h-1.5\", ring: \"h-14 w-14\" }, md: { ring: \"h-24 w-24\" }, lg: { track: \"h-3\", ring: \"h-36 w-36\" } },\n },\n defaultVariants: { size: \"md\" },\n});\n\n/** The colour of a fill: by tone (thresholds) or by part (the accent, a neutral, accent-2, a lighter neutral). */\nconst BG = { ok: \"bg-accent\", success: \"bg-success\", warning: \"bg-warning\", danger: \"bg-danger\" } as const;\nconst STROKE = { ok: \"stroke-accent\", success: \"stroke-success\", warning: \"stroke-warning\", danger: \"stroke-danger\" } as const;\nconst PART_BG = [\"bg-accent\", \"bg-fg-secondary\", \"bg-accent-2\", \"bg-border-strong\"];\nconst PART_STROKE = [\"stroke-accent\", \"stroke-fg-secondary\", \"stroke-accent-2\", \"stroke-border-strong\"];\n\nexport interface MeterPart {\n /** Its name (“To the creator”) — passed translated. */\n label: string;\n /** Its value, in the meter's unit. */\n value: number;\n /** Its value in words (“€9.00”), in the legend and for assistive technology; default: its percentage. */\n valueText?: string;\n}\n\nexport type MeterProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n /** The accessible name (“Storage”, “Split of a tip”) — passed translated; shown with `showLabel`. */\n label: string;\n /** The value, between `min` and `max` (ignored with `parts`). */\n value?: number;\n /**\n * The bottom of the range.\n * @default 0\n */\n min?: number;\n /**\n * The top of the range.\n * @default 100\n */\n max?: number;\n /** The value in words (“42 GB of 50 GB”) — passed translated; default: the percentage. */\n valueText?: string;\n /**\n * Ratios (0–1) where the tone changes: past `high` it is in danger, past `low` in warning — or the reverse with\n * `optimum=\"high\"` (a balance: the lower, the worse). Without thresholds the fill is the brand accent.\n */\n thresholds?: { readonly low: number; readonly high: number };\n /**\n * Which end is good.\n * @default \"low\"\n */\n optimum?: \"low\" | \"high\";\n /** Split into parts that add up to `max`: a stacked bar or ring, with its legend. */\n parts?: readonly MeterPart[];\n /**\n * bar · ring.\n * @default \"bar\"\n */\n variant?: \"bar\" | \"ring\";\n /**\n * sm · md · lg: the bar's thickness, the ring's diameter.\n * @default \"md\"\n */\n size?: \"sm\" | \"md\" | \"lg\";\n /** Shows the label and the value above the bar. */\n showLabel?: boolean;\n /** A ring's centre: the value, a word. */\n children?: React.ReactNode;\n};\n\n/** A measure within a range — a quota, a balance, a share — toned by thresholds or split into parts. */\nexport function Meter({\n label,\n value = 0,\n min = 0,\n max = 100,\n valueText,\n thresholds,\n optimum = \"low\",\n parts,\n variant = \"bar\",\n size = \"md\",\n showLabel,\n className,\n children,\n ...props\n}: MeterProps) {\n const s = meter({ size });\n const span = max - min || 1;\n const ratio = Math.min(1, Math.max(0, (value - min) / span));\n const level = !thresholds ? \"ok\" : ratio >= thresholds.high ? (optimum === \"low\" ? \"danger\" : \"success\") : ratio >= thresholds.low ? \"warning\" : optimum === \"low\" ? \"success\" : \"danger\";\n const words = valueText ?? `${Math.round(ratio * 100)} %`;\n const shares = (parts ?? []).map((p, i) => ({ ...p, i, ratio: Math.min(1, Math.max(0, p.value / span)), words: p.valueText ?? `${Math.round((p.value / span) * 100)} %` }));\n const a11y = parts\n ? { role: \"group\" as const, \"aria-label\": label }\n : { role: \"meter\" as const, \"aria-label\": label, \"aria-valuemin\": min, \"aria-valuemax\": max, \"aria-valuenow\": Math.min(max, Math.max(min, value)), \"aria-valuetext\": words };\n const legend = parts && (\n <ul className={s.legend()}>\n {shares.map((p) => (\n <li key={p.i} className=\"flex items-center gap-1.5\">\n <span aria-hidden className={[s.swatch(), PART_BG[p.i % PART_BG.length]].join(\" \")} />\n <span className=\"text-fg\">{p.label}</span>\n <span className=\"tabular-nums\">{p.words}</span>\n </li>\n ))}\n </ul>\n );\n\n if (variant === \"ring\") {\n const r = 44;\n let offset = 0;\n const arcs = parts\n ? shares.map((p) => {\n const arc = { cls: PART_STROKE[p.i % PART_STROKE.length]!, len: p.ratio * 100, start: offset };\n offset += arc.len;\n return arc;\n })\n : [{ cls: STROKE[level], len: ratio * 100, start: 0 }];\n return (\n <div {...a11y} data-level={parts ? undefined : level} className={[\"inline-flex flex-col items-center gap-2\", className].filter(Boolean).join(\" \")} {...props}>\n <div className={s.ring()}>\n <svg viewBox=\"0 0 100 100\" className=\"h-full w-full -rotate-90\" aria-hidden>\n <circle cx=\"50\" cy=\"50\" r={r} fill=\"none\" strokeWidth=\"9\" className=\"stroke-border-default\" />\n {arcs.map((a, i) => (\n <circle\n key={i}\n cx=\"50\"\n cy=\"50\"\n r={r}\n fill=\"none\"\n strokeWidth=\"9\"\n pathLength={100}\n strokeDasharray={`${Math.max(0, a.len - (parts ? 1 : 0))} ${100 - Math.max(0, a.len - (parts ? 1 : 0))}`}\n strokeDashoffset={-a.start}\n className={[a.cls, \"transition-[stroke-dasharray] duration-700 motion-reduce:transition-none\"].join(\" \")}\n />\n ))}\n </svg>\n {children !== undefined && <div className={s.center()}>{children}</div>}\n </div>\n {legend}\n </div>\n );\n }\n\n return (\n <div {...a11y} data-level={parts ? undefined : level} className={s.root({ className })} {...props}>\n {showLabel && (\n <div aria-hidden className={s.header()}>\n <span className={s.caption()}>{label}</span>\n {!parts && <span className={s.figure()}>{words}</span>}\n </div>\n )}\n <div aria-hidden className={s.track()}>\n {parts ? (\n shares.map((p) => <span key={p.i} className={[s.fill(), PART_BG[p.i % PART_BG.length]].join(\" \")} style={{ width: `${p.ratio * 100}%` }} />)\n ) : (\n <span className={[s.fill(), BG[level]].join(\" \")} style={{ width: `${ratio * 100}%` }} />\n )}\n </div>\n {legend}\n </div>\n );\n}\n\nexport interface MeterValueProps {\n /** The figure. */\n value: React.ReactNode;\n /** A word under it. */\n caption?: React.ReactNode;\n}\n\n/** For the ring's centre when it shows a figure and a word. */\nexport function MeterValue({ value, caption }: MeterValueProps) {\n return (\n <>\n <span className=\"font-display text-lg font-semibold text-fg tabular-nums\">{value}</span>\n {caption && <span className=\"text-[10px] text-fg-secondary\">{caption}</span>}\n </>\n );\n}\n"
345
+ "content": "// Server-safe. A measure within a known range — a quota, a balance, a share — as opposed to the progress of a task.\n// `role=\"meter\"`, its value in words; thresholds turn it to warning then danger (or the reverse when high is good);\n// split into parts (90 % to the creator, 10 % to the platform) it becomes a stacked bar or ring with its legend: the\n// text version of what it draws.\nimport type * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { type MeterLevel, meterModel, type MeterPart, type MeterThresholds } from \"./model\";\n\nexport type { MeterPart };\n\nexport const meter = tv({\n slots: {\n root: \"flex w-full flex-col gap-1.5\",\n header: \"flex items-baseline gap-2 text-xs\",\n caption: \"min-w-0 flex-1 truncate font-medium text-fg\",\n figure: \"shrink-0 tabular-nums text-fg-secondary\",\n track: \"relative flex h-2 w-full gap-0.5 overflow-hidden rounded-sm bg-surface-3\",\n fill: \"h-full rounded-sm transition-[width] duration-700 motion-reduce:transition-none\",\n legend: \"mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-fg-secondary\",\n swatch: \"inline-block h-2 w-2 shrink-0 rounded-full\",\n ring: \"relative inline-flex shrink-0 items-center justify-center\",\n center: \"absolute inset-0 flex flex-col items-center justify-center text-center\",\n },\n variants: {\n size: { sm: { track: \"h-1.5\", ring: \"h-14 w-14\" }, md: { ring: \"h-24 w-24\" }, lg: { track: \"h-3\", ring: \"h-36 w-36\" } },\n },\n defaultVariants: { size: \"md\" },\n});\n\n/** The colour of a fill: by tone (thresholds) or by part (the accent, a neutral, accent-2, a lighter neutral). */\nconst BG: Record<MeterLevel, string> = { ok: \"bg-accent\", success: \"bg-success\", warning: \"bg-warning\", danger: \"bg-danger\" };\nconst STROKE: Record<MeterLevel, string> = { ok: \"stroke-accent\", success: \"stroke-success\", warning: \"stroke-warning\", danger: \"stroke-danger\" };\nconst PART_BG = [\"bg-accent\", \"bg-fg-secondary\", \"bg-accent-2\", \"bg-border-strong\"];\nconst PART_STROKE = [\"stroke-accent\", \"stroke-fg-secondary\", \"stroke-accent-2\", \"stroke-border-strong\"];\n\nexport type MeterProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n /** The accessible name (“Storage”, “Split of a tip”) — passed translated; shown with `showLabel`. */\n label: string;\n /** The value, between `min` and `max` (ignored with `parts`). */\n value?: number;\n /**\n * The bottom of the range.\n * @default 0\n */\n min?: number;\n /**\n * The top of the range.\n * @default 100\n */\n max?: number;\n /** The value in words (“42 GB of 50 GB”) — passed translated; default: the percentage. */\n valueText?: string;\n /**\n * Ratios (0–1) where the tone changes: past `high` it is in danger, past `low` in warning — or the reverse with\n * `optimum=\"high\"` (a balance: the lower, the worse). Without thresholds the fill is the brand accent.\n */\n thresholds?: MeterThresholds;\n /**\n * Which end is good.\n * @default \"low\"\n */\n optimum?: \"low\" | \"high\";\n /** Split into parts that add up to `max`: a stacked bar or ring, with its legend. */\n parts?: readonly MeterPart[];\n /**\n * bar · ring.\n * @default \"bar\"\n */\n variant?: \"bar\" | \"ring\";\n /**\n * sm · md · lg: the bar's thickness, the ring's diameter.\n * @default \"md\"\n */\n size?: \"sm\" | \"md\" | \"lg\";\n /** Shows the label and the value above the bar. */\n showLabel?: boolean;\n /** A ring's centre: the value, a word. */\n children?: React.ReactNode;\n};\n\n/** A measure within a range — a quota, a balance, a share — toned by thresholds or split into parts. */\nexport function Meter({\n label,\n value = 0,\n min = 0,\n max = 100,\n valueText,\n thresholds,\n optimum = \"low\",\n parts,\n variant = \"bar\",\n size = \"md\",\n showLabel,\n className,\n children,\n ...props\n}: MeterProps) {\n const s = meter({ size });\n const { ratio, level, words, shares, arcs } = meterModel({ value, min, max, valueText, thresholds, optimum, parts });\n const a11y = parts\n ? { role: \"group\" as const, \"aria-label\": label }\n : { role: \"meter\" as const, \"aria-label\": label, \"aria-valuemin\": min, \"aria-valuemax\": max, \"aria-valuenow\": Math.min(max, Math.max(min, value)), \"aria-valuetext\": words };\n const legend = parts && (\n <ul className={s.legend()}>\n {shares.map((p) => (\n <li key={p.i} className=\"flex items-center gap-1.5\">\n <span aria-hidden className={[s.swatch(), PART_BG[p.i % PART_BG.length]].join(\" \")} />\n <span className=\"text-fg\">{p.label}</span>\n <span className=\"tabular-nums\">{p.words}</span>\n </li>\n ))}\n </ul>\n );\n\n if (variant === \"ring\") {\n const r = 44;\n return (\n <div {...a11y} data-level={parts ? undefined : level} className={[\"inline-flex flex-col items-center gap-2\", className].filter(Boolean).join(\" \")} {...props}>\n <div className={s.ring()}>\n <svg viewBox=\"0 0 100 100\" className=\"h-full w-full -rotate-90\" aria-hidden>\n <circle cx=\"50\" cy=\"50\" r={r} fill=\"none\" strokeWidth=\"9\" className=\"stroke-border-default\" />\n {arcs.map((arc, i) => {\n const length = Math.max(0, arc.len * 100 - (parts ? 1 : 0));\n return (\n <circle\n key={i}\n cx=\"50\"\n cy=\"50\"\n r={r}\n fill=\"none\"\n strokeWidth=\"9\"\n pathLength={100}\n strokeDasharray={`${length} ${100 - length}`}\n strokeDashoffset={-arc.start * 100}\n className={[arc.part === undefined ? STROKE[level] : PART_STROKE[arc.part % PART_STROKE.length], \"transition-[stroke-dasharray] duration-700 motion-reduce:transition-none\"].join(\" \")}\n />\n );\n })}\n </svg>\n {children !== undefined && <div className={s.center()}>{children}</div>}\n </div>\n {legend}\n </div>\n );\n }\n\n return (\n <div {...a11y} data-level={parts ? undefined : level} className={s.root({ className })} {...props}>\n {showLabel && (\n <div aria-hidden className={s.header()}>\n <span className={s.caption()}>{label}</span>\n {!parts && <span className={s.figure()}>{words}</span>}\n </div>\n )}\n <div aria-hidden className={s.track()}>\n {parts ? (\n shares.map((p) => <span key={p.i} className={[s.fill(), PART_BG[p.i % PART_BG.length]].join(\" \")} style={{ width: `${p.ratio * 100}%` }} />)\n ) : (\n <span className={[s.fill(), BG[level]].join(\" \")} style={{ width: `${ratio * 100}%` }} />\n )}\n </div>\n {legend}\n </div>\n );\n}\n\nexport interface MeterValueProps {\n /** The figure. */\n value: React.ReactNode;\n /** A word under it. */\n caption?: React.ReactNode;\n}\n\n/** For the ring's centre when it shows a figure and a word. */\nexport function MeterValue({ value, caption }: MeterValueProps) {\n return (\n <>\n <span className=\"font-display text-lg font-semibold text-fg tabular-nums\">{value}</span>\n {caption && <span className=\"text-[10px] text-fg-secondary\">{caption}</span>}\n </>\n );\n}\n"
346
+ },
347
+ {
348
+ "path": "meter/model.ts",
349
+ "content": "// Headless: the arithmetic of a Meter, read by the web (`meter.tsx`) and the native (`native/meter.tsx`) alike.\n\nexport interface MeterPart {\n /** Its name (“To the creator”) — passed translated. */\n label: string;\n /** Its value, in the meter's unit. */\n value: number;\n /** Its value in words (“€9.00”), in the legend and for assistive technology; default: its percentage. */\n valueText?: string;\n}\n\n/** Which end of the range is good: `low` (a quota) or `high` (a score, a balance). */\nexport const MeterOptimum = { Low: \"low\", High: \"high\" } as const;\nexport type MeterOptimum = (typeof MeterOptimum)[keyof typeof MeterOptimum];\n\n/** The tone of a fill: the accent without thresholds, otherwise good, warning or bad. */\nexport const MeterLevel = { Ok: \"ok\", Success: \"success\", Warning: \"warning\", Danger: \"danger\" } as const;\nexport type MeterLevel = (typeof MeterLevel)[keyof typeof MeterLevel];\n\nexport interface MeterThresholds {\n readonly low: number;\n readonly high: number;\n}\n\nexport interface MeterInput {\n value?: number;\n min?: number;\n max?: number;\n valueText?: string;\n thresholds?: MeterThresholds;\n optimum?: MeterOptimum;\n parts?: readonly MeterPart[];\n}\n\nexport interface MeterShare extends MeterPart {\n i: number;\n /** Its share of the range, 0–1. */\n ratio: number;\n /** Its value in words. */\n words: string;\n}\n\n/** One arc of a ring, as fractions of the circle: the part it draws (none for the whole value), its length, where it starts. */\nexport interface MeterArc {\n part: number | undefined;\n len: number;\n start: number;\n}\n\nexport interface MeterModel {\n ratio: number;\n level: MeterLevel;\n words: string;\n shares: MeterShare[];\n arcs: MeterArc[];\n}\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\nconst percent = (ratio: number) => `${Math.round(ratio * 100)} %`;\n\nfunction levelOf(ratio: number, thresholds: MeterThresholds | undefined, optimum: MeterOptimum): MeterLevel {\n if (!thresholds) return MeterLevel.Ok;\n const good = optimum === MeterOptimum.Low ? MeterLevel.Success : MeterLevel.Danger;\n const bad = optimum === MeterOptimum.Low ? MeterLevel.Danger : MeterLevel.Success;\n if (ratio >= thresholds.high) return bad;\n return ratio >= thresholds.low ? MeterLevel.Warning : good;\n}\n\n/** The ratio, tone, words, parts and ring arcs of a meter. */\nexport function meterModel({ value = 0, min = 0, max = 100, valueText, thresholds, optimum = MeterOptimum.Low, parts }: MeterInput): MeterModel {\n const span = max - min || 1;\n const ratio = clamp01((value - min) / span);\n const shares = (parts ?? []).map((p, i) => ({ ...p, i, ratio: clamp01(p.value / span), words: p.valueText ?? percent(p.value / span) }));\n let start = 0;\n const arcs = parts\n ? shares.map((p) => {\n const arc = { part: p.i, len: p.ratio, start };\n start += p.ratio;\n return arc;\n })\n : [{ part: undefined, len: ratio, start: 0 }];\n return { ratio, level: levelOf(ratio, thresholds, optimum), words: valueText ?? percent(ratio), shares, arcs };\n}\n"
346
350
  }
347
351
  ],
348
352
  "dependencies": [
@@ -416,7 +420,7 @@
416
420
  },
417
421
  {
418
422
  "name": "thresholds",
419
- "type": "{ readonly low: number; readonly high: number; }",
423
+ "type": "MeterThresholds",
420
424
  "default": null,
421
425
  "description": "Ratios (0–1) where the tone changes: past `high` it is in danger, past `low` in warning — or the reverse with\n`optimum=\"high\"` (a balance: the lower, the worse). Without thresholds the fill is the brand accent.",
422
426
  "required": false