svecosystem/formsnap

RFC: Minimizing the API Surface

huntabyte opened this issue ยท 6 comments

When I started this library my goal was to make it as simple as possible for almost all possible scenarios/use-cases, which at this point has led to having 15 different ways to do the same thing. But I think now that we have the ability to initialize the SuperForm outside of Formsnap (#83), that the API surface can be drastically reduced.

For example, we have actions, which were designed for those who wanted to opt out of using the <Form.X /> components, and just use regular elements for scoped styles, transitions,etc. The idea was that you could just do this and it works:

<Form.Root>
  <Form.Field let:actions>
    <label use:actions.label> Name </label>
    <input use:actions.input />
  </Form.Field>
</Form.Root>

But the only reason that was introduced was to handle updating the value store, by attaching an event listener to the input on your behalf, since prior to controlled usage you couldn't do things like this:

<Form.Root>
  <Form.Field>
    <input bind:value={$form.name} />
  </Form.Field>
</Form.Root>

I think the API surface is a bit overwhelming and not as clean as I'd like, so I'm thinking we drop the whole actions idea altogether, and just focus on providing attrs for those who want to bring their own elements, which is really where the repetition comes in when you're trying to build accessible forms.

This would make controlled a requirement for BYOE, and result in something like this:

<script lang="ts">
  // imports
  const theForm = superForm(data.form, {
    validators: schema
  })
  const { form } = theForm
</script>

<Form.Root controlled form={theForm}>
  <Form.Field let:attrs let:errors>
    <label {...attrs.label}>Name</label>
    <input {...attrs.input} bind:value={$form.name} />
    <p {...attrs.description}>Enter your full name</p>
    <span {...attrs.validation}>
      {errors}
    </span>
  </Form.Field>
</Form.Root>

This would also remove the need for the handlers helpers provided as well, which would remove a lot of unnecessary complexity from the project.

How does everyone feel about this?

ollema commented

I think this sounds great!

Agreed, this sounds like an awesome direction for the project. Improving upon already existing awesomeness

bfovez commented

Yes, that is the good direction. I totally agree.

This feels much more ergonomic! Exposing the superform constructor already solved most the issues with headless usage but this just ties everything together nicely.

Okay, one issue I see with the example I provided is that internally we need knowledge of whether the validation & description are being used, otherwise we have aria-describedby attributes pointing at IDs that may not exist in the DOM, which is what we're using the actions for.

Internally, when that action is called, it sets some stores like hasValidation & hasDescription to true, which tells the field to update the attributes on the input/control to include those (description always & validation only when errors are present).

So unfortunately, we'll need to wrap those in the component with an asChild prop, which kinda sucks but realistically you define your own components with the wrappers, so you aren't having to repeat the process more than a couple of times.

So it would end up looking like this instead, which if you don't move the logic into your own comps looks like a lot:

<Form.Root controlled form={theForm}>
  <Form.Field let:errors>
	<Form.Label asChild let:attrs>
	    <label {...attrs}>Name</label>
	</Form.Label>
	<Form.Control let:attrs>
		<input {...attrs} bind:value={$form.name}/>
	</Form.Control>
	<Form.Description asChild let:attrs>
		<p {...attrs}>Enter your full name</p>
	</Form.Description>
	<Form.Validation asChild let:attrs>
	    <span {...attrs}>
	      {errors}
	    </span>
	</Form.Validation>
  </Form.Field>
</Form.Root>

But ideally, you'd do something like:

<!-- lib/components/Label.svelte -->
<script lang="ts">
	import { Form } from 'formsnap'
</script>

<Form.Label asChild let:attrs>
	<label {...attrs} class="scoped">
		<slot />
	</label>
</Form.Label>

<style>
	.scoped {
		color: green;
	}
</style>

and repeat for validation, description, etc., and use those components instead, which looks pretty lean and clean IMO.

<Form.Root controlled form={theForm}>
  <Form.Field let:errors>
	<Label>Name</Label>
	<Input bind:value={$form.name} />
	<Description>Enter your full name</Description>
	<Validation>{errors}</Validation>
  </Form.Field>
</Form.Root>

To address the issues of SSRing those attributes, we can also provide the ability to hardcode whether or not you're using a description/validation for a given field in addition to the actions, which would be SSR-able and address issues like this: (#88).

For that, they would just be optional props, as I'm not going to assume everyone is SSRing their apps, and default to false and get updated during the description/validation's onMount internally just as it does today.

This would look something like:

<Form.Field hasValidation hasDescription>
	<!-- ... -->
</Form.Field>

So everyone is in full control of how their forms behave with/without JS.

Would exposing the validation and description as slots work? So the Field wrapper can just check for $$slots.validation?