Appearance
TextField — live demo
This is the real @rozie-ui/text-field-vue package running on this page (VitePress is itself a Vue app), built from this repo's workspace rather than npm: no @rozie-ui/text-field target has published yet. The same TextField component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit.
The email field shows the error only after its first change (blur), the usual "don't scold while typing" pattern, and the control gets aria-invalid="true" with the message linked through aria-describedby. The notes field grows from two rows to six, then scrolls. The search field has no visible label, so it takes ariaLabel; its buttons call the imperative handle through Vue's ref.
What ships for each framework
You author the component once as a .rozie file:
html
<!--
TextField.rozie — an accessible single-line or multiline text field with its
own label, description, error message and character counter.
A pure-Rozie family (NO third-party engine). A native <input> or <textarea>
already binds well in every framework, so the value here is the FIELD around
it: the label / description / error wiring (`for`, `aria-describedby`,
`aria-invalid`), the two-way `modelValue` binding (the Angular
ControlValueAccessor), token theming, and opt-in textarea autosize.
WHY THE CONTROL IS INSIDE (not a generic <Field> around a slotted control): on
Lit the component renders in a shadow root, and id references (`<label for>`,
`aria-describedby`) do not cross a shadow boundary. A slotted light-DOM input
could not be labelled by a shadow-root <label>, so a generic wrapper would be
broken on one of the six targets. With the control inside, every id reference
resolves within one tree on all six.
IDS: `<idBase>-input` / `-description` / `-error` / `-count`. An empty `idBase`
(the default) gets a unique `rozie-text-field-<n>` after mount from the page-wide
counter Combobox / Listbox / Popover share.
Authoring notes (collision classes — see the authoring playbook §6):
- No top-level binding is named `value` / `id` / `title` / `inputMode` /
`select` (Lit class-field collisions with inherited DOM members). The
exposed verb `select` is fine: HTMLElement has no `select()`. The focus verb
is `focus` — a deliberate, signature-compatible override of
HTMLElement.focus on Lit (ROZ137 warns, accepted; the otp/number-field
precedent).
- The model write funnel is `commitText`, NOT `writeValue` (the generated
Angular ControlValueAccessor method, TS2300).
- Handler params are untyped (neutralize to `any`) so `e.target.value` /
`e.key` typecheck on all six strict leaves.
- `$refs.inputEl` / `$refs.textareaEl` are read only in $onMount, handlers,
the $watch callback and $expose verbs (post-mount, ROZ123-safe). They type
as a generic HTMLElement on several leaves, so input-only members
(`select()`) go through an `any` local.
- Derived strings are plain functions called `()` (read in the template AND
in handlers), never `$computed`.
Consumer example:
<TextField r-model:modelValue="$data.email" label="Email" type="email"
description="We never share it." :error="$data.emailError" required />
-->
<rozie name="TextField">
<types>
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload { value: string }
</types>
<props>
{
modelValue: {
type: String,
default: '',
model: true,
docs: {
description:
'The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.',
example: '<TextField r-model:modelValue="email" label="Email" />',
},
},
label: {
type: String,
default: '',
docs: {
description:
'Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.',
},
},
description: {
type: String,
default: '',
docs: {
description:
'Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.',
},
},
error: {
type: String,
default: '',
docs: {
description:
'Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.',
example: '<TextField r-model:modelValue="email" label="Email" :error="emailError" />',
},
},
invalid: {
type: Boolean,
default: false,
docs: {
description:
'Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.',
},
},
type: {
type: String,
default: 'text',
docs: {
description:
"The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.",
},
},
placeholder: {
type: String,
default: '',
docs: {
description:
'Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.',
},
},
name: {
type: String,
default: '',
docs: {
description:
'The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.',
},
},
autocomplete: {
type: String,
default: '',
docs: {
description:
"The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.",
},
},
required: {
type: Boolean,
default: false,
docs: {
description:
'Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.',
},
},
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.',
},
},
readonly: {
type: Boolean,
default: false,
docs: {
description:
'Make the control read-only: the text is shown, focusable and selectable but cannot be edited.',
},
},
maxLength: {
type: Number,
default: null,
docs: {
description:
'Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.',
},
},
showCount: {
type: Boolean,
default: false,
docs: {
description:
'Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.',
},
},
multiline: {
type: Boolean,
default: false,
docs: {
description: 'Render a `<textarea>` instead of an `<input>`.',
},
},
rows: {
type: Number,
default: 3,
docs: {
description:
'Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.',
},
},
autosize: {
type: Boolean,
default: false,
docs: {
description:
'Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.',
example: '<TextField r-model:modelValue="notes" label="Notes" multiline autosize :maxRows="8" />',
},
},
maxRows: {
type: Number,
default: null,
docs: {
description:
'The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.',
},
},
ariaLabel: {
type: String,
default: null,
docs: {
description:
'Accessible name for the control (`aria-label`) when there is no visible `label`.',
},
},
idBase: {
type: String,
default: '',
docs: {
description:
"Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.",
},
},
}
</props>
<emits>
{
change: { payload: 'TextFieldValuePayload', docs: { description: 'Fired when the user commits an edit — the DOM `change` event: on blur after changing the text (and on Enter where the browser commits there). Programmatic writes never fire it. The payload is `{ value }`. Use `modelValue` for every keystroke.' } },
enter: { payload: 'TextFieldValuePayload', docs: { description: 'Fired when the user presses Enter in a single-line field (not while an IME composition is open). The payload is `{ value }`. The key is not prevented, so an enclosing form still submits.' } },
}
</emits>
<data>
{
// The generated id base, set once in $onMount when `idBase` is empty.
autoId: '',
}
</data>
<script lang="ts">
import { applyAutosize } from './internal/autosize'
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
let widthObserver: any = null
let observedEl: any = null
let lastWidth = -1
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
let autosized = false
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
let unmounted = false
let resetForm: any = null
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
const nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1
Reflect.set(globalThis, '__rozieAutoId', n)
return n
}
// ---- ids -------------------------------------------------------------------
const idRoot = () => $props.idBase || $data.autoId || 'rozie-text-field'
const inputId = () => idRoot() + '-input'
const descriptionId = () => idRoot() + '-description'
const errorId = () => idRoot() + '-error'
const countId = () => idRoot() + '-count'
// ---- derived state ---------------------------------------------------------
const hasError = () => typeof $props.error === 'string' && $props.error.length > 0
const isInvalid = () => !!$props.invalid || hasError()
const currentText = () => ($props.modelValue == null ? '' : String($props.modelValue))
// Read once into a local: Angular does not narrow across two signal calls.
const hasLimit = () => {
const limit = $props.maxLength
return typeof limit === 'number' && limit >= 0
}
const countText = () => {
const n = currentText().length
return hasLimit() ? n + ' / ' + $props.maxLength : String(n)
}
// The ids of the rendered helper text, space-joined, or null when there is none.
const describedBy = () => {
const ids: string[] = []
if ($props.description) ids.push(descriptionId())
if (hasError()) ids.push(errorId())
if ($props.showCount) ids.push(countId())
return ids.length > 0 ? ids.join(' ') : null
}
const inputType = () => ($props.type ? $props.type : 'text')
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
const optionalAttr = (v) => (v ? v : undefined)
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
const observeWidth = (el) => {
if (el === observedEl) return
if (widthObserver) widthObserver.disconnect()
widthObserver = null
observedEl = el
lastWidth = -1
if (!el || typeof ResizeObserver === 'undefined') return
widthObserver = new ResizeObserver((entries) => {
const w = entries[0] ? entries[0].contentRect.width : -1
if (w === lastWidth) return
lastWidth = w
requestAnimationFrame(() => fit())
})
widthObserver.observe(el)
}
const fit = () => {
if (unmounted) return
const el = $props.multiline ? $refs.textareaEl : null
if (!el || !$props.autosize) {
observeWidth(null)
if (el && autosized) {
el.style.height = ''
el.style.overflowY = ''
}
autosized = false
return
}
observeWidth(el)
applyAutosize(el, $props.rows, $props.maxRows)
autosized = true
}
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
const fitLater = () => {
setTimeout(() => fit(), 0)
}
// ---- handlers --------------------------------------------------------------
const control = () => ($props.multiline ? $refs.textareaEl : $refs.inputEl)
// Named commitText, NOT writeValue (the generated Angular CVA method).
const commitText = (text) => {
$model.modelValue = text
}
const onInput = (e) => {
commitText(e && e.target ? e.target.value : '')
fit()
}
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
const onChange = (e) => {
$emit('change', { value: e && e.target ? e.target.value : currentText() })
}
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
const onKeydown = (e) => {
if (!e || e.key !== 'Enter' || e.repeat) return
const ne = e.nativeEvent ?? e
if (ne.isComposing || ne.keyCode === 229) return
$emit('enter', { value: e.target ? e.target.value : currentText() })
}
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
const onFormReset = () => {
setTimeout(() => {
if (unmounted) return
const el: any = control()
if (el && el.value !== currentText()) commitText(el.value)
fit()
}, 0)
}
$onMount(() => {
unmounted = false
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
$data.autoId = 'rozie-text-field-' + nextAutoId()
fit()
const el: any = control()
resetForm = el && el.form ? el.form : null
if (resetForm) resetForm.addEventListener('reset', onFormReset)
return () => {
unmounted = true
observeWidth(null)
if (resetForm) resetForm.removeEventListener('reset', onFormReset)
resetForm = null
}
})
// A programmatic modelValue write changes the text without an input event, and
// the autosize props can change at runtime (`multiline` swaps the control).
$watch(() => $props.modelValue, () => fitLater())
$watch(() => $props.multiline, () => fitLater())
$watch(() => $props.autosize, () => fitLater())
$watch(() => $props.rows, () => fitLater())
$watch(() => $props.maxRows, () => fitLater())
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
const focus = () => {
const el = control()
if (el && el.focus) el.focus()
}
// select() — focus the control and select all of its text.
const select = () => {
const el: any = control()
if (!el) return
if (el.focus) el.focus()
if (el.select) el.select()
}
// clear() — empty the field (writes modelValue = '').
const clear = () => {
commitText('')
fit()
}
$expose({ focus, select, clear }, { focus: '() => void', select: '() => void', clear: '() => void' })
</script>
<template>
<div
class="rozie-text-field"
:class="{ 'rozie-text-field--invalid': isInvalid(), 'rozie-text-field--disabled': $props.disabled, 'rozie-text-field--multiline': $props.multiline, 'rozie-text-field--autosize': $props.multiline && $props.autosize }"
>
<label r-if="$props.label" class="rozie-text-field-label" :for="inputId()">{{ $props.label }}<span r-if="$props.required" class="rozie-text-field-required" aria-hidden="true">*</span></label>
<div class="rozie-text-field-control">
<span r-if="$slots.prefix" class="rozie-text-field-prefix"><slot name="prefix" /></span>
<textarea
r-if="$props.multiline"
ref="textareaEl"
class="rozie-text-field-input"
:id="inputId()"
:value="currentText()"
:rows="$props.rows"
:name="optionalAttr($props.name)"
:placeholder="optionalAttr($props.placeholder)"
:autocomplete="optionalAttr($props.autocomplete)"
:maxlength="$props.maxLength"
:required="!!$props.required"
:disabled="!!$props.disabled"
:readonly="!!$props.readonly"
:aria-label="$props.ariaLabel"
:aria-invalid="isInvalid() ? 'true' : null"
:aria-describedby="describedBy()"
@input="onInput($event)"
@change.stop="onChange($event)"
></textarea>
<input
r-if="!$props.multiline"
ref="inputEl"
class="rozie-text-field-input"
:id="inputId()"
:type="inputType()"
:value="currentText()"
:name="optionalAttr($props.name)"
:placeholder="optionalAttr($props.placeholder)"
:autocomplete="optionalAttr($props.autocomplete)"
:maxlength="$props.maxLength"
:required="!!$props.required"
:disabled="!!$props.disabled"
:readonly="!!$props.readonly"
:aria-label="$props.ariaLabel"
:aria-invalid="isInvalid() ? 'true' : null"
:aria-describedby="describedBy()"
@input="onInput($event)"
@change.stop="onChange($event)"
@keydown="onKeydown($event)"
/>
<span r-if="$slots.suffix" class="rozie-text-field-suffix"><slot name="suffix" /></span>
</div>
<p r-if="$props.description" class="rozie-text-field-description" :id="descriptionId()">{{ $props.description }}</p>
<p r-if="hasError()" class="rozie-text-field-error" :id="errorId()">{{ $props.error }}</p>
<p r-if="$props.showCount" class="rozie-text-field-count" :id="countId()">{{ countText() }}</p>
</div>
</template>
<style>
/*
Token-driven (the dialog/otp convention): every cosmetic value is a
`var(--rozie-text-field-*, var(--rtf-*, <fallback>))`. The shipped themes/*.css
presets map these onto shadcn/Radix, Material 3 and Bootstrap 5.
*/
.rozie-text-field {
display: flex;
flex-direction: column;
gap: var(--rozie-text-field-gap, var(--rtf-gap, 0.375rem));
font: var(--rozie-text-field-font, inherit);
color: var(--rozie-text-field-color, var(--rtf-color, inherit));
width: var(--rozie-text-field-width, var(--rtf-width, 100%));
min-width: 0;
}
.rozie-text-field-label {
font-size: var(--rozie-text-field-label-size, var(--rtf-label-size, 0.875rem));
font-weight: var(--rozie-text-field-label-weight, var(--rtf-label-weight, 500));
color: var(--rozie-text-field-label-color, var(--rtf-label-color, inherit));
}
.rozie-text-field-required {
/* A margin, not a leading space in the markup: targets collapse that space differently. */
margin-left: 0.25em;
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-control {
display: flex;
align-items: center;
gap: var(--rozie-text-field-adornment-gap, var(--rtf-adornment-gap, 0.5rem));
box-sizing: border-box;
padding: 0 var(--rozie-text-field-padding-x, var(--rtf-padding-x, 0.75rem));
border: var(--rozie-text-field-border-width, var(--rtf-border-width, 1px)) solid var(--rozie-text-field-border-color, var(--rtf-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-text-field-radius, var(--rtf-radius, 0.5rem));
background: var(--rozie-text-field-bg, var(--rtf-bg, #fff));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.rozie-text-field--multiline .rozie-text-field-control {
align-items: flex-start;
}
.rozie-text-field-control:focus-within {
border-color: var(--rozie-text-field-focus-color, var(--rtf-focus-color, #2563eb));
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-focus-ring, var(--rtf-focus-ring, rgba(37, 99, 235, 0.25)));
}
.rozie-text-field--invalid .rozie-text-field-control {
border-color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field--invalid .rozie-text-field-control:focus-within {
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-error-ring, var(--rtf-error-ring, rgba(220, 38, 38, 0.25)));
}
/* Keyed on the native :disabled state (not the --disabled class) so an Angular
form-level disable (setDisabledState) styles the same as the `disabled` prop. */
.rozie-text-field-control:has(> .rozie-text-field-input:disabled) {
background: var(--rozie-text-field-disabled-bg, var(--rtf-disabled-bg, rgba(0, 0, 0, 0.04)));
opacity: var(--rozie-text-field-disabled-opacity, var(--rtf-disabled-opacity, 0.6));
}
.rozie-text-field-input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem)) 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: inherit;
line-height: var(--rozie-text-field-line-height, var(--rtf-line-height, 1.5));
}
textarea.rozie-text-field-input {
resize: var(--rozie-text-field-resize, var(--rtf-resize, vertical));
}
/* An autosized textarea sizes itself; a manual resize grip would fight it. */
.rozie-text-field--autosize textarea.rozie-text-field-input {
resize: none;
}
.rozie-text-field-input::placeholder {
color: var(--rozie-text-field-placeholder-color, var(--rtf-placeholder-color, rgba(0, 0, 0, 0.45)));
}
.rozie-text-field-input:disabled {
cursor: not-allowed;
}
.rozie-text-field-prefix,
.rozie-text-field-suffix {
display: inline-flex;
align-items: center;
flex: none;
color: var(--rozie-text-field-adornment-color, var(--rtf-adornment-color, rgba(0, 0, 0, 0.55)));
}
.rozie-text-field--multiline .rozie-text-field-prefix,
.rozie-text-field--multiline .rozie-text-field-suffix {
padding-top: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem));
}
.rozie-text-field-description,
.rozie-text-field-error,
.rozie-text-field-count {
margin: 0;
font-size: var(--rozie-text-field-help-size, var(--rtf-help-size, 0.8125rem));
}
.rozie-text-field-description {
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
}
.rozie-text-field-error {
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-count {
align-self: flex-end;
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
font-variant-numeric: tabular-nums;
}
</style>
</rozie>…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/text-field-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { flushSync } from 'react-dom';
import { clsx, rozieAttr, rozieDisplay, rozieNativeListeners, useControllableState } from '@rozie/runtime-react';
import './TextField.css';
import { applyAutosize } from './internal/autosize';
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload {
value: string;
}
interface TextFieldProps extends Omit<import('react').ComponentPropsWithoutRef<'div'>, 'modelValue' | 'defaultModelValue' | 'onModelValueChange' | 'label' | 'description' | 'error' | 'invalid' | 'type' | 'placeholder' | 'name' | 'autocomplete' | 'required' | 'disabled' | 'readonly' | 'maxLength' | 'showCount' | 'multiline' | 'rows' | 'autosize' | 'maxRows' | 'ariaLabel' | 'idBase' | 'onChange' | 'onEnter' | 'renderPrefix' | 'renderSuffix' | 'slots' | 'children' | 'dangerouslySetInnerHTML'> {
/**
* The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.
* @example
* <TextField modelValue={email} onModelValueChange={setEmail} label="Email" />
*/
modelValue?: string;
defaultModelValue?: string;
onModelValueChange?: (modelValue: string) => void;
/**
* Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.
*/
label?: string;
/**
* Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.
*/
description?: string;
/**
* Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.
* @example
* <TextField modelValue={email} onModelValueChange={setEmail} label="Email" error={emailError} />
*/
error?: string;
/**
* Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.
*/
invalid?: boolean;
/**
* The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.
*/
type?: string;
/**
* Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.
*/
placeholder?: string;
/**
* The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.
*/
name?: string;
/**
* The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.
*/
autocomplete?: string;
/**
* Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.
*/
required?: boolean;
/**
* Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the control read-only: the text is shown, focusable and selectable but cannot be edited.
*/
readonly?: boolean;
/**
* Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.
*/
maxLength?: (number) | null;
/**
* Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.
*/
showCount?: boolean;
/**
* Render a `<textarea>` instead of an `<input>`.
*/
multiline?: boolean;
/**
* Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.
*/
rows?: number;
/**
* Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.
* @example
* <TextField modelValue={notes} onModelValueChange={setNotes} label="Notes" multiline autosize maxRows={8} />
*/
autosize?: boolean;
/**
* The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.
*/
maxRows?: (number) | null;
/**
* Accessible name for the control (`aria-label`) when there is no visible `label`.
*/
ariaLabel?: (string) | null;
/**
* Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
onChange?: (payload: TextFieldValuePayload) => void;
onEnter?: (payload: TextFieldValuePayload) => void;
renderPrefix?: () => ReactNode;
renderSuffix?: () => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface TextFieldHandle {
focus: () => void;
select: () => void;
clear: () => void;
}
const TextField = forwardRef<TextFieldHandle, TextFieldProps>(function TextField(_props: TextFieldProps, ref): JSX.Element {
const props: Omit<TextFieldProps, 'label' | 'description' | 'error' | 'invalid' | 'type' | 'placeholder' | 'name' | 'autocomplete' | 'required' | 'disabled' | 'readonly' | 'maxLength' | 'showCount' | 'multiline' | 'rows' | 'autosize' | 'maxRows' | 'ariaLabel' | 'idBase'> & { label: string; description: string; error: string; invalid: boolean; type: string; placeholder: string; name: string; autocomplete: string; required: boolean; disabled: boolean; readonly: boolean; maxLength: (number) | null; showCount: boolean; multiline: boolean; rows: number; autosize: boolean; maxRows: (number) | null; ariaLabel: (string) | null; idBase: string } = {
..._props,
label: _props.label ?? '',
description: _props.description ?? '',
error: _props.error ?? '',
invalid: _props.invalid ?? false,
type: _props.type ?? 'text',
placeholder: _props.placeholder ?? '',
name: _props.name ?? '',
autocomplete: _props.autocomplete ?? '',
required: _props.required ?? false,
disabled: _props.disabled ?? false,
readonly: _props.readonly ?? false,
maxLength: _props.maxLength ?? null,
showCount: _props.showCount ?? false,
multiline: _props.multiline ?? false,
rows: _props.rows ?? 3,
autosize: _props.autosize ?? false,
maxRows: _props.maxRows ?? null,
ariaLabel: _props.ariaLabel ?? null,
idBase: _props.idBase ?? '',
};
const attrs: Record<string, unknown> = (() => {
const { modelValue, label, description, error, invalid, type, placeholder, name, autocomplete, required, disabled, readonly, maxLength, showCount, multiline, rows, autosize, maxRows, ariaLabel, idBase, defaultValue, onModelValueChange, defaultModelValue, onChange, onEnter, renderPrefix, renderSuffix, slots, ...rest } = _props as TextFieldProps & Record<string, unknown>;
void modelValue; void label; void description; void error; void invalid; void type; void placeholder; void name; void autocomplete; void required; void disabled; void readonly; void maxLength; void showCount; void multiline; void rows; void autosize; void maxRows; void ariaLabel; void idBase; void defaultValue; void onModelValueChange; void defaultModelValue; void onChange; void onEnter; void renderPrefix; void renderSuffix; void slots;
return rest;
})();
const unmounted = useRef(false);
const resetForm = useRef<any>(null);
const autosized = useRef(false);
const observedEl = useRef<any>(null);
const widthObserver = useRef<any>(null);
const lastWidth = useRef(-1);
const [modelValue, setModelValue] = useControllableState({
value: props.modelValue,
defaultValue: props.defaultModelValue ?? '',
onValueChange: props.onModelValueChange,
});
const [autoId, setAutoId] = useState('');
const textareaEl = useRef<HTMLTextAreaElement | null>(null);
const inputEl = useRef<HTMLInputElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
const nextAutoId = useCallback(() => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
}, []);
// ---- ids -------------------------------------------------------------------
function idRoot() {
return props.idBase || autoId || 'rozie-text-field';
}
function inputId() {
return idRoot() + '-input';
}
function descriptionId() {
return idRoot() + '-description';
}
function errorId() {
return idRoot() + '-error';
}
function countId() {
return idRoot() + '-count';
}
// ---- derived state ---------------------------------------------------------
function hasError() {
return typeof props.error === 'string' && props.error.length > 0;
}
function isInvalid() {
return !!props.invalid || hasError();
}
function currentText() {
return modelValue == null ? '' : String(modelValue);
}
// Read once into a local: Angular does not narrow across two signal calls.
function hasLimit() {
const limit = props.maxLength;
return typeof limit === 'number' && limit >= 0;
}
function countText() {
const n = currentText().length;
return hasLimit() ? n + ' / ' + props.maxLength : String(n);
}
// The ids of the rendered helper text, space-joined, or null when there is none.
function describedBy() {
const ids: string[] = [];
if (props.description) ids.push(descriptionId());
if (hasError()) ids.push(errorId());
if (props.showCount) ids.push(countId());
return ids.length > 0 ? ids.join(' ') : null;
}
function inputType() {
return props.type ? props.type : 'text';
}
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
function optionalAttr(v: any) {
return v ? v : undefined;
}
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
const _rozieFwd_fit = useRef<typeof fit>(null!);
const observeWidth = useCallback((el: any) => {
if (el === observedEl.current) return;
if (widthObserver.current) widthObserver.current.disconnect();
widthObserver.current = null;
observedEl.current = el;
lastWidth.current = -1;
if (!el || typeof ResizeObserver === 'undefined') return;
widthObserver.current = new ResizeObserver((entries: any) => {
const w = entries[0] ? entries[0].contentRect.width : -1;
if (w === lastWidth.current) return;
lastWidth.current = w;
requestAnimationFrame(() => _rozieFwd_fit.current());
});
widthObserver.current.observe(el);
}, [props.autosize, props.maxRows, props.multiline, props.rows]);
const fit = useCallback(() => {
if (unmounted.current) return;
const el = props.multiline ? textareaEl.current : null;
if (!el || !props.autosize) {
observeWidth(null);
if (el && autosized.current) {
el.style.height = '';
el.style.overflowY = '';
}
autosized.current = false;
return;
}
observeWidth(el);
applyAutosize(el, props.rows, props.maxRows);
autosized.current = true;
}, [observeWidth, props.autosize, props.maxRows, props.multiline, props.rows]);
_rozieFwd_fit.current = fit;
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
function fitLater() {
setTimeout(() => fit(), 0);
}
// ---- handlers --------------------------------------------------------------
const control = useCallback(() => props.multiline ? textareaEl.current : inputEl.current, [props.multiline]);
// Named commitText, NOT writeValue (the generated Angular CVA method).
function commitText(text: any) {
setModelValue(text);
}
const onInput = useCallback((e: any) => {
commitText(e && e.target ? e.target.value : '');
fit();
}, [commitText, fit]);
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
const { onChange: _rozieProp_onChange } = props;
const onChange = useCallback((e: any) => {
_rozieProp_onChange && _rozieProp_onChange({
value: e && e.target ? e.target.value : currentText()
});
}, [_rozieProp_onChange, currentText]);
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
const { onEnter: _rozieProp_onEnter } = props;
const onKeydown = useCallback((e: any) => {
if (!e || e.key !== 'Enter' || e.repeat) return;
const ne = e.nativeEvent ?? e;
if (ne.isComposing || ne.keyCode === 229) return;
_rozieProp_onEnter && _rozieProp_onEnter({
value: e.target ? e.target.value : currentText()
});
}, [_rozieProp_onEnter, currentText]);
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
const onFormReset = useCallback(() => {
setTimeout(() => {
if (unmounted.current) return;
const el: any = control();
if (el && el.value !== currentText()) commitText(el.value);
fit();
}, 0);
}, [commitText, control, currentText, fit]);
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
function focus() {
const el = control();
if (el && el.focus) el.focus();
}
// select() — focus the control and select all of its text.
function select() {
const el: any = control();
if (!el) return;
if (el.focus) el.focus();
if (el.select) el.select();
}
// clear() — empty the field (writes modelValue = '').
function clear() {
commitText('');
fit();
}
const _controlRef = useRef(control);
_controlRef.current = control;
const _fitRef = useRef(fit);
_fitRef.current = fit;
const _observeWidthRef = useRef(observeWidth);
_observeWidthRef.current = observeWidth;
const _onFormResetRef = useRef(onFormReset);
_onFormResetRef.current = onFormReset;
useEffect(() => {
const _onFormResetStable: typeof _onFormResetRef.current = (...args) => _onFormResetRef.current(...args);
unmounted.current = false;
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
setAutoId('rozie-text-field-' + nextAutoId());
_fitRef.current();
const el: any = _controlRef.current();
resetForm.current = el && el.form ? el.form : null;
if (resetForm.current) resetForm.current.addEventListener('reset', _onFormResetStable);
return () => {
unmounted.current = true;
_observeWidthRef.current(null);
if (resetForm.current) resetForm.current.removeEventListener('reset', _onFormResetStable);
resetForm.current = null;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
fitLater();
}, [modelValue]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
fitLater();
}, [props.multiline]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
fitLater();
}, [props.autosize]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
fitLater();
}, [props.rows]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
fitLater();
}, [props.maxRows]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ focus, select, clear });
_rozieExposeRef.current = { focus, select, clear };
useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => flushSync(() => _rozieExposeRef.current.focus(...args)), select: (...args: Parameters<typeof select>): ReturnType<typeof select> => flushSync(() => _rozieExposeRef.current.select(...args)), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => flushSync(() => _rozieExposeRef.current.clear(...args)) }), []);
return (
<>
<div {...attrs} className={clsx(clsx("rozie-text-field", { "rozie-text-field--invalid": isInvalid(), "rozie-text-field--disabled": props.disabled, "rozie-text-field--multiline": props.multiline, "rozie-text-field--autosize": props.multiline && props.autosize }), (attrs.className as string | undefined))} data-rozie-s-346ae6aa="">
{!!(props.label) && <label className={"rozie-text-field-label"} htmlFor={rozieAttr(inputId())} data-rozie-s-346ae6aa="">{props.label}{!!(props.required) && <span className={"rozie-text-field-required"} aria-hidden="true" data-rozie-s-346ae6aa="">*</span>}</label>}<div className={"rozie-text-field-control"} data-rozie-s-346ae6aa="">
{!!((props.renderPrefix ?? props.slots?.['prefix'])) && <span className={"rozie-text-field-prefix"} data-rozie-s-346ae6aa="">{(props.renderPrefix ?? props.slots?.['prefix'])?.()}</span>}{!!(props.multiline) && <textarea className={"rozie-text-field-input"} id={rozieAttr(inputId())} value={currentText()} rows={props.rows} name={rozieAttr(optionalAttr(props.name))} placeholder={rozieAttr(optionalAttr(props.placeholder))} autoComplete={rozieAttr(optionalAttr(props.autocomplete))} maxLength={(props.maxLength) ?? undefined} required={!!props.required} disabled={!!props.disabled} readOnly={!!props.readonly} aria-label={rozieAttr(props.ariaLabel)} aria-invalid={rozieAttr(isInvalid() ? 'true' : undefined)} aria-describedby={rozieAttr(describedBy())} onInput={($event) => { onInput($event); }} ref={rozieNativeListeners<HTMLTextAreaElement>([["change", ($event) => { $event.stopPropagation(); onChange($event); }]], textareaEl)} data-rozie-s-346ae6aa="" />}{!!(!props.multiline) && <input className={"rozie-text-field-input"} id={rozieAttr(inputId())} type={rozieAttr(inputType())} value={currentText()} name={rozieAttr(optionalAttr(props.name))} placeholder={rozieAttr(optionalAttr(props.placeholder))} autoComplete={rozieAttr(optionalAttr(props.autocomplete))} maxLength={(props.maxLength) ?? undefined} required={!!props.required} disabled={!!props.disabled} readOnly={!!props.readonly} aria-label={rozieAttr(props.ariaLabel)} aria-invalid={rozieAttr(isInvalid() ? 'true' : undefined)} aria-describedby={rozieAttr(describedBy())} onInput={($event) => { onInput($event); }} onKeyDown={($event) => { onKeydown($event); }} ref={rozieNativeListeners<HTMLInputElement>([["change", ($event) => { $event.stopPropagation(); onChange($event); }]], inputEl)} data-rozie-s-346ae6aa="" />}{!!((props.renderSuffix ?? props.slots?.['suffix'])) && <span className={"rozie-text-field-suffix"} data-rozie-s-346ae6aa="">{(props.renderSuffix ?? props.slots?.['suffix'])?.()}</span>}</div>
{!!(props.description) && <p className={"rozie-text-field-description"} id={rozieAttr(descriptionId())} data-rozie-s-346ae6aa="">{props.description}</p>}{!!(hasError()) && <p className={"rozie-text-field-error"} id={rozieAttr(errorId())} data-rozie-s-346ae6aa="">{props.error}</p>}{!!(props.showCount) && <p className={"rozie-text-field-count"} id={rozieAttr(countId())} data-rozie-s-346ae6aa="">{rozieDisplay(countText())}</p>}</div>
</>
);
});
export default TextField;vue
<template>
<div :class="['rozie-text-field', { 'rozie-text-field--invalid': isInvalid(), 'rozie-text-field--disabled': props.disabled, 'rozie-text-field--multiline': props.multiline, 'rozie-text-field--autosize': props.multiline && props.autosize }]" v-bind="$attrs">
<label v-if="props.label" class="rozie-text-field-label" :for="inputId()">{{ props.label }}<span v-if="props.required" class="rozie-text-field-required" aria-hidden="true">*</span></label><div class="rozie-text-field-control">
<span v-if="$slots.prefix" class="rozie-text-field-prefix"><slot name="prefix"></slot></span><textarea v-if="props.multiline" ref="textareaElRef" class="rozie-text-field-input" :id="inputId()" :value="currentText()" :rows="(props.rows) ?? undefined" :name="optionalAttr(props.name)" :placeholder="optionalAttr(props.placeholder)" :autocomplete="optionalAttr(props.autocomplete)" :maxlength="(props.maxLength) ?? undefined" :required="!!props.required" :disabled="!!props.disabled" :readonly="!!props.readonly" :aria-label="(props.ariaLabel) ?? undefined" :aria-invalid="(isInvalid() ? 'true' : undefined) ?? undefined" :aria-describedby="(describedBy()) ?? undefined" @input="onInput($event)" @change.stop="onChange($event)"></textarea><input v-if="!props.multiline" ref="inputElRef" class="rozie-text-field-input" :id="inputId()" :type="inputType()" :value="currentText()" :name="optionalAttr(props.name)" :placeholder="optionalAttr(props.placeholder)" :autocomplete="optionalAttr(props.autocomplete)" :maxlength="(props.maxLength) ?? undefined" :required="!!props.required" :disabled="!!props.disabled" :readonly="!!props.readonly" :aria-label="(props.ariaLabel) ?? undefined" :aria-invalid="(isInvalid() ? 'true' : undefined) ?? undefined" :aria-describedby="(describedBy()) ?? undefined" @input="onInput($event)" @change.stop="onChange($event)" @keydown="onKeydown($event)" /><span v-if="$slots.suffix" class="rozie-text-field-suffix"><slot name="suffix"></slot></span></div>
<p v-if="props.description" class="rozie-text-field-description" :id="descriptionId()">{{ props.description }}</p><p v-if="hasError()" class="rozie-text-field-error" :id="errorId()">{{ props.error }}</p><p v-if="props.showCount" class="rozie-text-field-count" :id="countId()">{{ countText() }}</p></div>
</template>
<script lang="ts">
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload {
value: string;
}
export interface TextFieldHandle {
focus: () => void;
select: () => void;
clear: () => void;
}
</script>
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { applyAutosize } from './internal/autosize';
const props = withDefaults(
defineProps<{
/**
* Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.
*/
label?: string;
/**
* Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.
*/
description?: string;
/**
* Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.
* @example
* <TextField v-model:modelValue="email" label="Email" :error="emailError" />
*/
error?: string;
/**
* Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.
*/
invalid?: boolean;
/**
* The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.
*/
type?: string;
/**
* Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.
*/
placeholder?: string;
/**
* The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.
*/
name?: string;
/**
* The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.
*/
autocomplete?: string;
/**
* Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.
*/
required?: boolean;
/**
* Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the control read-only: the text is shown, focusable and selectable but cannot be edited.
*/
readonly?: boolean;
/**
* Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.
*/
maxLength?: number | null;
/**
* Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.
*/
showCount?: boolean;
/**
* Render a `<textarea>` instead of an `<input>`.
*/
multiline?: boolean;
/**
* Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.
*/
rows?: number;
/**
* Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.
* @example
* <TextField v-model:modelValue="notes" label="Notes" multiline autosize :maxRows="8" />
*/
autosize?: boolean;
/**
* The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.
*/
maxRows?: number | null;
/**
* Accessible name for the control (`aria-label`) when there is no visible `label`.
*/
ariaLabel?: string | null;
/**
* Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
}>(),
{ label: '', description: '', error: '', invalid: false, type: 'text', placeholder: '', name: '', autocomplete: '', required: false, disabled: false, readonly: false, maxLength: null, showCount: false, multiline: false, rows: 3, autosize: false, maxRows: null, ariaLabel: null, idBase: '' }
);
const __rozieVm = getCurrentInstance()!;
/** Whether the consumer binds a value (not `undefined`) to the two-way prop `name`. */
const __rozieBound = (name: string): boolean => {
const raw = __rozieVm.vnode.props;
if (!raw) return false;
const kebab = name.replace(/\B([A-Z])/g, '-$1').toLowerCase();
return (name in raw ? raw[name] : raw[kebab]) !== undefined;
};
/**
* The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.
* @example
* <TextField v-model:modelValue="email" label="Email" />
*/
const __rozieModel_modelValue = defineModel<string>('modelValue', { default: '' });
const modelValue = computed<string>({
get: () => __rozieModel_modelValue.value,
set: (v) => { if (__rozieBound('modelValue')) __rozieVm.emit('update:modelValue', v); else __rozieModel_modelValue.value = v; },
});
const emit = defineEmits<{
change: [payload: TextFieldValuePayload];
enter: [payload: TextFieldValuePayload];
}>();
defineSlots<{
prefix(props: { }): any;
suffix(props: { }): any;
}>();
const autoId = ref('');
const textareaElRef = ref<HTMLTextAreaElement>();
const inputElRef = ref<HTMLInputElement>();
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
let widthObserver: any = null;
let observedEl: any = null;
let lastWidth = -1;
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
let autosized = false;
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
let unmounted = false;
let resetForm: any = null;
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
const nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// ---- ids -------------------------------------------------------------------
const idRoot = () => props.idBase || autoId.value || 'rozie-text-field';
const inputId = () => idRoot() + '-input';
const descriptionId = () => idRoot() + '-description';
const errorId = () => idRoot() + '-error';
const countId = () => idRoot() + '-count';
// ---- derived state ---------------------------------------------------------
const hasError = () => typeof props.error === 'string' && props.error.length > 0;
const isInvalid = () => !!props.invalid || hasError();
const currentText = () => modelValue.value == null ? '' : String(modelValue.value);
// Read once into a local: Angular does not narrow across two signal calls.
const hasLimit = () => {
const limit = props.maxLength;
return typeof limit === 'number' && limit >= 0;
};
const countText = () => {
const n = currentText().length;
return hasLimit() ? n + ' / ' + props.maxLength : String(n);
};
// The ids of the rendered helper text, space-joined, or null when there is none.
const describedBy = () => {
const ids: string[] = [];
if (props.description) ids.push(descriptionId());
if (hasError()) ids.push(errorId());
if (props.showCount) ids.push(countId());
return ids.length > 0 ? ids.join(' ') : null;
};
const inputType = () => props.type ? props.type : 'text';
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
const optionalAttr = (v: any) => v ? v : undefined;
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
const observeWidth = (el: any) => {
if (el === observedEl) return;
if (widthObserver) widthObserver.disconnect();
widthObserver = null;
observedEl = el;
lastWidth = -1;
if (!el || typeof ResizeObserver === 'undefined') return;
widthObserver = new ResizeObserver((entries: any) => {
const w = entries[0] ? entries[0].contentRect.width : -1;
if (w === lastWidth) return;
lastWidth = w;
requestAnimationFrame(() => fit());
});
widthObserver.observe(el);
};
const fit = () => {
if (unmounted) return;
const el = props.multiline ? textareaElRef.value : null;
if (!el || !props.autosize) {
observeWidth(null);
if (el && autosized) {
el.style.height = '';
el.style.overflowY = '';
}
autosized = false;
return;
}
observeWidth(el);
applyAutosize(el, props.rows, props.maxRows);
autosized = true;
};
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
const fitLater = () => {
setTimeout(() => fit(), 0);
};
// ---- handlers --------------------------------------------------------------
const control = () => props.multiline ? textareaElRef.value : inputElRef.value;
// Named commitText, NOT writeValue (the generated Angular CVA method).
const commitText = (text: any) => {
modelValue.value = text;
};
const onInput = (e: any) => {
commitText(e && e.target ? e.target.value : '');
fit();
};
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
const onChange = (e: any) => {
emit('change', {
value: e && e.target ? e.target.value : currentText()
});
};
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
const onKeydown = (e: any) => {
if (!e || e.key !== 'Enter' || e.repeat) return;
const ne = e.nativeEvent ?? e;
if (ne.isComposing || ne.keyCode === 229) return;
emit('enter', {
value: e.target ? e.target.value : currentText()
});
};
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
const onFormReset = () => {
setTimeout(() => {
if (unmounted) return;
const el: any = control();
if (el && el.value !== currentText()) commitText(el.value);
fit();
}, 0);
};
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
const focus = () => {
const el = control();
if (el && el.focus) el.focus();
};
// select() — focus the control and select all of its text.
const select = () => {
const el: any = control();
if (!el) return;
if (el.focus) el.focus();
if (el.select) el.select();
};
// clear() — empty the field (writes modelValue = '').
const clear = () => {
commitText('');
fit();
};
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
unmounted = false;
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
autoId.value = 'rozie-text-field-' + nextAutoId();
fit();
const el: any = control();
resetForm = el && el.form ? el.form : null;
if (resetForm) resetForm.addEventListener('reset', onFormReset);
_cleanup_0 = () => {
unmounted = true;
observeWidth(null);
if (resetForm) resetForm.removeEventListener('reset', onFormReset);
resetForm = null;
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => modelValue.value, () => fitLater(), { flush: 'post' });
watch(() => props.multiline, () => fitLater(), { flush: 'post' });
watch(() => props.autosize, () => fitLater(), { flush: 'post' });
watch(() => props.rows, () => fitLater(), { flush: 'post' });
watch(() => props.maxRows, () => fitLater(), { flush: 'post' });
defineExpose({ focus, select, clear } as TextFieldHandle);
</script>
<style scoped>
.rozie-text-field {
display: flex;
flex-direction: column;
gap: var(--rozie-text-field-gap, var(--rtf-gap, 0.375rem));
font: var(--rozie-text-field-font, inherit);
color: var(--rozie-text-field-color, var(--rtf-color, inherit));
width: var(--rozie-text-field-width, var(--rtf-width, 100%));
min-width: 0;
}
.rozie-text-field-label {
font-size: var(--rozie-text-field-label-size, var(--rtf-label-size, 0.875rem));
font-weight: var(--rozie-text-field-label-weight, var(--rtf-label-weight, 500));
color: var(--rozie-text-field-label-color, var(--rtf-label-color, inherit));
}
.rozie-text-field-required {
/* A margin, not a leading space in the markup: targets collapse that space differently. */
margin-left: 0.25em;
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-control {
display: flex;
align-items: center;
gap: var(--rozie-text-field-adornment-gap, var(--rtf-adornment-gap, 0.5rem));
box-sizing: border-box;
padding: 0 var(--rozie-text-field-padding-x, var(--rtf-padding-x, 0.75rem));
border: var(--rozie-text-field-border-width, var(--rtf-border-width, 1px)) solid var(--rozie-text-field-border-color, var(--rtf-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-text-field-radius, var(--rtf-radius, 0.5rem));
background: var(--rozie-text-field-bg, var(--rtf-bg, #fff));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.rozie-text-field--multiline .rozie-text-field-control {
align-items: flex-start;
}
.rozie-text-field-control:focus-within {
border-color: var(--rozie-text-field-focus-color, var(--rtf-focus-color, #2563eb));
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-focus-ring, var(--rtf-focus-ring, rgba(37, 99, 235, 0.25)));
}
.rozie-text-field--invalid .rozie-text-field-control {
border-color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field--invalid .rozie-text-field-control:focus-within {
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-error-ring, var(--rtf-error-ring, rgba(220, 38, 38, 0.25)));
}
.rozie-text-field-control:has(> .rozie-text-field-input:disabled) {
background: var(--rozie-text-field-disabled-bg, var(--rtf-disabled-bg, rgba(0, 0, 0, 0.04)));
opacity: var(--rozie-text-field-disabled-opacity, var(--rtf-disabled-opacity, 0.6));
}
.rozie-text-field-input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem)) 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: inherit;
line-height: var(--rozie-text-field-line-height, var(--rtf-line-height, 1.5));
}
textarea.rozie-text-field-input {
resize: var(--rozie-text-field-resize, var(--rtf-resize, vertical));
}
.rozie-text-field--autosize textarea.rozie-text-field-input {
resize: none;
}
.rozie-text-field-input::placeholder {
color: var(--rozie-text-field-placeholder-color, var(--rtf-placeholder-color, rgba(0, 0, 0, 0.45)));
}
.rozie-text-field-input:disabled {
cursor: not-allowed;
}
.rozie-text-field-prefix,
.rozie-text-field-suffix {
display: inline-flex;
align-items: center;
flex: none;
color: var(--rozie-text-field-adornment-color, var(--rtf-adornment-color, rgba(0, 0, 0, 0.55)));
}
.rozie-text-field--multiline .rozie-text-field-prefix,
.rozie-text-field--multiline .rozie-text-field-suffix {
padding-top: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem));
}
.rozie-text-field-description,
.rozie-text-field-error,
.rozie-text-field-count {
margin: 0;
font-size: var(--rozie-text-field-help-size, var(--rtf-help-size, 0.8125rem));
}
.rozie-text-field-description {
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
}
.rozie-text-field-error {
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-count {
align-self: flex-end;
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
font-variant-numeric: tabular-nums;
}
</style>svelte
<script module lang="ts">
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload {
value: string;
}
</script>
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';
interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['div'], 'modelValue' | 'label' | 'description' | 'error' | 'invalid' | 'type' | 'placeholder' | 'name' | 'autocomplete' | 'required' | 'disabled' | 'readonly' | 'maxLength' | 'showCount' | 'multiline' | 'rows' | 'autosize' | 'maxRows' | 'ariaLabel' | 'idBase' | 'prefix' | 'suffix' | 'snippets' | 'onchange' | 'onenter' | 'children'> {
/**
* The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.
* @example
* <TextField bind:modelValue={email} label="Email" />
*/
modelValue?: string;
/**
* Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.
*/
label?: string;
/**
* Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.
*/
description?: string;
/**
* Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.
* @example
* <TextField bind:modelValue={email} label="Email" error={emailError} />
*/
error?: string;
/**
* Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.
*/
invalid?: boolean;
/**
* The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.
*/
type?: string;
/**
* Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.
*/
placeholder?: string;
/**
* The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.
*/
name?: string;
/**
* The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.
*/
autocomplete?: string;
/**
* Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.
*/
required?: boolean;
/**
* Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the control read-only: the text is shown, focusable and selectable but cannot be edited.
*/
readonly?: boolean;
/**
* Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.
*/
maxLength?: (number) | null;
/**
* Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.
*/
showCount?: boolean;
/**
* Render a `<textarea>` instead of an `<input>`.
*/
multiline?: boolean;
/**
* Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.
*/
rows?: number;
/**
* Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.
* @example
* <TextField bind:modelValue={notes} label="Notes" multiline autosize maxRows={8} />
*/
autosize?: boolean;
/**
* The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.
*/
maxRows?: (number) | null;
/**
* Accessible name for the control (`aria-label`) when there is no visible `label`.
*/
ariaLabel?: (string) | null;
/**
* Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
prefix?: Snippet;
suffix?: Snippet;
snippets?: Record<string, any>;
onchange?: (payload: TextFieldValuePayload) => void;
onenter?: (payload: TextFieldValuePayload) => void;
}
let {
modelValue = $bindable(''),
label = '',
description = '',
error = '',
invalid = false,
type = 'text',
placeholder = '',
name = '',
autocomplete = '',
required = false,
disabled = false,
readonly = false,
maxLength = null,
showCount = false,
multiline = false,
rows = 3,
autosize = false,
maxRows = null,
ariaLabel = null,
idBase = '',
prefix: __prefixProp,
suffix: __suffixProp,
snippets,
onchange,
onenter,
...__rozieAttrs
}: Props = $props();
const prefix = $derived(__prefixProp ?? snippets?.prefix);
const suffix = $derived(__suffixProp ?? snippets?.suffix);
let autoId = $state('');
let textareaEl = $state<HTMLTextAreaElement | undefined>(undefined);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
import { applyAutosize } from './internal/autosize';
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
let widthObserver: any = null;
let observedEl: any = null;
let lastWidth = -1;
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
let autosized = false;
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
let unmounted = false;
let resetForm: any = null;
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
const nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// ---- ids -------------------------------------------------------------------
const idRoot = () => idBase || autoId || 'rozie-text-field';
const inputId = () => idRoot() + '-input';
const descriptionId = () => idRoot() + '-description';
const errorId = () => idRoot() + '-error';
const countId = () => idRoot() + '-count';
// ---- derived state ---------------------------------------------------------
const hasError = () => typeof error === 'string' && error.length > 0;
const isInvalid = () => !!invalid || hasError();
const currentText = () => modelValue == null ? '' : String(modelValue);
// Read once into a local: Angular does not narrow across two signal calls.
const hasLimit = () => {
const limit = maxLength;
return typeof limit === 'number' && limit >= 0;
};
const countText = () => {
const n = currentText().length;
return hasLimit() ? n + ' / ' + maxLength : String(n);
};
// The ids of the rendered helper text, space-joined, or null when there is none.
const describedBy = () => {
const ids: string[] = [];
if (description) ids.push(descriptionId());
if (hasError()) ids.push(errorId());
if (showCount) ids.push(countId());
return ids.length > 0 ? ids.join(' ') : null;
};
const inputType = () => type ? type : 'text';
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
const optionalAttr = (v: any) => v ? v : undefined;
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
const observeWidth = (el: any) => {
if (el === observedEl) return;
if (widthObserver) widthObserver.disconnect();
widthObserver = null;
observedEl = el;
lastWidth = -1;
if (!el || typeof ResizeObserver === 'undefined') return;
widthObserver = new ResizeObserver((entries: any) => {
const w = entries[0] ? entries[0].contentRect.width : -1;
if (w === lastWidth) return;
lastWidth = w;
requestAnimationFrame(() => fit());
});
widthObserver.observe(el);
};
const fit = () => {
if (unmounted) return;
const el = multiline ? textareaEl : null;
if (!el || !autosize) {
observeWidth(null);
if (el && autosized) {
el.style.height = '';
el.style.overflowY = '';
}
autosized = false;
return;
}
observeWidth(el);
applyAutosize(el, rows, maxRows);
autosized = true;
};
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
const fitLater = () => {
setTimeout(() => fit(), 0);
};
// ---- handlers --------------------------------------------------------------
const control = () => multiline ? textareaEl : inputEl;
// Named commitText, NOT writeValue (the generated Angular CVA method).
const commitText = (text: any) => {
modelValue = text;
};
const onInput = (e: any) => {
commitText(e && e.target ? e.target.value : '');
fit();
};
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
const onChange = (e: any) => {
onchange?.({
value: e && e.target ? e.target.value : currentText()
});
};
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
const onKeydown = (e: any) => {
if (!e || e.key !== 'Enter' || e.repeat) return;
const ne = e.nativeEvent ?? e;
if (ne.isComposing || ne.keyCode === 229) return;
onenter?.({
value: e.target ? e.target.value : currentText()
});
};
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
const onFormReset = () => {
setTimeout(() => {
if (unmounted) return;
const el: any = control();
if (el && el.value !== currentText()) commitText(el.value);
fit();
}, 0);
};
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
export const focus: () => void = () => {
const el = control();
if (el && el.focus) el.focus();
};
// select() — focus the control and select all of its text.
export const select: () => void = () => {
const el: any = control();
if (!el) return;
if (el.focus) el.focus();
if (el.select) el.select();
};
// clear() — empty the field (writes modelValue = '').
export const clear: () => void = () => {
commitText('');
fit();
};
let __rozieWatchInitial_0 = true;
$effect(() => { (() => modelValue)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => fitLater())(); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => multiline)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => fitLater())(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => autosize)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => fitLater())(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { (() => rows)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } (() => fitLater())(); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { (() => maxRows)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } (() => fitLater())(); }); });
onMount(() => {
unmounted = false;
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
autoId = 'rozie-text-field-' + nextAutoId();
fit();
const el: any = control();
resetForm = el && el.form ? el.form : null;
if (resetForm) resetForm.addEventListener('reset', onFormReset);
return () => {
unmounted = true;
observeWidth(null);
if (resetForm) resetForm.removeEventListener('reset', onFormReset);
resetForm = null;
};
});
</script>
<div {...__rozieAttrs} class={["rozie-text-field", { 'rozie-text-field--invalid': isInvalid(), 'rozie-text-field--disabled': disabled, 'rozie-text-field--multiline': multiline, 'rozie-text-field--autosize': multiline && autosize }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-346ae6aa>{#if label}<label class="rozie-text-field-label" for={rozieAttr(inputId())} data-rozie-s-346ae6aa>{label}{#if required}<span class="rozie-text-field-required" aria-hidden="true" data-rozie-s-346ae6aa>*</span>{/if}</label>{/if}<div class="rozie-text-field-control" data-rozie-s-346ae6aa>{#if prefix}<span class="rozie-text-field-prefix" data-rozie-s-346ae6aa>{#if prefix}{@render prefix()}{/if}</span>{/if}{#if multiline}<textarea bind:this={textareaEl} class="rozie-text-field-input" id={rozieAttr(inputId())} value={rozieAttr(currentText())} rows={rows} name={rozieAttr(optionalAttr(name))} placeholder={rozieAttr(optionalAttr(placeholder))} autocomplete={rozieAttr(optionalAttr(autocomplete))} maxlength={maxLength} required={!!required} disabled={!!disabled} readonly={!!readonly} aria-label={ariaLabel} aria-invalid={rozieAttr(isInvalid() ? 'true' : null)} aria-describedby={rozieAttr(describedBy())} oninput={($event) => { onInput($event); }} onchange={($event) => { $event.stopPropagation(); onChange($event); }} data-rozie-s-346ae6aa></textarea>{/if}{#if !multiline}<input bind:this={inputEl} class="rozie-text-field-input" id={rozieAttr(inputId())} type={rozieAttr(inputType())} value={rozieAttr(currentText())} name={rozieAttr(optionalAttr(name))} placeholder={rozieAttr(optionalAttr(placeholder))} autocomplete={rozieAttr(optionalAttr(autocomplete))} maxlength={maxLength} required={!!required} disabled={!!disabled} readonly={!!readonly} aria-label={ariaLabel} aria-invalid={rozieAttr(isInvalid() ? 'true' : null)} aria-describedby={rozieAttr(describedBy())} oninput={($event) => { onInput($event); }} onchange={($event) => { $event.stopPropagation(); onChange($event); }} onkeydown={($event) => { onKeydown($event); }} data-rozie-s-346ae6aa />{/if}{#if suffix}<span class="rozie-text-field-suffix" data-rozie-s-346ae6aa>{#if suffix}{@render suffix()}{/if}</span>{/if}</div>{#if description}<p class="rozie-text-field-description" id={rozieAttr(descriptionId())} data-rozie-s-346ae6aa>{description}</p>{/if}{#if hasError()}<p class="rozie-text-field-error" id={rozieAttr(errorId())} data-rozie-s-346ae6aa>{error}</p>{/if}{#if showCount}<p class="rozie-text-field-count" id={rozieAttr(countId())} data-rozie-s-346ae6aa>{rozieDisplay(countText())}</p>{/if}</div>
<style>
:global {
.rozie-text-field[data-rozie-s-346ae6aa] {
display: flex;
flex-direction: column;
gap: var(--rozie-text-field-gap, var(--rtf-gap, 0.375rem));
font: var(--rozie-text-field-font, inherit);
color: var(--rozie-text-field-color, var(--rtf-color, inherit));
width: var(--rozie-text-field-width, var(--rtf-width, 100%));
min-width: 0;
}
.rozie-text-field-label[data-rozie-s-346ae6aa] {
font-size: var(--rozie-text-field-label-size, var(--rtf-label-size, 0.875rem));
font-weight: var(--rozie-text-field-label-weight, var(--rtf-label-weight, 500));
color: var(--rozie-text-field-label-color, var(--rtf-label-color, inherit));
}
.rozie-text-field-required[data-rozie-s-346ae6aa] {
/* A margin, not a leading space in the markup: targets collapse that space differently. */
margin-left: 0.25em;
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-control[data-rozie-s-346ae6aa] {
display: flex;
align-items: center;
gap: var(--rozie-text-field-adornment-gap, var(--rtf-adornment-gap, 0.5rem));
box-sizing: border-box;
padding: 0 var(--rozie-text-field-padding-x, var(--rtf-padding-x, 0.75rem));
border: var(--rozie-text-field-border-width, var(--rtf-border-width, 1px)) solid var(--rozie-text-field-border-color, var(--rtf-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-text-field-radius, var(--rtf-radius, 0.5rem));
background: var(--rozie-text-field-bg, var(--rtf-bg, #fff));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa] {
align-items: flex-start;
}
.rozie-text-field-control[data-rozie-s-346ae6aa]:focus-within {
border-color: var(--rozie-text-field-focus-color, var(--rtf-focus-color, #2563eb));
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-focus-ring, var(--rtf-focus-ring, rgba(37, 99, 235, 0.25)));
}
.rozie-text-field--invalid[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa] {
border-color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field--invalid[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa]:focus-within {
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-error-ring, var(--rtf-error-ring, rgba(220, 38, 38, 0.25)));
}
.rozie-text-field-control[data-rozie-s-346ae6aa]:has(> .rozie-text-field-input[data-rozie-s-346ae6aa]:disabled) {
background: var(--rozie-text-field-disabled-bg, var(--rtf-disabled-bg, rgba(0, 0, 0, 0.04)));
opacity: var(--rozie-text-field-disabled-opacity, var(--rtf-disabled-opacity, 0.6));
}
.rozie-text-field-input[data-rozie-s-346ae6aa] {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem)) 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: inherit;
line-height: var(--rozie-text-field-line-height, var(--rtf-line-height, 1.5));
}
textarea.rozie-text-field-input[data-rozie-s-346ae6aa] {
resize: var(--rozie-text-field-resize, var(--rtf-resize, vertical));
}
.rozie-text-field--autosize[data-rozie-s-346ae6aa] textarea.rozie-text-field-input[data-rozie-s-346ae6aa] {
resize: none;
}
.rozie-text-field-input[data-rozie-s-346ae6aa]::placeholder {
color: var(--rozie-text-field-placeholder-color, var(--rtf-placeholder-color, rgba(0, 0, 0, 0.45)));
}
.rozie-text-field-input[data-rozie-s-346ae6aa]:disabled {
cursor: not-allowed;
}
.rozie-text-field-prefix[data-rozie-s-346ae6aa],
.rozie-text-field-suffix[data-rozie-s-346ae6aa] {
display: inline-flex;
align-items: center;
flex: none;
color: var(--rozie-text-field-adornment-color, var(--rtf-adornment-color, rgba(0, 0, 0, 0.55)));
}
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-prefix[data-rozie-s-346ae6aa],
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-suffix[data-rozie-s-346ae6aa] {
padding-top: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem));
}
.rozie-text-field-description[data-rozie-s-346ae6aa],
.rozie-text-field-error[data-rozie-s-346ae6aa],
.rozie-text-field-count[data-rozie-s-346ae6aa] {
margin: 0;
font-size: var(--rozie-text-field-help-size, var(--rtf-help-size, 0.8125rem));
}
.rozie-text-field-description[data-rozie-s-346ae6aa] {
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
}
.rozie-text-field-error[data-rozie-s-346ae6aa] {
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-count[data-rozie-s-346ae6aa] {
align-self: flex-end;
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
font-variant-numeric: tabular-nums;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, forwardRef, inject, input, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay, rozieModel } from '@rozie/runtime-angular';
import { applyAutosize } from './internal/autosize';
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload {
value: string;
}
interface PrefixCtx {}
interface SuffixCtx {}
@Component({
selector: 'rozie-text-field',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-text-field" [ngClass]="{ 'rozie-text-field--invalid': isInvalid(), 'rozie-text-field--disabled': (disabled() || this.__rozieCvaDisabled()), 'rozie-text-field--multiline': multiline(), 'rozie-text-field--autosize': multiline() && autosize() }" #rozieSpread_0 #rozieListenersTarget_1>
@if (label()) {
<label class="rozie-text-field-label" [attr.for]="rozieAttr(inputId())">{{ label() }}@if (required()) {
<span class="rozie-text-field-required" aria-hidden="true">*</span>
}</label>
}<div class="rozie-text-field-control">
@if ((prefixTpl ?? __rozieFillMap()['prefix'] ?? templates()?.['prefix'])) {
<span class="rozie-text-field-prefix">@if ((prefixTpl ?? __rozieFillMap()['prefix'] ?? templates()?.['prefix'])) {
<ng-container *ngTemplateOutlet="(prefixTpl ?? __rozieFillMap()['prefix'] ?? templates()?.['prefix'])" />
}</span>
}@if (multiline()) {
<textarea #textareaEl class="rozie-text-field-input" [attr.id]="rozieAttr(inputId())" [value]="currentText()" [rows]="rows()" [attr.name]="rozieAttr(optionalAttr(name()))" [attr.placeholder]="rozieAttr(optionalAttr(placeholder()))" [attr.autocomplete]="rozieAttr(optionalAttr(autocomplete()))" [attr.maxlength]="rozieAttr(maxLength())" [required]="!!required()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [readOnly]="!!readonly()" [attr.aria-label]="rozieAttr(ariaLabel())" [attr.aria-invalid]="rozieAttr(isInvalid() ? 'true' : null)" [attr.aria-describedby]="rozieAttr(describedBy())" (input)="onInput($event)" (change)="$event.stopPropagation(); onChange($event)"></textarea>
}@if (!multiline()) {
<input #inputEl class="rozie-text-field-input" [attr.id]="rozieAttr(inputId())" [attr.type]="rozieAttr(inputType())" [value]="currentText()" [attr.name]="rozieAttr(optionalAttr(name()))" [attr.placeholder]="rozieAttr(optionalAttr(placeholder()))" [attr.autocomplete]="rozieAttr(optionalAttr(autocomplete()))" [attr.maxlength]="rozieAttr(maxLength())" [required]="!!required()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [readOnly]="!!readonly()" [attr.aria-label]="rozieAttr(ariaLabel())" [attr.aria-invalid]="rozieAttr(isInvalid() ? 'true' : null)" [attr.aria-describedby]="rozieAttr(describedBy())" (input)="onInput($event)" (change)="$event.stopPropagation(); onChange($event)" (keydown)="onKeydown($event)" />
}@if ((suffixTpl ?? __rozieFillMap()['suffix'] ?? templates()?.['suffix'])) {
<span class="rozie-text-field-suffix">@if ((suffixTpl ?? __rozieFillMap()['suffix'] ?? templates()?.['suffix'])) {
<ng-container *ngTemplateOutlet="(suffixTpl ?? __rozieFillMap()['suffix'] ?? templates()?.['suffix'])" />
}</span>
}</div>
@if (description()) {
<p class="rozie-text-field-description" [attr.id]="rozieAttr(descriptionId())">{{ description() }}</p>
}@if (hasError()) {
<p class="rozie-text-field-error" [attr.id]="rozieAttr(errorId())">{{ error() }}</p>
}@if (showCount()) {
<p class="rozie-text-field-count" [attr.id]="rozieAttr(countId())">{{ rozieDisplay(countText()) }}</p>
}</div>
`,
styles: [`
:host(rozie-text-field) { display: contents; }
.rozie-text-field {
display: flex;
flex-direction: column;
gap: var(--rozie-text-field-gap, var(--rtf-gap, 0.375rem));
font: var(--rozie-text-field-font, inherit);
color: var(--rozie-text-field-color, var(--rtf-color, inherit));
width: var(--rozie-text-field-width, var(--rtf-width, 100%));
min-width: 0;
}
.rozie-text-field-label {
font-size: var(--rozie-text-field-label-size, var(--rtf-label-size, 0.875rem));
font-weight: var(--rozie-text-field-label-weight, var(--rtf-label-weight, 500));
color: var(--rozie-text-field-label-color, var(--rtf-label-color, inherit));
}
.rozie-text-field-required {
/* A margin, not a leading space in the markup: targets collapse that space differently. */
margin-left: 0.25em;
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-control {
display: flex;
align-items: center;
gap: var(--rozie-text-field-adornment-gap, var(--rtf-adornment-gap, 0.5rem));
box-sizing: border-box;
padding: 0 var(--rozie-text-field-padding-x, var(--rtf-padding-x, 0.75rem));
border: var(--rozie-text-field-border-width, var(--rtf-border-width, 1px)) solid var(--rozie-text-field-border-color, var(--rtf-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-text-field-radius, var(--rtf-radius, 0.5rem));
background: var(--rozie-text-field-bg, var(--rtf-bg, #fff));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.rozie-text-field--multiline .rozie-text-field-control {
align-items: flex-start;
}
.rozie-text-field-control:focus-within {
border-color: var(--rozie-text-field-focus-color, var(--rtf-focus-color, #2563eb));
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-focus-ring, var(--rtf-focus-ring, rgba(37, 99, 235, 0.25)));
}
.rozie-text-field--invalid .rozie-text-field-control {
border-color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field--invalid .rozie-text-field-control:focus-within {
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-error-ring, var(--rtf-error-ring, rgba(220, 38, 38, 0.25)));
}
.rozie-text-field-control:has(> .rozie-text-field-input:disabled) {
background: var(--rozie-text-field-disabled-bg, var(--rtf-disabled-bg, rgba(0, 0, 0, 0.04)));
opacity: var(--rozie-text-field-disabled-opacity, var(--rtf-disabled-opacity, 0.6));
}
.rozie-text-field-input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem)) 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: inherit;
line-height: var(--rozie-text-field-line-height, var(--rtf-line-height, 1.5));
}
textarea.rozie-text-field-input {
resize: var(--rozie-text-field-resize, var(--rtf-resize, vertical));
}
.rozie-text-field--autosize textarea.rozie-text-field-input {
resize: none;
}
.rozie-text-field-input::placeholder {
color: var(--rozie-text-field-placeholder-color, var(--rtf-placeholder-color, rgba(0, 0, 0, 0.45)));
}
.rozie-text-field-input:disabled {
cursor: not-allowed;
}
.rozie-text-field-prefix,
.rozie-text-field-suffix {
display: inline-flex;
align-items: center;
flex: none;
color: var(--rozie-text-field-adornment-color, var(--rtf-adornment-color, rgba(0, 0, 0, 0.55)));
}
.rozie-text-field--multiline .rozie-text-field-prefix,
.rozie-text-field--multiline .rozie-text-field-suffix {
padding-top: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem));
}
.rozie-text-field-description,
.rozie-text-field-error,
.rozie-text-field-count {
margin: 0;
font-size: var(--rozie-text-field-help-size, var(--rtf-help-size, 0.8125rem));
}
.rozie-text-field-description {
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
}
.rozie-text-field-error {
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-count {
align-self: flex-end;
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
font-variant-numeric: tabular-nums;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TextField),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class TextField {
/**
* The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.
* @example
* <rozie-text-field [(modelValue)]="email" label="Email" />
*/
readonly __rozieModelIn_modelValue = input<string | undefined>(undefined, { alias: 'modelValue' });
modelValueChange = output<string>();
modelValue = rozieModel<string, import('@angular/core').ModelSignal<string>>(this.__rozieModelIn_modelValue, this.modelValueChange, '', { signal, computed, untracked });
/**
* Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.
*/
label = input<string>('');
/**
* Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.
*/
description = input<string>('');
/**
* Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.
* @example
* <rozie-text-field [(modelValue)]="email" label="Email" [error]="emailError" />
*/
error = input<string>('');
/**
* Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.
*/
invalid = input<boolean>(false);
/**
* The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.
*/
type = input<string>('text');
/**
* Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.
*/
placeholder = input<string>('');
/**
* The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.
*/
name = input<string>('');
/**
* The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.
*/
autocomplete = input<string>('');
/**
* Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.
*/
required = input<boolean>(false);
/**
* Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled = input<boolean>(false);
/**
* Make the control read-only: the text is shown, focusable and selectable but cannot be edited.
*/
readonly = input<boolean>(false);
/**
* Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.
*/
maxLength = input<(number) | null>(null);
/**
* Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.
*/
showCount = input<boolean>(false);
/**
* Render a `<textarea>` instead of an `<input>`.
*/
multiline = input<boolean>(false);
/**
* Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.
*/
rows = input<number>(3);
/**
* Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.
* @example
* <rozie-text-field [(modelValue)]="notes" label="Notes" multiline autosize [maxRows]="8" />
*/
autosize = input<boolean>(false);
/**
* The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.
*/
maxRows = input<(number) | null>(null);
/**
* Accessible name for the control (`aria-label`) when there is no visible `label`.
*/
ariaLabel = input<(string) | null>(null);
/**
* Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase = input<string>('');
autoId = signal('');
textareaEl = viewChild<ElementRef<HTMLTextAreaElement>>('textareaEl');
inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
change = output<TextFieldValuePayload>();
enter = output<TextFieldValuePayload>();
private __rozieNativeEventGuard = ((host: HTMLElement, renderer: Renderer2) => ['change'].map((type) => renderer.listen(host, type, (e: Event) => { if (e.target !== host) e.stopImmediatePropagation(); })))(inject(ElementRef).nativeElement, inject(Renderer2));
@ContentChild('prefix', { read: TemplateRef }) prefixTpl?: TemplateRef<PrefixCtx>;
@ContentChild('suffix', { read: TemplateRef }) suffixTpl?: TemplateRef<SuffixCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
__rozieFills = contentChildren(RozieSlot, { descendants: true });
__rozieFillMap = computed(() => {
const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
for (const f of this.__rozieFills()) {
const k = f.rozieSlot();
if (k == null) continue;
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
map[k === '' ? 'defaultSlot' : k] = f.templateRef;
}
return map;
});
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
constructor() {
effect(() => { const __watchVal = (() => this.modelValue())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => this.fitLater())(); }); });
effect(() => { const __watchVal = (() => this.multiline())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => this.fitLater())(); }); });
effect(() => { const __watchVal = (() => this.autosize())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => this.fitLater())(); }); });
effect(() => { const __watchVal = (() => this.rows())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } (() => this.fitLater())(); }); });
effect(() => { const __watchVal = (() => this.maxRows())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } (() => this.fitLater())(); }); });
}
ngAfterViewInit() {
this.unmounted = false;
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
this.autoId.set('rozie-text-field-' + this.nextAutoId());
this.fit();
const el: any = this.control();
this.resetForm = el && el.form ? el.form : null;
if (this.resetForm) this.resetForm.addEventListener('reset', this.onFormReset);
this.__rozieDestroyRef.onDestroy(() => {
this.unmounted = true;
this.observeWidth(null);
if (this.resetForm) this.resetForm.removeEventListener('reset', this.onFormReset);
this.resetForm = null;
});
}
widthObserver: any = null;
observedEl: any = null;
lastWidth = -1;
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
autosized = false;
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
unmounted = false;
resetForm: any = null;
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// ---- ids -------------------------------------------------------------------
idRoot = () => this.idBase() || this.autoId() || 'rozie-text-field';
inputId = () => this.idRoot() + '-input';
descriptionId = () => this.idRoot() + '-description';
errorId = () => this.idRoot() + '-error';
countId = () => this.idRoot() + '-count';
// ---- derived state ---------------------------------------------------------
hasError = () => typeof this.error() === 'string' && this.error().length > 0;
isInvalid = () => !!this.invalid() || this.hasError();
currentText = () => this.modelValue() == null ? '' : String(this.modelValue());
// Read once into a local: Angular does not narrow across two signal calls.
hasLimit = () => {
const limit = this.maxLength();
return typeof limit === 'number' && limit >= 0;
};
countText = () => {
const n = this.currentText().length;
return this.hasLimit() ? n + ' / ' + this.maxLength() : String(n);
};
// The ids of the rendered helper text, space-joined, or null when there is none.
describedBy = () => {
const ids: string[] = [];
if (this.description()) ids.push(this.descriptionId());
if (this.hasError()) ids.push(this.errorId());
if (this.showCount()) ids.push(this.countId());
return ids.length > 0 ? ids.join(' ') : null;
};
inputType = () => this.type() ? this.type() : 'text';
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
optionalAttr = (v: any) => v ? v : undefined;
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
observeWidth = (el: any) => {
if (el === this.observedEl) return;
if (this.widthObserver) this.widthObserver.disconnect();
this.widthObserver = null;
this.observedEl = el;
this.lastWidth = -1;
if (!el || typeof ResizeObserver === 'undefined') return;
this.widthObserver = new ResizeObserver((entries: any) => {
const w = entries[0] ? entries[0].contentRect.width : -1;
if (w === this.lastWidth) return;
this.lastWidth = w;
requestAnimationFrame(() => this.fit());
});
this.widthObserver.observe(el);
};
fit = () => {
if (this.unmounted) return;
const el = this.multiline() ? this.textareaEl()?.nativeElement : null;
if (!el || !this.autosize()) {
this.observeWidth(null);
if (el && this.autosized) {
el.style.height = '';
el.style.overflowY = '';
}
this.autosized = false;
return;
}
this.observeWidth(el);
applyAutosize(el, this.rows(), this.maxRows());
this.autosized = true;
};
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
fitLater = () => {
setTimeout(() => this.fit(), 0);
};
// ---- handlers --------------------------------------------------------------
control = () => this.multiline() ? this.textareaEl()?.nativeElement : this.inputEl()?.nativeElement;
// Named commitText, NOT writeValue (the generated Angular CVA method).
commitText = (text: any) => {
this.modelValue.set(text), this.__rozieCvaOnChange(text);
};
onInput = (e: any) => {
this.commitText(e && e.target ? e.target.value : '');
this.fit();
};
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
onChange = (e: any) => {
this.change.emit({
value: e && e.target ? e.target.value : this.currentText()
});
};
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
onKeydown = (e: any) => {
if (!e || e.key !== 'Enter' || e.repeat) return;
const ne = e.nativeEvent ?? e;
if (ne.isComposing || ne.keyCode === 229) return;
this.enter.emit({
value: e.target ? e.target.value : this.currentText()
});
};
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
onFormReset = () => {
setTimeout(() => {
if (this.unmounted) return;
const el: any = this.control();
if (el && el.value !== this.currentText()) this.commitText(el.value);
this.fit();
}, 0);
};
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
focus: () => void = () => {
const el = this.control();
if (el && el.focus) el.focus();
};
// select() — focus the control and select all of its text.
select: () => void = () => {
const el: any = this.control();
if (!el) return;
if (el.focus) el.focus();
if (el.select) el.select();
};
// clear() — empty the field (writes modelValue = '').
clear: () => void = () => {
this.commitText('');
this.fit();
};
private __rozieCvaOnChange: (v: string) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: string | null): void {
this.modelValue.set(v ?? '');
}
registerOnChange(fn: (v: string) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: TextField,
_ctx: unknown,
): _ctx is PrefixCtx | SuffixCtx {
return true;
}
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));
private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default TextField;tsx
import type { JSX } from 'solid-js';
import { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
import { applyAutosize } from './internal/autosize';
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload {
value: string;
}
__rozieInjectStyle('TextField-346ae6aa', `.rozie-text-field[data-rozie-s-346ae6aa] {
display: flex;
flex-direction: column;
gap: var(--rozie-text-field-gap, var(--rtf-gap, 0.375rem));
font: var(--rozie-text-field-font, inherit);
color: var(--rozie-text-field-color, var(--rtf-color, inherit));
width: var(--rozie-text-field-width, var(--rtf-width, 100%));
min-width: 0;
}
.rozie-text-field-label[data-rozie-s-346ae6aa] {
font-size: var(--rozie-text-field-label-size, var(--rtf-label-size, 0.875rem));
font-weight: var(--rozie-text-field-label-weight, var(--rtf-label-weight, 500));
color: var(--rozie-text-field-label-color, var(--rtf-label-color, inherit));
}
.rozie-text-field-required[data-rozie-s-346ae6aa] {
/* A margin, not a leading space in the markup: targets collapse that space differently. */
margin-left: 0.25em;
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-control[data-rozie-s-346ae6aa] {
display: flex;
align-items: center;
gap: var(--rozie-text-field-adornment-gap, var(--rtf-adornment-gap, 0.5rem));
box-sizing: border-box;
padding: 0 var(--rozie-text-field-padding-x, var(--rtf-padding-x, 0.75rem));
border: var(--rozie-text-field-border-width, var(--rtf-border-width, 1px)) solid var(--rozie-text-field-border-color, var(--rtf-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-text-field-radius, var(--rtf-radius, 0.5rem));
background: var(--rozie-text-field-bg, var(--rtf-bg, #fff));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa] {
align-items: flex-start;
}
.rozie-text-field-control[data-rozie-s-346ae6aa]:focus-within {
border-color: var(--rozie-text-field-focus-color, var(--rtf-focus-color, #2563eb));
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-focus-ring, var(--rtf-focus-ring, rgba(37, 99, 235, 0.25)));
}
.rozie-text-field--invalid[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa] {
border-color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field--invalid[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa]:focus-within {
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-error-ring, var(--rtf-error-ring, rgba(220, 38, 38, 0.25)));
}
.rozie-text-field-control[data-rozie-s-346ae6aa]:has(> .rozie-text-field-input[data-rozie-s-346ae6aa]:disabled) {
background: var(--rozie-text-field-disabled-bg, var(--rtf-disabled-bg, rgba(0, 0, 0, 0.04)));
opacity: var(--rozie-text-field-disabled-opacity, var(--rtf-disabled-opacity, 0.6));
}
.rozie-text-field-input[data-rozie-s-346ae6aa] {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem)) 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: inherit;
line-height: var(--rozie-text-field-line-height, var(--rtf-line-height, 1.5));
}
textarea.rozie-text-field-input[data-rozie-s-346ae6aa] {
resize: var(--rozie-text-field-resize, var(--rtf-resize, vertical));
}
.rozie-text-field--autosize[data-rozie-s-346ae6aa] textarea.rozie-text-field-input[data-rozie-s-346ae6aa] {
resize: none;
}
.rozie-text-field-input[data-rozie-s-346ae6aa]::placeholder {
color: var(--rozie-text-field-placeholder-color, var(--rtf-placeholder-color, rgba(0, 0, 0, 0.45)));
}
.rozie-text-field-input[data-rozie-s-346ae6aa]:disabled {
cursor: not-allowed;
}
.rozie-text-field-prefix[data-rozie-s-346ae6aa],
.rozie-text-field-suffix[data-rozie-s-346ae6aa] {
display: inline-flex;
align-items: center;
flex: none;
color: var(--rozie-text-field-adornment-color, var(--rtf-adornment-color, rgba(0, 0, 0, 0.55)));
}
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-prefix[data-rozie-s-346ae6aa],
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-suffix[data-rozie-s-346ae6aa] {
padding-top: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem));
}
.rozie-text-field-description[data-rozie-s-346ae6aa],
.rozie-text-field-error[data-rozie-s-346ae6aa],
.rozie-text-field-count[data-rozie-s-346ae6aa] {
margin: 0;
font-size: var(--rozie-text-field-help-size, var(--rtf-help-size, 0.8125rem));
}
.rozie-text-field-description[data-rozie-s-346ae6aa] {
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
}
.rozie-text-field-error[data-rozie-s-346ae6aa] {
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-count[data-rozie-s-346ae6aa] {
align-self: flex-end;
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
font-variant-numeric: tabular-nums;
}`);
interface TextFieldProps extends Omit<import('solid-js').ComponentProps<'div'>, 'modelValue' | 'defaultModelValue' | 'onModelValueChange' | 'label' | 'description' | 'error' | 'invalid' | 'type' | 'placeholder' | 'name' | 'autocomplete' | 'required' | 'disabled' | 'readonly' | 'maxLength' | 'showCount' | 'multiline' | 'rows' | 'autosize' | 'maxRows' | 'ariaLabel' | 'idBase' | 'onChange' | 'onEnter' | 'prefixSlot' | 'suffixSlot' | 'slots' | 'ref' | 'children' | 'innerHTML' | 'innerText' | 'textContent'> {
/**
* The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.
* @example
* <TextField modelValue={email()} onModelValueChange={setEmail} label="Email" />
*/
modelValue?: string;
defaultModelValue?: string;
onModelValueChange?: (modelValue: string) => void;
/**
* Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.
*/
label?: string;
/**
* Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.
*/
description?: string;
/**
* Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.
* @example
* <TextField modelValue={email()} onModelValueChange={setEmail} label="Email" error={emailError} />
*/
error?: string;
/**
* Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.
*/
invalid?: boolean;
/**
* The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.
*/
type?: string;
/**
* Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.
*/
placeholder?: string;
/**
* The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.
*/
name?: string;
/**
* The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.
*/
autocomplete?: string;
/**
* Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.
*/
required?: boolean;
/**
* Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the control read-only: the text is shown, focusable and selectable but cannot be edited.
*/
readonly?: boolean;
/**
* Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.
*/
maxLength?: (number) | null;
/**
* Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.
*/
showCount?: boolean;
/**
* Render a `<textarea>` instead of an `<input>`.
*/
multiline?: boolean;
/**
* Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.
*/
rows?: number;
/**
* Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.
* @example
* <TextField modelValue={notes()} onModelValueChange={setNotes} label="Notes" multiline autosize maxRows={8} />
*/
autosize?: boolean;
/**
* The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.
*/
maxRows?: (number) | null;
/**
* Accessible name for the control (`aria-label`) when there is no visible `label`.
*/
ariaLabel?: (string) | null;
/**
* Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
onChange?: (payload: TextFieldValuePayload) => void;
onEnter?: (payload: TextFieldValuePayload) => void;
prefixSlot?: JSX.Element;
suffixSlot?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: TextFieldHandle) => void;
}
export interface TextFieldHandle {
focus: () => void;
select: () => void;
clear: () => void;
}
export default function TextField(_props: TextFieldProps): JSX.Element {
const _merged = mergeProps({ label: '', description: '', error: '', invalid: false, type: 'text', placeholder: '', name: '', autocomplete: '', required: false, disabled: false, readonly: false, maxLength: null, showCount: false, multiline: false, rows: 3, autosize: false, maxRows: null, ariaLabel: null, idBase: '' }, _props);
const [local, attrs] = splitProps(_merged, ['modelValue', 'label', 'description', 'error', 'invalid', 'type', 'placeholder', 'name', 'autocomplete', 'required', 'disabled', 'readonly', 'maxLength', 'showCount', 'multiline', 'rows', 'autosize', 'maxRows', 'ariaLabel', 'idBase', 'ref', 'onChange', 'onEnter', 'prefixSlot', 'suffixSlot', 'slots']);
onMount(() => { local.ref?.({ focus, select, clear }); });
const [modelValue, setModelValue] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'modelValue', '');
const [autoId, setAutoId] = createSignal('');
createEffect(on(() => (() => modelValue())(), (v) => untrack(() => (() => fitLater())()), { defer: true }));
createEffect(on(() => (() => local.multiline)(), (v) => untrack(() => (() => fitLater())()), { defer: true }));
createEffect(on(() => (() => local.autosize)(), (v) => untrack(() => (() => fitLater())()), { defer: true }));
createEffect(on(() => (() => local.rows)(), (v) => untrack(() => (() => fitLater())()), { defer: true }));
createEffect(on(() => (() => local.maxRows)(), (v) => untrack(() => (() => fitLater())()), { defer: true }));
onMount(() => {
const _cleanup = (() => {
unmounted = false;
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
setAutoId('rozie-text-field-' + nextAutoId());
fit();
const el: any = control();
resetForm = el && el.form ? el.form : null;
if (resetForm) resetForm.addEventListener('reset', onFormReset);
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
unmounted = true;
observeWidth(null);
if (resetForm) resetForm.removeEventListener('reset', onFormReset);
resetForm = null;
});
});
let textareaElRef: HTMLElement | null = null;
let inputElRef: HTMLElement | null = null;
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
let widthObserver: any = null;
let observedEl: any = null;
let lastWidth = -1;
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
let autosized = false;
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
let unmounted = false;
let resetForm: any = null;
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
function nextAutoId() {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
}
// ---- ids -------------------------------------------------------------------
function idRoot() {
return local.idBase || autoId() || 'rozie-text-field';
}
function inputId() {
return idRoot() + '-input';
}
function descriptionId() {
return idRoot() + '-description';
}
function errorId() {
return idRoot() + '-error';
}
function countId() {
return idRoot() + '-count';
}
// ---- derived state ---------------------------------------------------------
function hasError() {
return typeof local.error === 'string' && local.error.length > 0;
}
function isInvalid() {
return !!local.invalid || hasError();
}
function currentText() {
return modelValue() == null ? '' : String(modelValue());
}
// Read once into a local: Angular does not narrow across two signal calls.
function hasLimit() {
const limit = local.maxLength;
return typeof limit === 'number' && limit >= 0;
}
function countText() {
const n = currentText().length;
return hasLimit() ? n + ' / ' + local.maxLength : String(n);
}
// The ids of the rendered helper text, space-joined, or null when there is none.
function describedBy() {
const ids: string[] = [];
if (local.description) ids.push(descriptionId());
if (hasError()) ids.push(errorId());
if (local.showCount) ids.push(countId());
return ids.length > 0 ? ids.join(' ') : null;
}
function inputType() {
return local.type ? local.type : 'text';
}
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
function optionalAttr(v: any) {
return v ? v : undefined;
}
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
function observeWidth(el: any) {
if (el === observedEl) return;
if (widthObserver) widthObserver.disconnect();
widthObserver = null;
observedEl = el;
lastWidth = -1;
if (!el || typeof ResizeObserver === 'undefined') return;
widthObserver = new ResizeObserver((entries: any) => {
const w = entries[0] ? entries[0].contentRect.width : -1;
if (w === lastWidth) return;
lastWidth = w;
requestAnimationFrame(() => fit());
});
widthObserver.observe(el);
}
function fit() {
if (unmounted) return;
const el = local.multiline ? textareaElRef : null;
if (!el || !local.autosize) {
observeWidth(null);
if (el && autosized) {
el.style.height = '';
el.style.overflowY = '';
}
autosized = false;
return;
}
observeWidth(el);
applyAutosize(el, local.rows, local.maxRows);
autosized = true;
}
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
function fitLater() {
setTimeout(() => fit(), 0);
}
// ---- handlers --------------------------------------------------------------
function control() {
return local.multiline ? textareaElRef : inputElRef;
}
// Named commitText, NOT writeValue (the generated Angular CVA method).
function commitText(text: any) {
setModelValue(text);
}
function onInput(e: any) {
commitText(e && e.target ? e.target.value : '');
fit();
}
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
function onChange(e: any) {
_props.onChange?.({
value: e && e.target ? e.target.value : currentText()
});
}
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
function onKeydown(e: any) {
if (!e || e.key !== 'Enter' || e.repeat) return;
const ne = e.nativeEvent ?? e;
if (ne.isComposing || ne.keyCode === 229) return;
_props.onEnter?.({
value: e.target ? e.target.value : currentText()
});
}
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
function onFormReset() {
setTimeout(() => {
if (unmounted) return;
const el: any = control();
if (el && el.value !== currentText()) commitText(el.value);
fit();
}, 0);
}
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
function focus() {
const el = control();
if (el && el.focus) el.focus();
}
// select() — focus the control and select all of its text.
function select() {
const el: any = control();
if (!el) return;
if (el.focus) el.focus();
if (el.select) el.select();
}
// clear() — empty the field (writes modelValue = '').
function clear() {
commitText('');
fit();
}
return (
<>
<div {...attrs} class={"rozie-text-field" + " " + rozieClass({ 'rozie-text-field--invalid': isInvalid(), 'rozie-text-field--disabled': local.disabled, 'rozie-text-field--multiline': local.multiline, 'rozie-text-field--autosize': local.multiline && local.autosize }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-346ae6aa="">
{<Show when={local.label}><label class={"rozie-text-field-label"} for={rozieAttr(inputId())} data-rozie-s-346ae6aa="">{local.label}{<Show when={local.required}><span class={"rozie-text-field-required"} aria-hidden="true" data-rozie-s-346ae6aa="">*</span></Show>}</label></Show>}<div class={"rozie-text-field-control"} data-rozie-s-346ae6aa="">
{<Show when={(_props.prefixSlot ?? _props.slots?.['prefix'])}><span class={"rozie-text-field-prefix"} data-rozie-s-346ae6aa="">{(_props.prefixSlot ?? _props.slots?.['prefix']?.({}))}</span></Show>}{<Show when={local.multiline}><textarea aria-label={rozieAttr(local.ariaLabel)} aria-invalid={rozieAttr(isInvalid() ? 'true' : null)} aria-describedby={rozieAttr(describedBy())} ref={(el) => { textareaElRef = el as HTMLElement; }} class={"rozie-text-field-input"} id={rozieAttr(inputId())} value={currentText()} rows={local.rows} name={rozieAttr(optionalAttr(local.name))} placeholder={rozieAttr(optionalAttr(local.placeholder))} autocomplete={rozieAttr(optionalAttr(local.autocomplete))} maxLength={(local.maxLength) ?? undefined} required={!!local.required} disabled={!!local.disabled} readOnly={!!local.readonly} onInput={($event: InputEvent & { currentTarget: HTMLTextAreaElement; target: Element }) => { onInput($event); }} onChange={($event: Event & { currentTarget: HTMLTextAreaElement; target: Element }) => { $event.stopPropagation(); onChange($event); }} data-rozie-s-346ae6aa="" /></Show>}{<Show when={!local.multiline}><input aria-label={rozieAttr(local.ariaLabel)} aria-invalid={rozieAttr(isInvalid() ? 'true' : null)} aria-describedby={rozieAttr(describedBy())} ref={(el) => { inputElRef = el as HTMLElement; }} class={"rozie-text-field-input"} id={rozieAttr(inputId())} type={rozieAttr(inputType())} value={currentText()} name={rozieAttr(optionalAttr(local.name))} placeholder={rozieAttr(optionalAttr(local.placeholder))} autocomplete={rozieAttr(optionalAttr(local.autocomplete))} maxLength={(local.maxLength) ?? undefined} required={!!local.required} disabled={!!local.disabled} readOnly={!!local.readonly} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInput($event); }} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { $event.stopPropagation(); onChange($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeydown($event); }} data-rozie-s-346ae6aa="" /></Show>}{<Show when={(_props.suffixSlot ?? _props.slots?.['suffix'])}><span class={"rozie-text-field-suffix"} data-rozie-s-346ae6aa="">{(_props.suffixSlot ?? _props.slots?.['suffix']?.({}))}</span></Show>}</div>
{<Show when={local.description}><p class={"rozie-text-field-description"} id={rozieAttr(descriptionId())} data-rozie-s-346ae6aa="">{local.description}</p></Show>}{<Show when={hasError()}><p class={"rozie-text-field-error"} id={rozieAttr(errorId())} data-rozie-s-346ae6aa="">{local.error}</p></Show>}{<Show when={local.showCount}><p class={"rozie-text-field-count"} id={rozieAttr(countId())} data-rozie-s-346ae6aa="">{rozieDisplay(countText())}</p></Show>}</div>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { applyAutosize } from './internal/autosize';
// Top-level so React hoists it to a ref and the Solid onMount/onCleanup split
// sees it in teardown.
/** The `change` and `enter` event payload: the field's current text. */
export interface TextFieldValuePayload {
value: string;
}
export interface RozieTextFieldEventMap extends Omit<HTMLElementEventMap, 'change' | 'enter' | 'model-value-change'> {
'change': CustomEvent<TextFieldValuePayload>;
'enter': CustomEvent<TextFieldValuePayload>;
'model-value-change': CustomEvent<string>;
}
@customElement('rozie-text-field')
export default class TextField extends SignalWatcher(LitElement) {
static styles = css`
:host{display:contents}
.rozie-text-field[data-rozie-s-346ae6aa] {
display: flex;
flex-direction: column;
gap: var(--rozie-text-field-gap, var(--rtf-gap, 0.375rem));
font: var(--rozie-text-field-font, inherit);
color: var(--rozie-text-field-color, var(--rtf-color, inherit));
width: var(--rozie-text-field-width, var(--rtf-width, 100%));
min-width: 0;
}
.rozie-text-field-label[data-rozie-s-346ae6aa] {
font-size: var(--rozie-text-field-label-size, var(--rtf-label-size, 0.875rem));
font-weight: var(--rozie-text-field-label-weight, var(--rtf-label-weight, 500));
color: var(--rozie-text-field-label-color, var(--rtf-label-color, inherit));
}
.rozie-text-field-required[data-rozie-s-346ae6aa] {
/* A margin, not a leading space in the markup: targets collapse that space differently. */
margin-left: 0.25em;
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-control[data-rozie-s-346ae6aa] {
display: flex;
align-items: center;
gap: var(--rozie-text-field-adornment-gap, var(--rtf-adornment-gap, 0.5rem));
box-sizing: border-box;
padding: 0 var(--rozie-text-field-padding-x, var(--rtf-padding-x, 0.75rem));
border: var(--rozie-text-field-border-width, var(--rtf-border-width, 1px)) solid var(--rozie-text-field-border-color, var(--rtf-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-text-field-radius, var(--rtf-radius, 0.5rem));
background: var(--rozie-text-field-bg, var(--rtf-bg, #fff));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa] {
align-items: flex-start;
}
.rozie-text-field-control[data-rozie-s-346ae6aa]:focus-within {
border-color: var(--rozie-text-field-focus-color, var(--rtf-focus-color, #2563eb));
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-focus-ring, var(--rtf-focus-ring, rgba(37, 99, 235, 0.25)));
}
.rozie-text-field--invalid[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa] {
border-color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field--invalid[data-rozie-s-346ae6aa] .rozie-text-field-control[data-rozie-s-346ae6aa]:focus-within {
box-shadow: 0 0 0 var(--rozie-text-field-focus-ring-width, var(--rtf-focus-ring-width, 3px)) var(--rozie-text-field-error-ring, var(--rtf-error-ring, rgba(220, 38, 38, 0.25)));
}
.rozie-text-field-control[data-rozie-s-346ae6aa]:has(> .rozie-text-field-input[data-rozie-s-346ae6aa]:disabled) {
background: var(--rozie-text-field-disabled-bg, var(--rtf-disabled-bg, rgba(0, 0, 0, 0.04)));
opacity: var(--rozie-text-field-disabled-opacity, var(--rtf-disabled-opacity, 0.6));
}
.rozie-text-field-input[data-rozie-s-346ae6aa] {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem)) 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
color: inherit;
line-height: var(--rozie-text-field-line-height, var(--rtf-line-height, 1.5));
}
textarea.rozie-text-field-input[data-rozie-s-346ae6aa] {
resize: var(--rozie-text-field-resize, var(--rtf-resize, vertical));
}
.rozie-text-field--autosize[data-rozie-s-346ae6aa] textarea.rozie-text-field-input[data-rozie-s-346ae6aa] {
resize: none;
}
.rozie-text-field-input[data-rozie-s-346ae6aa]::placeholder {
color: var(--rozie-text-field-placeholder-color, var(--rtf-placeholder-color, rgba(0, 0, 0, 0.45)));
}
.rozie-text-field-input[data-rozie-s-346ae6aa]:disabled {
cursor: not-allowed;
}
.rozie-text-field-prefix[data-rozie-s-346ae6aa],
.rozie-text-field-suffix[data-rozie-s-346ae6aa] {
display: inline-flex;
align-items: center;
flex: none;
color: var(--rozie-text-field-adornment-color, var(--rtf-adornment-color, rgba(0, 0, 0, 0.55)));
}
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-prefix[data-rozie-s-346ae6aa],
.rozie-text-field--multiline[data-rozie-s-346ae6aa] .rozie-text-field-suffix[data-rozie-s-346ae6aa] {
padding-top: var(--rozie-text-field-padding-y, var(--rtf-padding-y, 0.5rem));
}
.rozie-text-field-description[data-rozie-s-346ae6aa],
.rozie-text-field-error[data-rozie-s-346ae6aa],
.rozie-text-field-count[data-rozie-s-346ae6aa] {
margin: 0;
font-size: var(--rozie-text-field-help-size, var(--rtf-help-size, 0.8125rem));
}
.rozie-text-field-description[data-rozie-s-346ae6aa] {
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
}
.rozie-text-field-error[data-rozie-s-346ae6aa] {
color: var(--rozie-text-field-error-color, var(--rtf-error-color, #dc2626));
}
.rozie-text-field-count[data-rozie-s-346ae6aa] {
align-self: flex-end;
color: var(--rozie-text-field-description-color, var(--rtf-description-color, rgba(0, 0, 0, 0.6)));
font-variant-numeric: tabular-nums;
}
`;
/**
* The text in the field (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so `[(ngModel)]` and `[formControl]` bind directly. Written on every keystroke.
* @example
* <rozie-text-field .modelValue=${email} @model-value-change=${…} label="Email"></rozie-text-field>
*/
@property({ type: String, attribute: 'model-value' }) _modelValue_attr: string = '';
private _modelValueControllable = createLitControllableProperty<string>({ host: this, eventName: 'model-value-change', defaultValue: '', initialControlledValue: undefined });
/**
* Visible label text, rendered as a `<label>` whose `for` points at the control. Leave it empty and set `ariaLabel` when the field has no visible label.
*/
@property({ type: String, reflect: true }) label: string = '';
/**
* Help text shown under the control and linked to it through `aria-describedby`, so screen readers announce it after the label.
*/
@property({ type: String, reflect: true }) description: string = '';
/**
* Error message. A non-empty value renders it under the control, links it through `aria-describedby`, and marks the control `aria-invalid="true"`. Clear it (empty string) once the value is valid again.
* @example
* <rozie-text-field .modelValue=${email} @model-value-change=${…} label="Email" .error=${emailError}></rozie-text-field>
*/
@property({ type: String, reflect: true }) error: string = '';
/**
* Mark the field invalid (`aria-invalid="true"` and the invalid styling) without showing a message, for example when the message is rendered elsewhere. A non-empty `error` implies this.
*/
@property({ type: Boolean, reflect: true }) invalid: boolean = false;
/**
* The native input `type`: `'text'`, `'email'`, `'password'`, `'search'`, `'tel'` or `'url'`. Ignored when `multiline` is set.
*/
@property({ type: String, reflect: true }) type: string = 'text';
/**
* Placeholder text shown while the field is empty. It is not a substitute for `label`: it disappears on input and many screen readers do not announce it.
*/
@property({ type: String, reflect: true }) placeholder: string = '';
/**
* The native `name` attribute, used when the field sits in a plain HTML `<form>`. On Lit the control is in a shadow root, so it does not join the outer form; bind `modelValue` there.
*/
@property({ type: String, reflect: true }) name: string = '';
/**
* The native `autocomplete` hint (e.g. `'email'`, `'current-password'`, `'off'`). Empty leaves the browser default.
*/
@property({ type: String, reflect: true }) autocomplete: string = '';
/**
* Mark the field required: sets the native `required` attribute (announced by screen readers) and shows a `*` after the label. The marker is `aria-hidden` so it is not read twice.
*/
@property({ type: Boolean, reflect: true }) required: boolean = false;
/**
* Disable the control. Also sets the Angular `ControlValueAccessor` disabled state.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* Make the control read-only: the text is shown, focusable and selectable but cannot be edited.
*/
@property({ type: Boolean, reflect: true }) readonly: boolean = false;
/**
* Maximum number of characters, forwarded as the native `maxlength`. `null` (the default) means no limit. With `showCount`, the counter reads `n / maxLength`.
*/
@property({ type: Number, reflect: true, attribute: 'max-length' }) maxLength: number | null = null;
/**
* Show a character counter under the control (`n / maxLength`, or just `n` without a limit). The counter is linked through `aria-describedby`; it is not a live region, so it does not interrupt typing.
*/
@property({ type: Boolean, reflect: true, attribute: 'show-count' }) showCount: boolean = false;
/**
* Render a `<textarea>` instead of an `<input>`.
*/
@property({ type: Boolean, reflect: true }) multiline: boolean = false;
/**
* Visible rows of a `multiline` field (the native `rows`). With `autosize` it is the minimum height.
*/
@property({ type: Number, reflect: true }) rows: number = 3;
/**
* Grow a `multiline` field to fit its content, from `rows` up to `maxRows`. It re-measures on input, on programmatic `modelValue` changes and when the field width changes.
* @example
* <rozie-text-field .modelValue=${notes} @model-value-change=${…} label="Notes" multiline autosize .maxRows=${8}></rozie-text-field>
*/
@property({ type: Boolean, reflect: true }) autosize: boolean = false;
/**
* The tallest an `autosize` field grows, in rows; past it the textarea scrolls. `null` (the default) means no cap.
*/
@property({ type: Number, reflect: true, attribute: 'max-rows' }) maxRows: number | null = null;
/**
* Accessible name for the control (`aria-label`) when there is no visible `label`.
*/
@property({ type: String, reflect: true, attribute: 'aria-label' }) ariaLabel: string | null = null;
/**
* Id base for the control and its text: the control is `idBase + '-input'`, and the description, error and counter are `-description`, `-error` and `-count`. Leave it empty (the default) and each instance generates a unique base after mount (`rozie-text-field-<n>`); set it when you need stable ids, for example to point an external element at the control. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
@property({ type: String, reflect: true, attribute: 'id-base' }) idBase: string = '';
private _autoId = signal('');
@query('[data-rozie-ref="textareaEl"]') private _refTextareaEl!: HTMLElement;
@query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieFirstUpdateDone = false;
@state() private _hasSlotPrefix = false;
@queryAssignedElements({ slot: 'prefix', flatten: true }) private _slotPrefixElements!: Element[];
@state() private _hasSlotSuffix = false;
@queryAssignedElements({ slot: 'suffix', flatten: true }) private _slotSuffixElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="prefix"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotPrefix = this._slotPrefixElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="suffix"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSuffix = this._slotSuffixElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotPrefix = Array.from(this.children).some((el) => el.getAttribute('slot') === 'prefix');
this._hasSlotSuffix = Array.from(this.children).some((el) => el.getAttribute('slot') === 'suffix');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push((() => {
this.unmounted = true;
this.observeWidth(null);
if (this.resetForm) this.resetForm.removeEventListener('reset', this.onFormReset);
this.resetForm = null;
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.modelValue)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => this.fitLater())(); }); }));
this.unmounted = false;
// Generated even when idBase is set, so clearing idBase later still leaves
// this instance its own ids.
this._autoId.value = 'rozie-text-field-' + this.nextAutoId();
this.fit();
const el: any = this.control();
this.resetForm = el && el.form ? el.form : null;
if (this.resetForm) this.resetForm.addEventListener('reset', this.onFormReset);
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('multiline'))) { const __watchVal = (() => this.multiline)(); (() => this.fitLater())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('autosize'))) { const __watchVal = (() => this.autosize)(); (() => this.fitLater())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('rows'))) { const __watchVal = (() => this.rows)(); (() => this.fitLater())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('maxRows'))) { const __watchVal = (() => this.maxRows)(); (() => this.fitLater())(); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'model-value') this._modelValueControllable.notifyAttributeChange(value as unknown as string);
}
render() {
return html`
<div class="${Object.entries({ "rozie-text-field": true, 'rozie-text-field--invalid': this.isInvalid(), 'rozie-text-field--disabled': this.disabled, 'rozie-text-field--multiline': this.multiline, 'rozie-text-field--autosize': this.multiline && this.autosize }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-346ae6aa>
${this.label ? html`<label class="rozie-text-field-label" for=${rozieAttr(this.inputId())} data-rozie-s-346ae6aa>${this.label}${this.required ? html`<span class="rozie-text-field-required" aria-hidden="true" data-rozie-s-346ae6aa>*</span>` : nothing}</label>` : nothing}<div class="rozie-text-field-control" data-rozie-s-346ae6aa>
${this._hasSlotPrefix ? html`<span class="rozie-text-field-prefix" data-rozie-s-346ae6aa><slot name="prefix"></slot></span>` : nothing}${this.multiline ? html`<textarea class="rozie-text-field-input" id=${rozieAttr(this.inputId())} .value=${this.currentText()} rows=${this.rows} name=${rozieAttr(this.optionalAttr(this.name))} placeholder=${rozieAttr(this.optionalAttr(this.placeholder))} autocomplete=${rozieAttr(this.optionalAttr(this.autocomplete))} maxlength=${rozieAttr(this.maxLength)} ?required=${!!this.required} ?disabled=${!!this.disabled} ?readonly=${!!this.readonly} aria-label=${rozieAttr(this.ariaLabel)} aria-invalid=${rozieAttr(this.isInvalid() ? 'true' : null)} aria-describedby=${rozieAttr(this.describedBy())} @input=${($event: InputEvent & { currentTarget: HTMLTextAreaElement; target: HTMLTextAreaElement }) => { this.onInput($event); }} @change=${($event: Event & { currentTarget: HTMLTextAreaElement; target: HTMLTextAreaElement }) => { $event.stopPropagation(); this.onChange($event); }} data-rozie-ref="textareaEl" data-rozie-s-346ae6aa></textarea>` : nothing}${!this.multiline ? html`<input class="rozie-text-field-input" id=${rozieAttr(this.inputId())} type=${rozieAttr(this.inputType())} .value=${this.currentText()} name=${rozieAttr(this.optionalAttr(this.name))} placeholder=${rozieAttr(this.optionalAttr(this.placeholder))} autocomplete=${rozieAttr(this.optionalAttr(this.autocomplete))} maxlength=${rozieAttr(this.maxLength)} ?required=${!!this.required} ?disabled=${!!this.disabled} ?readonly=${!!this.readonly} aria-label=${rozieAttr(this.ariaLabel)} aria-invalid=${rozieAttr(this.isInvalid() ? 'true' : null)} aria-describedby=${rozieAttr(this.describedBy())} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput($event); }} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { $event.stopPropagation(); this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} data-rozie-ref="inputEl" data-rozie-s-346ae6aa />` : nothing}${this._hasSlotSuffix ? html`<span class="rozie-text-field-suffix" data-rozie-s-346ae6aa><slot name="suffix"></slot></span>` : nothing}</div>
${this.description ? html`<p class="rozie-text-field-description" id=${rozieAttr(this.descriptionId())} data-rozie-s-346ae6aa>${this.description}</p>` : nothing}${this.hasError() ? html`<p class="rozie-text-field-error" id=${rozieAttr(this.errorId())} data-rozie-s-346ae6aa>${this.error}</p>` : nothing}${this.showCount ? html`<p class="rozie-text-field-count" id=${rozieAttr(this.countId())} data-rozie-s-346ae6aa>${rozieDisplay(this.countText())}</p>` : nothing}</div>
`;
}
widthObserver: any = null;
observedEl: any = null;
lastWidth = -1;
// Whether fit() has written an inline height, so turning `autosize` off can
// remove it (and never touches a height the user set with the resize grip).
autosized = false;
// Set by the unmount cleanup, so a re-fit still pending then does nothing (on
// Lit, Angular and Solid the element refs outlive the unmount).
unmounted = false;
resetForm: any = null;
// The page-wide id counter (the same four lines as Combobox / Listbox / Popover).
nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// ---- ids -------------------------------------------------------------------
idRoot = () => this.idBase || this._autoId.value || 'rozie-text-field';
inputId = () => this.idRoot() + '-input';
descriptionId = () => this.idRoot() + '-description';
errorId = () => this.idRoot() + '-error';
countId = () => this.idRoot() + '-count';
// ---- derived state ---------------------------------------------------------
hasError = () => typeof this.error === 'string' && this.error.length > 0;
isInvalid = () => !!this.invalid || this.hasError();
currentText = () => this.modelValue == null ? '' : String(this.modelValue);
// Read once into a local: Angular does not narrow across two signal calls.
hasLimit = () => {
const limit = this.maxLength;
return typeof limit === 'number' && limit >= 0;
};
countText = () => {
const n = this.currentText().length;
return this.hasLimit() ? n + ' / ' + this.maxLength : String(n);
};
// The ids of the rendered helper text, space-joined, or null when there is none.
describedBy = () => {
const ids: string[] = [];
if (this.description) ids.push(this.descriptionId());
if (this.hasError()) ids.push(this.errorId());
if (this.showCount) ids.push(this.countId());
return ids.length > 0 ? ids.join(' ') : null;
};
inputType = () => this.type ? this.type : 'text';
// An optional string attribute: omitted when empty. Untyped param → `any`, so it
// feeds strict DOM attribute types on every target (Vue's `string | undefined`,
// Svelte's closed `autocomplete` union) without a per-target cast.
optionalAttr = (v: any) => v ? v : undefined;
// ---- autosize --------------------------------------------------------------
// A width change re-wraps the text, so an autosized textarea is observed and
// re-measured. Only a WIDTH change counts: our own height write must not loop
// the observer. Re-targets when the textarea is (re)mounted by `multiline`.
// The re-fit waits for the next frame: changing the observed element's height
// inside the callback is the "ResizeObserver loop completed with undelivered
// notifications" error.
observeWidth = (el: any) => {
if (el === this.observedEl) return;
if (this.widthObserver) this.widthObserver.disconnect();
this.widthObserver = null;
this.observedEl = el;
this.lastWidth = -1;
if (!el || typeof ResizeObserver === 'undefined') return;
this.widthObserver = new ResizeObserver((entries: any) => {
const w = entries[0] ? entries[0].contentRect.width : -1;
if (w === this.lastWidth) return;
this.lastWidth = w;
requestAnimationFrame(() => this.fit());
});
this.widthObserver.observe(el);
};
fit = () => {
if (this.unmounted) return;
const el = this.multiline ? this._refTextareaEl : null;
if (!el || !this.autosize) {
this.observeWidth(null);
if (el && this.autosized) {
el.style.height = '';
el.style.overflowY = '';
}
this.autosized = false;
return;
}
this.observeWidth(el);
applyAutosize(el, this.rows, this.maxRows);
this.autosized = true;
};
// Re-measure once every target has rendered the change: a macrotask, because
// Lit applies its update in a microtask of its own.
fitLater = () => {
setTimeout(() => this.fit(), 0);
};
// ---- handlers --------------------------------------------------------------
control = () => this.multiline ? this._refTextareaEl : this._refInputEl;
// Named commitText, NOT writeValue (the generated Angular CVA method).
commitText = (text: any) => {
this._modelValueControllable.write(text);
};
onInput = (e: any) => {
this.commitText(e && e.target ? e.target.value : '');
this.fit();
};
// `change` = the DOM `change` event: the user committed an edit (blurred after
// changing the text, or pressed Enter where the browser commits on it). A
// programmatic write never fires it. The template stops the native event so it
// can't bubble out alongside this component's own `change` (Angular has no
// shadow boundary to contain it).
onChange = (e: any) => {
this.dispatchEvent(new CustomEvent<TextFieldValuePayload>("change", {
detail: {
value: e && e.target ? e.target.value : this.currentText()
},
bubbles: true,
composed: true
}));
};
// Enter that confirms an IME composition is not a commit. React's synthetic
// event has no `isComposing`, so read the native event; Safari reports the
// confirming Enter with keyCode 229 and `isComposing` false.
onKeydown = (e: any) => {
if (!e || e.key !== 'Enter' || e.repeat) return;
const ne = e.nativeEvent ?? e;
if (ne.isComposing || ne.keyCode === 229) return;
this.dispatchEvent(new CustomEvent<TextFieldValuePayload>("enter", {
detail: {
value: e.target ? e.target.value : this.currentText()
},
bubbles: true,
composed: true
}));
};
// ---- lifecycle -------------------------------------------------------------
// A form reset puts the control back to its default value without an input
// event. Vue, Svelte, Angular and Solid set the value as a property only, so
// the control empties while modelValue (and the counter, the autosize height,
// an Angular FormControl) keep the old text. The reset event fires before the
// reset, so the control is read a task later.
onFormReset = () => {
setTimeout(() => {
if (this.unmounted) return;
const el: any = this.control();
if (el && el.value !== this.currentText()) this.commitText(el.value);
this.fit();
}, 0);
};
// ---- imperative handle -----------------------------------------------------
// focus() deliberately overrides HTMLElement.focus on Lit (ROZ137, accepted).
focus: () => void = () => {
const el = this.control();
if (el && el.focus) el.focus();
};
// select() — focus the control and select all of its text.
select: () => void = () => {
const el: any = this.control();
if (!el) return;
if (el.focus) el.focus();
if (el.select) el.select();
};
// clear() — empty the field (writes modelValue = '').
clear: () => void = () => {
this.commitText('');
this.fit();
};
get modelValue(): string { return this._modelValueControllable.read(); }
set modelValue(v: string) { this._modelValueControllable.notifyPropertyWrite(v); }
addEventListener<K extends keyof RozieTextFieldEventMap>(type: K, listener: (this: TextField, ev: RozieTextFieldEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void {
super.addEventListener(type, listener, options);
}
removeEventListener<K extends keyof RozieTextFieldEventMap>(type: K, listener: (this: TextField, ev: RozieTextFieldEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void {
super.removeEventListener(type, listener, options);
}
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*
* command-palette-per-level-virtual / portal-through-portal cluster —
* `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
* attribute, never a consumer prop) so a parent-assigned `ref=` on this
* component's own host tag can never clobber this component's OWN
* internal `data-rozie-ref` ref markers via fallthrough re-application.
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['data-rozie-ref', 'model-value', 'modelvalue', 'label', 'description', 'error', 'invalid', 'type', 'placeholder', 'name', 'autocomplete', 'required', 'disabled', 'readonly', 'max-length', 'maxlength', 'show-count', 'showcount', 'multiline', 'rows', 'autosize', 'max-rows', 'maxrows', 'aria-label', 'arialabel', 'id-base', 'idbase']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}See also
- TextField — showcase & API — install, quick start, theming, accessibility, and the full reference.
- Text field comparison — what
@rozie-ui/text-fieldreplaces in each framework, and what it deliberately does not do.