@texaryn/vue
v0.5.0
Published
Vue 3 bindings, composables, and default widgets for Texaryn
Maintainers
Readme
@texaryn/vue
Vue 3 bindings for Texaryn: composables, provide/inject context, a renderer and a small set of native widgets.
Status: pre-1.0. Public APIs may change before 1.0.
Install
pnpm add @texaryn/core @texaryn/schema-json @texaryn/vue vue@texaryn/vue requires Vue 3.5 or newer, for useId.
Quick start
main.ts creates the adapter before mounting and passes it to App as a prop,
because awaiting createJsonSchemaAdapter inside <script setup> would make
App an async component, which renders nothing as the root without a
<Suspense> boundary.
// schema.ts
export const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: { name: { type: 'string', title: 'Name' } },
required: ['name'],
}// main.ts
import { createApp } from 'vue'
import { createJsonSchemaAdapter } from '@texaryn/schema-json'
import App from './App.vue'
import { schema } from './schema'
const adapter = await createJsonSchemaAdapter(schema)
createApp(App, { adapter }).mount('#app')<!-- App.vue -->
<script setup lang="ts">
import type { SchemaEvaluationPort } from '@texaryn/core'
import { ErrorSummary, FormRoot, createDefaultRegistry, provideFormRuntime, useForm } from '@texaryn/vue'
const props = defineProps<{ adapter: SchemaEvaluationPort }>()
const form = useForm(props.adapter, { initialData: { name: '' } })
provideFormRuntime(form.runtime)
const registry = createDefaultRegistry()
</script>
<template>
<!-- Both have to be descendants, not this component itself -->
<ErrorSummary />
<FormRoot :registry="registry" />
</template>provideFormRuntime(runtime, { messages }) takes a whole FormMessages set
from @texaryn/core, as a value, a ref or a getter, and replaces every word
the built-in widgets invent. Custom widgets read it with useFormMessages(),
which returns a computed.
ErrorSummary renders a named group, headed by an h2, listing every visible
error with a link to the input the renderer mounted, #<prefix>-<nodeId>-input,
and nothing while there are none. It takes focus once a failed submit settles,
once per attempt; pass :focus="false" on all but one summary when one
runtime is rendered twice. It is not a live region: the fields already
announce their own errors, and the focus move is what speaks the heading.
useForm creates the runtime and ties it to the calling scope. Providing is a
separate call rather than a side effect of construction: React's equivalent is
<FormProvider> in the template, and Vue's is a setup call.
A component cannot inject what it provided itself, so FormRoot and anything
calling useField has to be a descendant of whichever component called
provideFormRuntime.
That call also opens the DOM id namespace for the form, which is why it sits
there rather than in FormRoot: a sibling added later shares the same scope.
Generated ids are opaque relationship identifiers rather than styling hooks, so
use classes or data attributes for CSS. Uniqueness is per Vue application, so
two independent apps on one page need distinct app.config.idPrefix values.
Lifetime is a requirement, not a convention
useForm and useStore throw when called outside setup() or an active
effectScope. Both own something that only teardown releases, a runtime with
live validation timers and a store subscription, and neither returns a handle
the caller could stop. Requiring a scope is what makes that ownership real:
the check runs before anything is created, so a misuse cannot leak the thing
it was about to allocate. Stopping the scope destroys the runtime and
unsubscribes.
What it does not depend on
@texaryn/core and Vue. Not @texaryn/react. The point of a second binding is
to find out what core actually promises, and reaching into the first binding
for a helper would answer a different question.
Node ids are accepted as refs or getters, and that matters
useField and useFieldArray take MaybeRefOrGetter<NodeId>, not NodeId.
Node ids follow position in the document. Array item identity is carried
separately as StableItemId, and a row keyed on that keeps its component
instance through a move while the node underneath it becomes a different one.
React re-reads the id on every render, so it follows for free. A Vue
composable that captured the id in setup would keep reading the old position
and keep writing to it, so typing into a moved row would overwrite the row
that took its place.
The same reasoning applies to reads. getNodeState is a map lookup with no
reactivity of its own, and the runtime replaces a node's state bundle when its
id leaves the document and returns. useField reads the document ref when it
resolves the bundle so a recompile re-resolves it.
Both are covered by tests that fail when the behaviour is removed.
Conformance
tests/example-conformance/vue.test.ts runs the whole catalog through this
package and makes the same two claims the React renderers make: every example
mounts without a console error or warning, and every active node in every
example resolves a widget. The framework-neutral half of that harness is
shared; mounting is not, because the two frameworks do not share a mounting
model.
No single file components
The package ships plain TypeScript with render functions, so it builds with
the same tsc --build as every other package in the workspace and needs no
Vue-specific toolchain to compile. Consumers are free to use SFCs; nothing
here requires them to.
Related packages
@texaryn/core, runtime and renderer contracts@texaryn/schema-json, JSON Schema adapter@texaryn/react, the React binding over the same runtime
See the repository README for the complete architecture.
License
Apache-2.0
