@vparchment/tada
v0.32.2
Published
A portable, domain-agnostic frontend design system.
Downloads
277
Maintainers
Readme
Tada Design System
Version: 0.31.0
A portable, domain-agnostic frontend design system.
Philosophy
- Opinion in organisation, not in style
- Domain-agnostic: No business logic, no context, no SDKs
- Props in, callbacks out, slots for composition
- Accessibility is a first-class concern
Overview
This document captures the architecture, principles, and structure agreed upon for the Petscribers frontend design system. It is intended as the canonical reference before implementation begins.
The system is built around a single governing philosophy: opinion in organisation, not in style. Constraints are principled and enforced by structure rather than convention. The design system is a standalone, portable package — domain-agnostic, independently versioned, and tested in isolation.
Principles
Constraints enforce clarity. Every architectural boundary exists for a specific reason. When a decision is ambiguous, the constraints resolve it mechanically rather than requiring judgment each time.
Components are agnostic to state origin. No component knows or cares whether its props came from a Solid signal, a store, an SSE event, or a hardcoded value. State locality is determined by lifetime and scope, not by library preference.
The package boundary is inviolable. No context consumption, no store access, no SDK knowledge inside the design system package. Props flow in, callbacks flow out, slots handle composition.
The SDK boundary is the ceiling of business logic. Everything below the store layer is pure interface — reflecting values and emitting events. Domain concepts do not leak into the component tree.
Accessibility is philosophical, not regulatory. A11y is a first-class concern at the primitive level and is maintained consistently, not just where it is audited.
Architecture
Full Stack Data Flow
WASM SDK (imperative calls) ─┐
SSE / Presto-Voila (push) ─┤──► Store Layer ──► Organisms ──► Composites ──► Primitives
User interactions ─┘The store layer is the only place the SDK is imported. Everything below it is reactive to props alone.
State Locality
State lives at the lowest level that owns it. Components are agnostic to the source.
Level Tool Example
─────────────────────────────────────────────────────────────────
Global / SDK-derived createStore + context prescription status, active user
Portal session createStore + context selected practice, role context
Form / scoped UI createStore, scoped field values, validation state
Component interaction createSignal dropdown open, focus state
Ephemeral createSignal, inline tooltip visible, hover stateNo external state management dependencies. Solid's native primitives (createSignal, createStore, createContext) cover all cases. Existing Zustand usage is migrated opportunistically as components are touched — not as a dedicated migration project.
SSE Lifecycle
SSE connections (via Presto-Voila) are managed in the coordination layer above the store. Components never own connection lifecycle. Solid's onCleanup handles teardown at the appropriate level. From the store's perspective, an SSE event and an SDK call are both just writes — the source is irrelevant.
Design System Package
Identity
- Separate, independently versioned npm package
- Domain-agnostic — zero knowledge of Petscribers concepts
- Portals and future projects consume and extend it
- Tested in isolation — props in, rendered output out
Layer Structure
design-system/
├── tokens/ # Design tokens — the source of truth for all visual decisions
├── primitives/ # Single elements, a11y, style variants, zero domain state
├── composites/ # Composed primitives, local UI state only
├── components/ # Generic superstructures, unopinionated collections
└── index.ts # Public APITokens
The source of truth for all visual decisions. Tailwind config is derived from tokens, not the other way around. Consumers extend the token layer — they do not override it.
Covers: colour, spacing, typography scale, border radius, shadow, z-index, motion duration.
Tailwind's role is reset + sane defaults + DX for bootstrapping. It is the floor, not the ceiling. The opinion is in how things are organised, not what the styles are.
Primitives
- Single HTML element with a11y attributes, ARIA roles, keyboard contracts
- Style variants via Tailwind utility classes
- Only legitimate state: focus, hover, disabled, error — pure UI micro-state
- Zero domain concepts, zero context consumption
- Examples: Button, Input, Checkbox, Radio, Pill, Label, Avatar, Icon, Card, Tabs, Accordion, Select, Dialog, Tooltip, Listbox, Skeleton, Slider, Switch, DateInput, Textarea
Composites
- Composed primitives acting in concert
- Local UI state only — open/closed, selected index, keyboard navigation
- No business concepts, no store access
- Examples: Form, Combobox, Menu, Carousel, Spinner, Table
The hard composites (Combobox, Menu) carry the meaningful a11y complexity — keyboard contracts, focus trapping, ARIA relationships. These are built from scratch to own the implementation fully, rather than wrapping a third-party headless library.
Toast is explicitly excluded — the existing solution is retained as-is.
Menu
A slide-in panel (drawer) composite built for navigation and contextual UI. The Menu root manages controlled/uncontrolled open state; the remaining parts are consumed inside it.
| Component | Description |
|---|---|
| Menu | Root context provider. Accepts open, defaultOpen, onOpenChange. |
| MenuTrigger | A styled-reset <button> that opens the menu. Accepts style, class. |
| MenuSheet | The slide-in panel. Accepts side ('left'|'right', default 'right'), width (default '320px'), fullScreenBelow (breakpoint string or false, default '480px'), closeOnOverlayClick (default true), openAnimation (CSS animation shorthand or false; defaults to a slide-in from side), closeAnimation (CSS animation shorthand or false; defaults to a slide-out toward side). Renders a backdrop overlay and traps focus; Escape closes. |
| MenuHeader | Header bar with a title slot and an optional close button. Accepts showClose (default true), closeLabel (default 'Close'), closeButton (custom element). |
| MenuContent | Scrollable body region. Accepts style, class. |
| MenuFooter | Footer bar pinned to the bottom. Accepts style, class. |
| MenuClose | A styled-reset <button> that closes the menu — use anywhere inside Menu. |
| MenuFloatingToggle | A position: fixed toggle button that floats above the overlay. Accepts side, insetInline (default '1rem'), insetBlock (default '1rem'), openLabel, closeLabel. Children may be a render function receiving isOpen: Accessor<boolean> so icons can swap dynamically. |
<Menu>
<MenuTrigger>Open</MenuTrigger>
<MenuSheet side="right" width="360px">
<MenuHeader>Settings</MenuHeader>
<MenuContent>…</MenuContent>
<MenuFooter>
<MenuClose>Close</MenuClose>
</MenuFooter>
</MenuSheet>
</Menu>
{/* Floating hamburger variant */}
<Menu>
<MenuFloatingToggle side="left">
{(isOpen) => (isOpen() ? <CloseIcon /> : <HamburgerIcon />)}
</MenuFloatingToggle>
<MenuSheet side="left">…</MenuSheet>
</Menu>Components
- Composites forming generic superstructures
- Structural opinions only — field order, layout relationships, interaction patterns
- No style opinions, no domain opinions
- Injectable: what is searched, how results are styled, what happens on selection — all injected by the consumer
- Examples: SearchInput + Results, Login shell, Drag-and-drop list, Data table, Notification feed, Paginated list
The portability contract at this level: the component owns the relationship between its parts, not the semantics of the content.
Package Boundary Contract
✓ Props in
✓ Callbacks out
✓ Slots / children for composition
✓ Tailwind config exported for consumer extension
✗ No context consumption
✗ No store access
✗ No SDK imports
✗ No domain vocabularyStructure
src/
tokens/ # Design tokens (colors, spacing, etc)
primitives/ # Atomic UI elements (Button, Input, etc)
composites/ # Composed primitives (Tabs, Dialog, etc)
components/ # Generic superstructures (SearchInput, DataTable, etc)Usage
Linked Containers
Container is a validity-aware primitive that nests to arbitrary depth. Containers wire themselves together automatically via Solid context — a child registers with its nearest Container ancestor on mount and unregisters on cleanup. No manual wiring is needed.
How the tree works
Each container holds two things:
- Local state — its own
localStatusandlocalReason, set via props. - Child states — a
Mapof every direct child's fullContainerValidityState, populated automatically as children mount and unmount.
From those two things it derives a single status — the worst of its own local status and every direct child's derived status. The visual treatment (border colour, background) always reflects this derived status, not the local one. A container that is locally ok will still render as error if any descendant is in error.
Status severity order: error > warning > pending > ok
reason is local-only. It is never inherited from children. A parent's reason describes only the parent's own failure.
ContainerValidityState
Every container produces and reports this shape upward:
type ContainerValidityState = {
key: string; // stable name within the parent's scope
status: Status; // derived: worst of local + all children
reason: string | null; // local failure reason only
children: Map<string, ContainerValidityState>; // direct children, recursively
};Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | auto-generated | Stable identifier within the parent's scope. Provide an explicit value when the container must be addressable or predictable. |
| localStatus | Status | 'pending' | This container's own validity status. |
| localReason | string \| null | null | This container's own failure reason. Never propagated upward. |
| onValidityChange | (state: ContainerValidityState) => void | — | Fires whenever the container's full validity state changes, including changes triggered by descendants. Use on root containers to observe the complete tree. |
| showWhen | (parentState: ContainerValidityState) => boolean | — | Controls whether this container is rendered, based on the parent container's current validity state. Receives the parent's full ContainerValidityState, giving access to sibling containers via parentState.children. When hidden, the container unregisters from its parent and does not affect the parent's derived status. Has no effect on root containers. |
| locked | boolean | — | When true, renders a visual overlay over the container's children and marks them as inert, blocking all interaction — mouse, keyboard, focus, and assistive technology. Validity state continues to propagate normally; locking is purely a UI concern. |
| overlayClass | string | — | Additional CSS class applied to the lock overlay element. |
| overlayStyle | JSX.CSSProperties | — | Inline styles merged into the lock overlay, applied after the defaults. Defaults produce a white overlay at 50% opacity covering the full container. |
| overlayContent | JSX.Element | — | Content rendered inside the lock overlay — intended for a status icon or label. |
| style | JSX.CSSProperties | — | Merged on top of the container's base + status styles. |
| class | string | — | Applied to the root <div>. |
Examples
Flat — root observes multiple children
The simplest pattern: a root container with no local state of its own, observing two named children. onValidityChange fires whenever anything in the tree changes.
const [formState, setFormState] = createSignal<ContainerValidityState | null>(null);
<Container name="root" onValidityChange={setFormState}>
<Container name="patient" localStatus="ok" />
<Container name="medication" localStatus="warning" localReason="Review required" />
</Container>
// formState().status → 'warning' (worst of ok + warning)
// formState().children.get('medication')?.reason → 'Review required'Driven by signals
localStatus and localReason are reactive — pass signals directly to reflect async validation results.
const [doseStatus, setDoseStatus] = createSignal<Status>('pending');
const [doseReason, setDoseReason] = createSignal<string | null>(null);
// Somewhere in your validation logic:
// setDoseStatus('error');
// setDoseReason('Exceeds maximum dose');
<Container name="prescription">
<Container name="drug" localStatus="ok" />
<Container name="dose" localStatus={doseStatus()} localReason={doseReason()} />
</Container>Deeply nested — error propagates to root
Status bubbles up level by level. A leaf error reaches the root regardless of depth.
<Container name="root" localStatus="ok" onValidityChange={(s) => console.log(s.status)}>
<Container name="section-a" localStatus="ok">
<Container name="field-1" localStatus="ok" />
<Container name="field-2" localStatus="error" localReason="Required" />
</Container>
<Container name="section-b" localStatus="ok" />
</Container>
// root's derived status → 'error'
// section-a's derived status → 'error'
// section-b's derived status → 'ok'
// root.reason → null (reason is local-only; root has no local reason)
// root.children.get('section-a')?.children.get('field-2')?.reason → 'Required'Gating a submit action
Use onValidityChange on the root to gate actions. The callback receives the full tree, so you can inspect any subtree or just check the top-level status.
const [validity, setValidity] = createSignal<ContainerValidityState | null>(null);
const canSubmit = () => validity()?.status === 'ok';
<Container name="form-root" localStatus="ok" onValidityChange={setValidity}>
<Container name="patient" localStatus={patientStatus()} />
<Container name="prescriber" localStatus={prescriberStatus()} />
<Container name="medication" localStatus={medicationStatus()} />
</Container>
<Button disabled={!canSubmit()}>Submit</Button>Conditional visibility — showWhen
Use showWhen to show a container only when a sibling's validity state meets a condition. The predicate receives the parent's ContainerValidityState, so all siblings are reachable via parentState.children.
When hidden, the container is fully unmounted — it unregisters from its parent and contributes nothing to the parent's derived status. When it re-appears it re-registers as pending and begins propagating normally.
<Container name="prescription-root">
<Container name="patient" localStatus={patientStatus()} />
{/* Only appears — and only counts toward root validity — when patient has errors */}
<Container
name="override-reason"
localStatus={overrideStatus()}
showWhen={(p) => p.children.get('patient')?.status === 'error'}
>
<TextInput placeholder="Reason for override" />
</Container>
</Container>The predicate can inspect any field of the parent state, including nested children and status values at any severity:
// Show if any direct child has an error
showWhen={(p) => [...p.children.values()].some(c => c.status === 'error')}
// Show only when the parent itself is fully valid
showWhen={(p) => p.status === 'ok'}Note:
showWhenhas no effect on root containers (containers with no parentContainerancestor) — they are always rendered.
Locking — locked
Use locked to keep a container visible but non-interactive. When true, a semi-transparent overlay is rendered over the container and the children are marked inert — all mouse, keyboard, and focus interaction is blocked, including elements not reached by the overlay (e.g. via tab navigation). Validity state is unaffected.
const [signing, setSigning] = createSignal<Status>('pending');
<Container name="prescription">
<Container name="patient" localStatus={patientStatus()} locked={signing() === 'ok'} />
<Container name="medication" localStatus={medicationStatus()} locked={signing() === 'ok'} />
<Container name="signing" localStatus={signing()}>
{/* completing this locks the rest of the form */}
</Container>
</Container>Customise the overlay appearance and provide an icon via overlayStyle and overlayContent:
<Container
name="patient"
localStatus={patientStatus()}
locked={isSigned()}
overlayStyle={{ 'background-color': 'rgba(0, 0, 0, 0.08)' }}
overlayContent={<LockIcon style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }} />}
/>Note:
lockedandshowWhenare independent — a container can be conditionally shown and locked at the same time. Validity state always propagates regardless of either.
Notes
- Containers batch all
register/unregister/updatecalls from a single synchronous frame into one reactive notification — large mounts do not cause N separate re-renders. - Two levels of nesting require two microtask flushes to fully propagate; this is automatic in a running Solid app but relevant to know when writing tests.
namedefaults to a SolidcreateUniqueId()value when omitted — only provide it when the key must be stable or addressable viachildrenmaps.- A container hidden by
showWhenis fully unmounted and does not influence its parent's derived status. It re-registers (aspending) when it becomes visible again.
Slider
A fully accessible, single-value or dual-thumb range slider primitive. Supports tick marks, value tooltips, annotation ranges, and both horizontal and vertical orientations.
Props
Core
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number \| [number, number] | — | Controlled value. Pass a [low, high] tuple to activate range (two-thumb) mode. |
| defaultValue | number \| [number, number] | — | Initial value for uncontrolled usage. Pass a tuple for range mode. |
| min | number | 0 | Minimum value. |
| max | number | 100 | Maximum value. |
| step | number | 1 | Increment snapped to on drag and keyboard movement. |
| size | 'sm' \| 'md' \| 'lg' | 'md' | Controls track thickness, thumb size, and mark size. |
| orientation | 'horizontal' \| 'vertical' | 'horizontal' | Track direction. For vertical sliders set an explicit height on the wrapper. |
| disabled | boolean | — | Prevents interaction and dims the slider. |
| invalid | boolean | — | Applies danger styling to the thumb. |
| required | boolean | — | Forwarded to the hidden input for form validation. |
| id | string | — | Applied to the first thumb element. |
| name | string | — | Emits hidden <input> elements for form submission. Range mode emits name[0] and name[1]. |
| onChange | (value: SliderValue, e: PointerEvent \| KeyboardEvent) => void | — | Fires on every value change from pointer or keyboard. |
| link | FieldLink<any> | — | Syncs state into a createFieldLink() channel so linked labels observe it. |
Marks
| Prop | Type | Default | Description |
|---|---|---|---|
| marks | boolean \| SliderMark[] | — | true auto-generates a mark at every step (capped at 200). Pass an array for explicit positions with optional labels. |
SliderMark:
| Field | Type | Description |
|---|---|---|
| value | number | Position on the value axis. |
| label | string | Optional text label rendered beside the dot. |
Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
| tooltip | boolean \| ((value: number) => JSX.Element) | — | Shows a tooltip above each thumb on focus and drag. true displays the raw number. Pass a function to render custom content. |
Accessibility
| Prop | Type | Description |
|---|---|---|
| aria-label | string | Accessible label for the thumb(s). |
| aria-labelledby | string | ID of an external label element. |
| aria-valuetext | string \| ((value: number) => string) | Custom screen-reader announcement instead of the raw number. In range mode pass a function to produce different text per thumb. |
Styling
All style props accept inline JSX.CSSProperties objects and/or a class string. They are spread last onto the element, so any property can be overridden.
| Prop | Targets |
|---|---|
| style / class | Outer wrapper <div> |
| trackStyle / trackClass | Track bar |
| fillStyle / fillClass | Filled region |
| thumbStyle / thumbClass | Every thumb |
| markStyle / markClass | Every mark dot |
Annotation ranges
| Prop | Type | Default | Description |
|---|---|---|---|
| ranges | SliderRange[] | — | Coloured bands overlaid on the track to mark named value zones. |
| rangesExploded | boolean | false | When true, bars separate from the track and stack alongside it so overlapping ranges can be compared side-by-side. Animates between collapsed and exploded states. |
| rangesStackDirection | 'above' \| 'below' | 'above' | Default stacking side when rangesExploded is true. For vertical sliders 'above' maps to the left and 'below' to the right. |
SliderRange fields:
| Field | Type | Default | Description |
|---|---|---|---|
| min | number | — | Start of the range in value-space. |
| max | number | — | End of the range in value-space. |
| color | string | warningSubtle token | Background colour of the bar. |
| thickness | string \| number | — | Bar thickness (height for horizontal, width for vertical). A number is treated as px. When omitted the bar matches the track thickness. The bar is automatically centred on the track axis when a custom thickness is set. |
| layer | 'front' \| 'back' | 'front' | 'front' draws the bar on top of the fill; 'back' draws it beneath the fill so the fill overlaps the bar. |
| label | string | — | Text label rendered adjacent to the bar. |
| labelAlign | 'left' \| 'right' | 'left' | Which edge the label anchors to. Horizontal: left/right edge. Vertical: top/bottom edge. |
| stackDirection | 'above' \| 'below' | slider default | Which side this bar stacks toward when rangesExploded is true. Falls back to the slider-level rangesStackDirection, then 'above'. |
| style / class | JSX.CSSProperties / string | — | Styles applied to the bar element. |
| labelStyle / labelClass | JSX.CSSProperties / string | — | Styles applied to the label element. |
Examples
Uncontrolled
<Slider defaultValue={40} />Controlled
const [volume, setVolume] = createSignal(60);
<Slider
value={volume()}
onChange={(v) => setVolume(v as number)}
/>Range mode (two thumbs)
Pass a [low, high] tuple to either value or defaultValue.
const [range, setRange] = createSignal<[number, number]>([20, 75]);
<Slider
value={range()}
onChange={(v) => setRange(v as [number, number])}
/>Sizes
<Slider defaultValue={50} size="sm" />
<Slider defaultValue={50} size="md" /> {/* default */}
<Slider defaultValue={50} size="lg" />Vertical orientation
Set an explicit height on the wrapper (or the slider itself via style).
<Slider
defaultValue={50}
orientation="vertical"
style={{ height: '200px' }}
/>Tick marks — auto
Generates a dot at every step. Capped at 200 marks to avoid visual noise.
<Slider defaultValue={3} min={1} max={5} step={1} marks />Tick marks — explicit with labels
<Slider
defaultValue={50}
marks={[
{ value: 0, label: 'Low' },
{ value: 50, label: 'Mid' },
{ value: 100, label: 'High' },
]}
/>Value tooltip
{/* Raw number */}
<Slider defaultValue={42} tooltip />
{/* Custom content */}
<Slider
defaultValue={42}
tooltip={(v) => <span>{v} kg</span>}
/>Disabled and invalid states
<Slider defaultValue={50} disabled />
<Slider defaultValue={50} invalid />Styling individual parts
All style props are spread last so any CSS property can be overridden.
<Slider
defaultValue={50}
class="my-slider"
trackStyle={{ 'background-color': '#e5e7eb' }}
fillStyle={{ background: 'linear-gradient(to right, #6366f1, #8b5cf6)' }}
thumbStyle={{ 'border-color': '#6366f1', 'background-color': 'white' }}
markStyle={{ 'background-color': '#6366f1' }}
/>Annotation ranges — basic
<Slider
defaultValue={60}
ranges={[
{ min: 0, max: 40, color: '#86efac', label: 'Normal' },
{ min: 40, max: 70, color: '#fde68a', label: 'Elevated' },
{ min: 70, max: 100, color: '#fca5a5', label: 'High' },
]}
/>Range bar behind the fill
Use layer: 'back' so the fill renders on top of the annotation bar. Combine with thickness to make the bar thinner so it peeks out at the edges.
<Slider
defaultValue={50}
ranges={[{
min: 30, max: 80,
color: '#fde68a',
layer: 'back',
thickness: 3, // 3px, centred on the track axis
}]}
/>Custom thickness without layer change
The bar is centred on the track axis automatically when thickness is set.
<Slider
defaultValue={50}
ranges={[{
min: 20, max: 60,
color: '#a5b4fc',
thickness: '50%', // any CSS length
}]}
/>Exploded ranges
When rangesExploded is true, overlapping bars separate from the track and stack alongside it so all zones remain legible simultaneously.
<Slider
defaultValue={50}
rangesExploded
rangesStackDirection="above"
ranges={[
{ min: 10, max: 50, color: '#fde68a', label: 'Zone A', stackDirection: 'above' },
{ min: 30, max: 80, color: '#86efac', label: 'Zone B', stackDirection: 'below' },
]}
/>Form integration
Emits hidden inputs for native form submission. Range mode uses name[0] / name[1].
<Slider name="dose" defaultValue={50} />
<Slider name="dose" defaultValue={[20, 80]} />Field link
Syncs invalid, disabled, and required into a createFieldLink() channel so a paired <Label> reflects the field state.
const link = createFieldLink();
<Label link={link}>Dosage</Label>
<Slider link={link} invalid={hasError()} required />Accessible value text
{/* Static string */}
<Slider defaultValue={42} aria-valuetext="42 kilograms" aria-label="Weight" />
{/* Dynamic — different text per thumb in range mode */}
<Slider
defaultValue={[20, 80]}
aria-valuetext={(v) => `${v} mg`}
aria-label="Dose range"
/>Textarea with Highlighting — TextareaHighlighted
A contenteditable div that looks and behaves like Textarea but accepts a renderHtml callback to inject highlighted markup while preserving the cursor position. The stored value is always plain text — the caller is responsible for converting it to HTML.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled plain-text value. |
| onInput | (value: string) => void | — | Fires on every keystroke with the current plain-text content. |
| onChange | (value: string) => void | — | Fires on the native change event with the current plain-text content. |
| onFocus | (e: FocusEvent) => void | — | Focus handler. |
| onBlur | (e: FocusEvent) => void | — | Blur handler. |
| renderHtml | (value: string) => string | — | Converts plain text to an HTML string for display. Called after every input event. If omitted, the text is rendered with HTML entities escaped. |
| placeholder | string | — | Exposed via aria-placeholder. |
| size | 'sm' \| 'md' \| 'lg' | 'md' | Same size scale as Textarea. |
| resize | 'none' \| 'both' \| 'horizontal' \| 'vertical' | 'vertical' | CSS resize behaviour. |
| disabled | boolean | — | Sets contenteditable="false" and applies disabled styles. |
| invalid | boolean | — | Applies danger border and focus ring. |
| required | boolean | — | Exposed via aria-required. |
| id | string | — | Forwarded to the root element. |
| link | FieldLink<any> | — | Syncs invalid, disabled, and required into a createFieldLink() channel so linked labels observe the state. |
| style | JSX.CSSProperties | — | Merged on top of composed styles. |
| class | string | — | Applied to the root element. |
Uncontrolled — syntax highlighting as you type
Provide only renderHtml. The component owns the text state internally and calls renderHtml after every keystroke.
<TextareaHighlighted
renderHtml={(text) =>
text.replace(/&/g, '&').replace(/</g, '<').replace(/@(\w+)/g,
'<mark style="background: lightyellow">@$1</mark>'
)
}
placeholder="Type an @mention…"
/>Controlled — driving value from a signal
Pair value with onInput. The component re-renders the HTML whenever the signal changes, but only when the text content actually differs — so the cursor is never disturbed on a same-value update.
const [text, setText] = createSignal('');
<TextareaHighlighted
value={text()}
onInput={setText}
renderHtml={(value) => highlight(value)}
placeholder="Start typing…"
/>Writing a renderHtml function
renderHtml receives plain text and must return a valid HTML string. You are responsible for escaping any characters that should not be interpreted as HTML before wrapping them in markup.
function highlight(value: string): string {
// 1. Escape first so your markup is not itself escaped.
const escaped = value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// 2. Apply markup to the escaped string.
return escaped
.replace(/\*([^*]+)\*/g, '<strong>*$1*</strong>')
.replace(/(@\w+)/g, '<mark>$1</mark>');
}Important: always escape
&,<, and>before you apply pattern replacements — otherwise any<the user types will be interpreted as an HTML tag once you inject the result asinnerHTML.
Paste handling
Paste is intercepted automatically. Rich content is stripped to plain text before insertion so pasted HTML never pollutes the document or the value.
Using with a field link
TextareaHighlighted supports the same createFieldLink integration as Textarea:
const link = createFieldLink();
<Label link={link}>Notes</Label>
<TextareaHighlighted link={link} invalid={hasError()} required />Contributing
- Keep all code domain-agnostic
- No context, store, or SDK usage in this package
- Follow the architectural principles in
tada.md
