<form-textbox>
<label for="name-input">Name</label>
<div class="input">
<input
type="text"
id="name-input"
name="name"
autocomplete="name"
required
/>
</div>
<p class="error" aria-live="assertive" id="name-error"></p>
<p class="description" aria-live="polite" id="city-description">
Tell us how you want us to call you in our communications.
</p>
</form-textbox>
<form-textbox clearable>
<label for="query-input">Search terms</label>
<div class="input">
<input
type="text"
id="query-input"
name="query"
autocomplete="off"
placeholder="apple banana"
required
/>
<button type="button" class="clear" aria-label="Clear input" hidden>
✕
</button>
</div>
<p class="error" aria-live="assertive" id="query-error"></p>
</form-textbox>
<form-textbox>
<label for="comment-input">Comment</label>
<div class="input">
<textarea
id="comment-input"
name="comment"
autocomplete="off"
maxlength="500"
></textarea>
</div>
<p class="error" aria-live="assertive" id="comment-error"></p>
<p
class="description"
aria-live="polite"
id="comment-description"
data-remaining="${n} characters remaining"
></p>
</form-textbox>
form-textbox {
width: 100%;
& label,
& p,
& button {
opacity: var(--opacity-dimmed);
transition: opacity var(--transition-short) var(--easing-inout);
}
& label {
display: block;
font-size: var(--font-size-s);
color: var(--color-text);
margin-bottom: var(--space-xxs);
}
& input,
& textarea {
display: inline-block;
box-sizing: border-box;
background: var(--color-input);
color: var(--color-text);
border: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-xs) var(--space-xxs);
font-size: var(--font-size-m);
width: 100%;
transition: color var(--transition-short) var(--easing-inout);
&[aria-invalid="true"] {
box-shadow: 0 0 var(--space-xxs) 2px var(--color-error-invalid);
}
&::placeholder {
color: var(--color-text);
opacity: var(--opacity-translucent);
}
}
& input {
height: var(--input-height);
}
&[clearable] .input {
position: relative;
& input {
padding-right: var(--input-height);
}
.clear {
position: absolute;
bottom: 0;
right: 0;
border: 0;
border-radius: 50%;
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
color: var(--color-input);
background-color: var(--color-text-soft);
width: calc(0.6 * var(--input-height));
height: calc(0.6 * var(--input-height));
margin: calc(0.2 * var(--input-height));
padding: 0;
&:hover {
background-color: var(--color-text);
}
}
}
.error,
.description {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-xs);
line-height: var(--line-height-s);
&:empty {
display: none;
}
}
.error {
color: color-mix(in srgb, var(--color-text) 50%, var(--color-error));
}
.description {
color: var(--color-text-soft);
}
&:focus-within {
& label,
& p,
& button {
opacity: var(--opacity-solid);
}
& input,
& textarea {
color: var(--color-text);
}
}
}
import {
type Component,
UNSET,
// batch,
component,
computed,
fromEvents,
on,
setAttribute,
setProperty,
setText,
show,
} from '../../..'
import { createClearFunction } from '../../functions/shared/clear-input'
export type FormTextboxProps = {
value: string
length: number
error: string
description: string
clear(): void
}
export default component<FormTextboxProps>(
'form-textbox',
{
value: fromEvents<
string,
HTMLInputElement | HTMLTextAreaElement,
HTMLElement & { error: string }
>('', 'input, textarea', {
change: ({ host, target }) => {
target.checkValidity()
host.error = target.validationMessage
return target.value
},
}),
length: fromEvents<number, HTMLInputElement | HTMLTextAreaElement>(
0,
'input, textarea',
{
input: ({ target }) => target.value.length,
},
),
error: '',
description: '',
clear() {},
},
(el, { first }) => {
const input = el.querySelector<HTMLInputElement | HTMLTextAreaElement>(
'input, textarea',
)
if (!input) throw new Error('No Input or textarea element found')
// Add clear method to component using shared functionality
el.clear = createClearFunction(input)
// If there's a description with data-remaining attribute we set a computed signal to update the description text
const description = el.querySelector<HTMLElement>('.description')
if (description?.dataset.remaining && input.maxLength) {
el.setSignal(
'description',
computed(() =>
description.dataset.remaining!.replace(
'${n}',
String(input.maxLength - el.length),
),
),
)
}
const errorId = el.querySelector('.error')?.id
const descriptionId = description?.id
return [
setAttribute('value'),
// Effects on input / textarea
first(
'input, textarea',
setProperty('ariaInvalid', () => String(!!el.error)),
setAttribute('aria-errormessage', () =>
el.error && errorId ? errorId : UNSET,
),
setAttribute('aria-describedby', () =>
el.description && descriptionId ? descriptionId : UNSET,
),
/* on({
input: () => {
el.length = input.value.length
},
change: () => {
input.checkValidity()
batch(() => {
el.value = input.value
el.error = input.validationMessage ?? ''
})
},
}), */
),
// Effects and event listeners on clear button
first(
'.clear',
show(() => !!el.length),
on('click', () => {
el.clear()
}),
),
// Effects on error and description
first('.error', setText('error')),
first('.description', setText('description')),
]
},
)
declare global {
interface HTMLElementTagNameMap {
'form-textbox': Component<FormTextboxProps>
}
}