Forms
Input
A text field that always has a label, with optional hint and error text.
Installation
npx sinaui add inputCopy src/lib/ui/Input.svelte and tokens.css. No dependencies.
Examples
Basic
<script lang="ts">
import Input from '#lib/ui/Input.svelte';
</script>
<Input label="Title of the work" placeholder="Kitab al-Manazir" />With a hint
The hint is read out after the label, so instructions reach screen reader users too.
As it appears in the House of Wisdom register.
<script lang="ts">
import Input from '#lib/ui/Input.svelte';
</script>
<Input
label="Full name"
placeholder="Abu Ali al-Husayn ibn Sina"
autocomplete="name"
hint="As it appears in the House of Wisdom register."
/>With an error
An error marks the field invalid and is read out with it. Type a full address to clear it.
Enter an email like khwarizmi@baytalhikma.org.
<script lang="ts">
import Input from '#lib/ui/Input.svelte';
let email = $state('khwarizmi@');
const error = $derived(
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)
? undefined
: 'Enter an email like khwarizmi@baytalhikma.org.'
);
</script>
<Input label="Email" type="email" autocomplete="email" bind:value={email} {error} />Disabled
<script lang="ts">
import Input from '#lib/ui/Input.svelte';
</script>
<Input label="Library" value="Bayt al-Hikma, Baghdad" disabled />With a prefix and suffix
Text or an icon inside the box, before or after what's typed.
<script lang="ts">
import Input from '#lib/ui/Input.svelte';
</script>
<Input label="Library website" placeholder="bayt-al-hikma">
{#snippet start()}https://{/snippet}
{#snippet end()}.org{/snippet}
</Input>With a button
A button inside the box: here, copying the link, with a tick to say it worked.
<script lang="ts">
import { Copy01Icon, Tick02Icon } from '@hugeicons/core-free-icons';
import Input from '#lib/ui/Input.svelte';
import InputButton from '#lib/ui/InputButton.svelte';
import { announce } from '#lib/ui/announce.js';
const link = 'https://bayt-al-hikma.org/manuscripts/optics-1021';
let copied = $state(false);
async function copy() {
await navigator.clipboard.writeText(link);
copied = true;
announce('Link copied');
setTimeout(() => (copied = false), 1600);
}
</script>
<Input label="Share link" value={link} readonly onfocus={(e) => e.currentTarget.select()}>
{#snippet end()}
<InputButton icon={copied ? Tick02Icon : Copy01Icon} aria-label="Copy link" onclick={copy} />
{/snippet}
</Input>Inline edit
Text that turns into a field where it stands: for titles, names and settings shown as a page rather than a form. Press it (or Enter) to edit; Enter or moving away saves, Escape puts it back.
npx sinaui add inline-editCopy src/lib/ui/InlineEdit.svelte, with Button.svelte, Icon.svelte, Spinner.svelte, announce.ts and tokens.css. No dependencies.
A title
Hover to see the pencil. Clearing it and saving says a title is needed.
<script lang="ts">
import InlineEdit from '#lib/ui/InlineEdit.svelte';
let title = $state('Kitab al-Manazir (Book of Optics)');
</script>
<div class="demo">
<InlineEdit label="Title" bind:value={title} required maxlength={80} />
</div>
<style>
.demo {
inline-size: min(24rem, 100%);
}
</style>A profile, edited in place
Each field saves on its own. The email is checked before saving; the city fails once, to show the message and a second try.
- Name
- Name
- Email
- City
- City
- Note
- Note
<script lang="ts">
import InlineEdit from '#lib/ui/InlineEdit.svelte';
let name = $state('Maryam al-Asturlabi');
let email = $state('maryam@bayt-al-hikma.org');
let city = $state('Aleppo');
let note = $state('');
let tries = 0;
// Stand-ins for your API. The city save fails the first time, to show what happens.
const wait = () => new Promise((r) => setTimeout(r, 600));
const saveCity = async () => {
await wait();
if (tries++ === 0) throw new Error('offline');
};
</script>
<dl class="profile">
<div>
<dt class="sr">Name</dt>
<dd><InlineEdit label="Name" bind:value={name} required onsave={wait} /></dd>
</div>
<div>
<dt class="sr">Email</dt>
<dd>
<InlineEdit
label="Email"
type="email"
bind:value={email}
required
validate={(v) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
? undefined
: 'Enter an email like name@example.com.'}
onsave={wait}
/>
</dd>
</div>
<div>
<dt class="sr">City</dt>
<dd><InlineEdit label="City" bind:value={city} onsave={saveCity} /></dd>
</div>
<div>
<dt class="sr">Note</dt>
<dd><InlineEdit label="Note" bind:value={note} placeholder="Add a note" onsave={wait} /></dd>
</div>
</dl>
<style>
.profile {
display: grid;
gap: 0.75rem;
inline-size: min(24rem, 100%);
margin: 0;
}
dd {
margin: 0;
}
.sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
}
</style>| Inline edit prop | Type | Default |
|---|---|---|
label | string (required) | |
value | string (bindable) | '' |
placeholder | string: shown when empty | 'Empty' |
hideLabel | boolean | false |
type | 'text' | 'email' | 'url' | 'text' |
required | boolean | false |
maxlength | number | |
validate | (value) => string | undefined: a message keeps it open | |
onsave | (value) => Promise | void: throw to keep it open with "Couldn’t save" |
Props
| Prop | Type | Default |
|---|---|---|
label | string (required) | |
value | string | number | null, bindable | |
start, end | Snippet: inside the box | |
hint | string | |
error | string | |
| …rest | Any <input> attribute. | |
Accessibility
labelis required and linked to the field: clicking it focuses the field, and screen readers announce it.hintanderrorare joined witharia-describedby; an error also setsaria-invalid.- Text is at least 16px so iOS Safari doesn't zoom the page on focus.
- Inline edit is a button named with its label, its value and "edit"; after saving or canceling, focus returns to it and "Saved" is announced.
- Its error is joined to the field and read out; the field stays open until it's fixed.
- Save and Cancel are real buttons, reachable with Tab.