Skip to content

Conversation

logaretm
Copy link
Member

@logaretm logaretm commented Aug 19, 2025

What

This PR breaks up the field composables into smaller, more granular composables for use in UI libraries that follow the field and control pattern.

How

A form-field can be defined as:

  • A control that the user interacts with.
  • A group of labels/descriptions, and error messages.

This PR models this relationship by breaking a field composable into:

  • use___Control: Handles all the logic related to the control (e.g: Interactions and props).
  • useFormField: Handles the state and labeling/descriptions/error message relationships with the control.

Usage

There are a couple of configurations to put all three composables together. A same-component approach and injectable approach.

Let's take a look at a TextField implementation.

Same component approach

Similar to how Formwerk will expose the full field composables, where all three are composed in the same composable. In this case, the labeling is colocated with the control.

// TextField.vue
// Build the state
 const field = useFormField<string | undefined>({
  label: props.label,
  description: props.description,
  path: props.name,
  initialValue: toValue(props.modelValue) ?? toValue(props.value),
  disabled: props.disabled,
  schema: props.schema,
});

// Build the control
const { inputEl, inputProps } = useTextControl(props, { field });

Injection Approach

This suits UI libraries more as they usually maintain their separate FormField component.

At a FormField component implementation:

// FormField.vue
const field = useFormField<string | undefined>({
  label: props.label,
  description: props.description,
  path: props.name,
  initialValue: toValue(props.modelValue) ?? toValue(props.value),
  disabled: props.disabled,
  schema: props.schema,
});

Then, at the control level, like an <Input > component, you only need to call the useTextControl composable:

// Input.vue
const { inputEl, inputProps } = useTextControl(props);

Under the hood the control composable will find the form field context and hook itself up.

Misc

supports genu/ui#1

Copy link

changeset-bot bot commented Aug 19, 2025

🦋 Changeset detected

Latest commit: 58cfafe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@formwerk/core Minor
@formwerk/devtools Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@logaretm logaretm changed the title feat: initial API draft feat: granular field composables Aug 19, 2025
Copy link

pkg-pr-new bot commented Aug 19, 2025

Open in StackBlitz

npm i https://pkg.pr.new/formwerkjs/formwerk/@formwerk/core@205
npm i https://pkg.pr.new/formwerkjs/formwerk/@formwerk/devtools@205

commit: 57c8b75

@genu
Copy link
Contributor

genu commented Aug 21, 2025

@logaretm Say in the Injectable Approach we want to use some value from the state.

Would we use useFieldStateInjection() alongside useTextControl?

So, we would do something like this?

const { errorMessage } = useFieldStateInjection()!
const { inputEl, inputProps } = useTextControl({  name })

@logaretm
Copy link
Member Author

const { errorMessage } = useFieldStateInjection()!
const { inputEl, inputProps } = useTextControl({  name })

You can do that if you want to, as useFieldStateInjection doesn't create the state object it just injects it. Is there a limitation you are trying to bypass?

@logaretm logaretm force-pushed the feat/granular-controls branch 2 times, most recently from 95d0361 to 1342a64 Compare August 21, 2025 08:52
@logaretm
Copy link
Member Author

logaretm commented Aug 21, 2025

@genu Thinking about combining the useFieldState and useFormField because its too confusing to have two required pieces detached like that. What do you think?

EDIT: I just pushed this, feels much cleaner and will make migrating other fields easier.

@genu
Copy link
Contributor

genu commented Aug 21, 2025

@genu Thinking about combining the useFieldState and useFormField because its too confusing to have two required pieces detached like that. What do you think?

EDIT: I just pushed this, feels much cleaner and will make migrating other fields easier.

Just pulled the latest. It definitely feels more elegant 🚀

@logaretm logaretm force-pushed the feat/granular-controls branch 3 times, most recently from 3a85049 to 6705570 Compare August 26, 2025 13:52
@genu
Copy link
Contributor

genu commented Aug 27, 2025

@logaretm Should we export the inputId from use__Control.ts or useFormField() or both? This can be useful when adding extra elements with ids.

For example, in NuxtUi, a FormField has a hint and a help. Currently, I'm doing this:

const { labelProps  } = useFormField(...)
<span :id="`${labelProps?.id}-hint`">...</span>
<div :id="`${labelProps?.id}-help`">...</div>      

): ExposedField<TValue> & TReturns {
const exposedField = {
return {
_field: markRaw(field),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@logaretm Do we need to expose _field to the user?

I'm thinking that it can be confusing as most of the _field properties are already exposed.

Would there be any benefit to be able to extract a property from both?

const { isTouched, _field } = useTextField({ label: 'label' })

const v1 = isTouched.value
const v2 = _field.isTouched.value

Comment on lines 42 to 72
return exposeField(
{
/**
* The id of the control element.
*/
controlId,

/**
* Props for the control element/group.
*/
controlProps,
/**
* Validates the field.
*/
validate: updateValidity,
},
field,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@logaretm Should there be distinction between useFormField, use__Control, and use__Field when we expose properties?

For example, at the moment, we can do:

const { inputEl, inputProps, labelProps } = useTextControl(...)

but a Text control, technically, wouldn't ever have a label, as it would be a lower level control.

@logaretm logaretm force-pushed the feat/granular-controls branch from 12abf4d to 8d838e0 Compare September 8, 2025 19:13
Comment on lines +55 to +72
/**
* The id of the control element.
*/
controlId,

/**
* Props for the control element/group.
*/
controlProps,
/**
* Validates the field.
*/
validate: updateValidity,

/**
* The field state.
*/
field,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we talked about exposing controlEl here.

@logaretm logaretm force-pushed the feat/granular-controls branch from 48ac966 to e732324 Compare September 18, 2025 09:34
logaretm and others added 2 commits September 18, 2025 12:35
* chore: register with devtools in `useFormField`

* refactor: update registerField to accept Ref type and use

* feat: introduce BuiltInControlTypes for consistent control type

* chore: fix typo

* fix: types and type reactivity in devtools

---------

Co-authored-by: Abdelrahman Awad <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants