Skip to content

Commit

Permalink
Add focusOnFirstError feature
Browse files Browse the repository at this point in the history
  • Loading branch information
x0k committed Oct 11, 2024
1 parent 24d1dd5 commit 34042fa
Show file tree
Hide file tree
Showing 12 changed files with 135 additions and 9 deletions.
8 changes: 8 additions & 0 deletions .changeset/fair-chairs-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sjsf/daisyui-theme": patch
"playground": patch
"@sjsf/form": patch
"docs": patch
---

Add `focusOnFirstError` feature
24 changes: 24 additions & 0 deletions apps/docs/src/content/docs/customization/focus-on-first-error.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: Focus on first error
sidebar:
order: 2
---

You can achieve focus on the first error by using the `focusOnFirstError` function.

## Usage

```svelte
<script lang="ts">
import { Form } from "@sjsf/form";
import { focusOnFirstError } from '@sjsf/form/focus-on-first-error';
</script>
<Form {...} onSubmitError={focusOnFirstError} />
```

## Explanation

1. `focusOnFirstError` will try to find a focusable element and focus it.
2. If it's not found, it will try to find an errors list and scroll it into view.
3. If it's not found, it will return `false`.
13 changes: 11 additions & 2 deletions apps/playground/src/app.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
type ValidatorError,
} from "@sjsf/form";
import { translation } from "@sjsf/form/translations/en";
import { themes, themeStyles } from './themes'
import { AjvValidator, addFormComponents, DEFAULT_AJV_CONFIG } from "@sjsf/ajv8-validator";
import { focusOnFirstError } from '@sjsf/form/focus-on-first-error';
import { themes, themeStyles } from './themes'
import { ShadowHost } from "./shadow";
import Github from "./github.svelte";
import OpenBook from "./open-book.svelte";
Expand Down Expand Up @@ -64,6 +65,7 @@
let readonly = $state(false);
let html5Validation = $state(false);
let errorsList = $state(true);
let doFocusOnFirstError = $state(true);
let errors = $state.raw<ValidatorError<any>[]>(
samples[initialSampleName].errors ?? []
);
Expand Down Expand Up @@ -96,6 +98,10 @@
<input type="checkbox" bind:checked={errorsList} />
Errors list
</label>
<label>
<input type="checkbox" bind:checked={doFocusOnFirstError} />
Focus on first error
</label>
<select bind:value={themeName} onchange={() => selectTheme(themeName)}>
{#each Object.keys(themes) as name (name)}
<option value={name}>{name}</option>
Expand Down Expand Up @@ -185,7 +191,10 @@
onSubmit={(value) => {
console.log("submit", value);
}}
onSubmitError={(errors) => {
onSubmitError={(errors, e) => {
if (doFocusOnFirstError) {
focusOnFirstError(errors, e);
}
console.log("errors", errors);
}}
/>
Expand Down
8 changes: 8 additions & 0 deletions mkfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@ f/:

docs/:
pushd apps/docs
d:
pnpm run dev
b:
pnpm run build
popd

pl/:
pushd apps/playground
d:
pnpm run dev
b:
pnpm run build
popd

daisy/:
Expand Down
4 changes: 2 additions & 2 deletions packages/daisyui-theme/src/lib/components/errors-list.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script lang="ts">
import type { ComponentProps } from "@sjsf/form";
const { errors }: ComponentProps<"errorsList"> = $props();
const { errors, forId }: ComponentProps<"errorsList"> = $props();
</script>

<ui class="text-error">
<ui class="text-error" data-errors-for={forId}>
{#each errors as err}
<li>{err.message}</li>
{/each}
Expand Down
5 changes: 5 additions & 0 deletions packages/form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"types": "./dist/basic-theme/index.d.ts",
"svelte": "./dist/basic-theme/index.js",
"default": "./dist/basic-theme/index.js"
},
"./focus-on-first-error": {
"types": "./dist/focus-on-first-error.d.ts",
"svelte": "./dist/focus-on-first-error.js",
"default": "./dist/focus-on-first-error.js"
}
}
}
4 changes: 2 additions & 2 deletions packages/form/src/basic-theme/components/errors-list.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script lang="ts">
import type { ComponentProps } from "@/form/index.js";
const { errors }: ComponentProps<"errorsList"> = $props();
const { errors, forId }: ComponentProps<"errorsList"> = $props();
</script>

<ui style="color: red;">
<ui style="color: red;" data-errors-for={forId}>
{#each errors as err}
<li>{err.message}</li>
{/each}
Expand Down
71 changes: 71 additions & 0 deletions packages/form/src/focus-on-first-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { tick } from "svelte";

import {
ValidatorErrorType,
type ValidatorError,
type ValidationError,
} from "@/core/validator.js";

export function getFocusableElement(
form: HTMLElement,
error: ValidationError<unknown>
) {
const item = form.querySelector(`[id="${error.instanceId}"]`);
if (
!(
(item instanceof HTMLInputElement && item.type !== "checkbox") ||
item instanceof HTMLTextAreaElement ||
item instanceof HTMLSelectElement ||
item instanceof HTMLButtonElement
)
) {
return null;
}
return item;
}

export function getErrorsList(
form: HTMLElement,
error: ValidationError<unknown>
) {
return form.querySelector(`[data-errors-for="${error.instanceId}"]`);
}

export function getFocusAction(
form: HTMLElement,
error: ValidationError<unknown>
) {
const focusableElement = getFocusableElement(form, error);
if (focusableElement !== null) {
return () => focusableElement.focus();
}
const errorsList = getErrorsList(form, error);
if (errorsList !== null) {
return () =>
errorsList.scrollIntoView({ behavior: "auto", block: "center" });
}
return null;
}

export function focusOnFirstError(
errors: ValidatorError<unknown>[],
e: SubmitEvent
) {
const form = e.target;
if (!(form instanceof HTMLElement)) {
console.warn("Expected form to be an HTMLFormElement, got", form);
return false;
}
const error = errors.find(
(err) => err.type === ValidatorErrorType.ValidationError
);
if (!error) {
return false;
}
const focusAction = getFocusAction(form, error);
if (focusAction === null) {
return false;
}
// NOTE: We use tick here because new errors may produce layout changes.
return tick().then(focusAction);
}
1 change: 1 addition & 0 deletions packages/form/src/form/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface HelpComponentProps {
}

export interface ErrorsListProps {
forId: string;
errors: ValidationError<unknown>[];
}

Expand Down
2 changes: 1 addition & 1 deletion packages/form/src/form/templates/array-template.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ import { getTemplateProps } from './get-template-props.js';
</Layout>
{@render addButton?.()}
{#if errors.length > 0}
<ErrorsList {errors} {config} />
<ErrorsList forId={config.idSchema.$id} {errors} {config} />
{/if}
</Layout>
2 changes: 1 addition & 1 deletion packages/form/src/form/templates/field-template.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
{@render children()}
</Layout>
{#if errors.length > 0}
<ErrorsList {errors} {config} />
<ErrorsList forId={config.idSchema.$id} {errors} {config} />
{/if}
{#if config.uiOptions?.help !== undefined}
<Help help={config.uiOptions.help} {config} {errors} />
Expand Down
2 changes: 1 addition & 1 deletion packages/form/src/form/templates/object-template.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ import { getFormContext } from '../context.js';
</Layout>
{@render addButton?.()}
{#if errors.length > 0}
<ErrorsList {errors} {config} />
<ErrorsList forId={config.idSchema.$id} {errors} {config} />
{/if}
</Layout>

0 comments on commit 34042fa

Please sign in to comment.