Appearance
Switch — live demo
This is the real @rozie-ui/switch-vue package running on this page (VitePress is itself a Vue app). Click a switch, or focus one and press Space / Enter. Everything below is driven by the same Switch.rozie source that compiles to all six frameworks, built on a native focusable element with no engine and no required CSS — the toggle behaviour, the ARIA wiring, and a tokenised skin all ship inside the component.
modelValue is two-way bound with v-model:modelValue — the readout updates the instant you toggle, and a consumer write flows back in. Listen to @change for the new boolean. The Wi-Fi instance's buttons drive the imperative handle (toggle(), focus()) grabbed through Vue's ref. See the full API for every prop, event, slot, and handle verb, plus theming and the keyboard reference.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Switch.rozie — a headless, WAI-ARIA accessible toggle / switch.
A pure-Rozie family (NO third-party engine) in the spirit of otp / number-field:
it fills a real cross-framework need (every design system re-implements an
accessible on/off toggle with `role="switch"`, keyboard support, and a
controlled boolean value) with zero engine dependency. The platform IS the
engine: a focusable element, native click, and Space/Enter keydown. Rozie owns
the author-side API: the two-way boolean binding, the toggle choreography, the
ARIA wiring, and the token-themed skin.
CONTROLLED, NO LOCAL STATE: the on/off state IS `modelValue` (the sole
model:true prop → Angular ControlValueAccessor; a switch IS a form control).
There is no draft to keep in <data> and no value↔ui echo guard: the thumb
position and `aria-checked` are derived straight from `$props.modelValue`.
FOCUS via ONE ref: $refs.control (the focusable button element) is read only in
$expose verbs (post-mount → ROZ123-safe), and it works inside Lit's shadow root
because the control lives there too.
Authoring notes (collision classes — see the authoring playbook §6):
- The focus verb is `focus` — a DELIBERATE override of the inherited
HTMLElement.focus on the Lit custom element (ROZ137 WARNS, accepted; the
public focus() handle is intended). Same choice otp / number-field made.
- The toggle verb is `toggle` — NOT an inherited HTMLElement / LitElement
member, so it is collision-safe as both an $expose verb AND a scoped-slot
param (the slot NAME is the default/empty slot, never a prop key → ROZ127
clear; a slot PARAM is a local closure binding, not a class field).
- NO helper/const is named `value` / `checked` / `id` / `title` / `hidden` /
`tabIndex` / `inputMode`: any of those as a top-level binding becomes a Lit
class field and collides with an inherited DOM property (hard TS2416). The
derived boolean helper is `isChecked` (never a bare `checked`/`valueOf`).
- NO plain helper is named `writeValue`/`registerOnChange`/`registerOnTouched`/
`setDisabledState`: with a single model:true prop those collide with the
generated Angular ControlValueAccessor (TS2300). The write funnel is
`commitValue` (the otp / number-field precedent).
- Handler params are LEFT UNTYPED so they neutralize to `any`; reading `e.key`
/ calling `e.preventDefault()` then typechecks across all six strict leaves.
- `isChecked` is a PLAIN function called `()` — never `$computed` — because it
is read both in the template AND inside handlers/verbs, and a $computed is a
value on React but an accessor on Solid (aliasing/calling diverges).
- aria booleans bound from a CALL (`isChecked()`) are prefixed with `!!` (the
rozieAttr→Booleanish JSX edge): `:aria-checked="!!$props.modelValue"` reads
the prop directly (provably-boolean), never dropped on `false`.
Consumer example:
<Switch r-model:modelValue="$data.on" ariaLabel="Wi-Fi" @change="onChange" />
-->
<rozie name="Switch">
<props>
{
// The on/off state (two-way). As the sole model:true prop it drives the Angular
// ControlValueAccessor — a switch IS a form control. Read via `$props.modelValue`;
// written via `$model`.
modelValue: {
type: Boolean,
default: false,
model: true,
docs: {
description:
'The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.',
example: '<Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />',
},
},
// Disable the control entirely — not focusable, not toggleable. Boolean props
// default FALSE (the negative-opt-in convention).
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.',
},
},
// Read-only: the state is shown and the control is focusable, but it cannot be
// toggled by the user.
readonly: {
type: Boolean,
default: false,
docs: {
description:
'Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.',
},
},
// Accessible name for the switch (role="switch").
ariaLabel: {
type: String,
default: null,
docs: {
description:
'Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.',
},
},
}
</props>
<script lang="ts">
// ---- derived view (plain function, uniform ×6) -----------------------------
// The current on/off state as a real boolean. Named isChecked, NOT a bare
// `checked` (which would become a Lit class field colliding with inherited DOM)
// nor `valueOf` (which cascades TS1240/1271 across the Lit class). A plain
// function (read in the template AND inside handlers/verbs) — never $computed,
// whose value-vs-accessor form diverges between React and Solid.
const isChecked = () => $props.modelValue === true
// ---- write funnel (single $emit site) --------------------------------------
// Write the model and emit change. Named commitValue (NOT writeValue) so it does
// not collide with the generated Angular ControlValueAccessor.writeValue (TS2300).
const commitValue = (next) => {
const v = next === true
$model.modelValue = v
$emit('change', { checked: v })
}
// Flip the state, unless disabled / readonly. The public toggle verb + the
// click/keyboard handlers all funnel through here.
const toggle = () => {
if ($props.disabled || $props.readonly) return
commitValue(!isChecked())
}
// ---- pointer + keyboard handlers -------------------------------------------
const onClick = () => {
toggle()
}
// Space and Enter toggle the switch (the WAI-ARIA switch keyboard pattern).
// preventDefault on Space so the page does not scroll.
const onKeydown = (e) => {
if ($props.disabled || $props.readonly) return
const key = e ? e.key : ''
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault()
toggle()
}
}
// ---- focusability helper (plain function, uniform ×6) ----------------------
// tabindex is 0 when interactive, dropped (null → attribute omitted) when
// disabled. Returns number | null; rozieAttr drops the attr on null.
const controlTabindex = () => ($props.disabled ? null : 0)
// ---- imperative handle -----------------------------------------------------
// focus() — move DOM focus to the control. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted; the public focus() handle is
// intended). Reads $refs in a post-mount handle call (ROZ123-safe). $refs.control
// types to the generic HTMLElement on the tsdown/vue leaves, so we only touch
// HTMLElement members here (`focus`). toggle() — flip the state (same funnel as
// the UI), a no-op when disabled / readonly.
const focus = () => {
const el = $refs.control
if (el && el.focus) el.focus()
}
$expose({ focus, toggle })
</script>
<template>
<button
ref="control"
type="button"
class="rozie-switch"
role="switch"
:class="{ 'rozie-switch--checked': isChecked(), 'rozie-switch--disabled': $props.disabled }"
:tabindex="controlTabindex()"
:disabled="!!$props.disabled"
:aria-checked="!!$props.modelValue"
:aria-disabled="!!$props.disabled"
:aria-readonly="!!$props.readonly"
:aria-label="$props.ariaLabel"
@click="onClick()"
@keydown="onKeydown($event)"
>
<slot :checked="isChecked()" :toggle="toggle">
<span class="rozie-switch-track">
<span class="rozie-switch-thumb" />
</span>
</slot>
</button>
</template>
<style>
/*
Fully token-driven (mirrors otp / number-field / themes): EVERY visual value is
a `var(--rozie-switch-*, <fallback>)`, so the component renders with zero config
yet is completely re-skinnable by setting tokens at any ancestor scope. The
shipped themes/*.css presets map these tokens onto shadcn/Radix, Material 3,
Bootstrap 5.
*/
.rozie-switch {
display: inline-flex;
align-items: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.rozie-switch:focus-visible {
outline: var(--rozie-switch-focus-ring-width, 2px) solid var(--rozie-switch-focus-ring-color, rgba(0, 102, 204, 0.5));
outline-offset: var(--rozie-switch-focus-ring-offset, 2px);
border-radius: var(--rozie-switch-radius, 999px);
}
.rozie-switch--disabled {
cursor: not-allowed;
opacity: var(--rozie-switch-disabled-opacity, 0.55);
}
.rozie-switch-track {
box-sizing: border-box;
display: inline-flex;
align-items: center;
width: var(--rozie-switch-width, 2.75rem);
height: var(--rozie-switch-height, 1.5rem);
padding: var(--rozie-switch-track-padding, 0.125rem);
background: var(--rozie-switch-off-bg, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-switch-radius, 999px);
transition: background-color 0.18s ease;
}
.rozie-switch--checked .rozie-switch-track {
background: var(--rozie-switch-on-bg, #0066cc);
}
.rozie-switch-thumb {
box-sizing: border-box;
width: var(--rozie-switch-thumb-size, 1.25rem);
height: var(--rozie-switch-thumb-size, 1.25rem);
background: var(--rozie-switch-thumb-bg, #fff);
border-radius: 50%;
box-shadow: var(--rozie-switch-thumb-shadow, 0 1px 2px rgba(0, 0, 0, 0.3));
transition: transform 0.18s ease;
transform: translateX(0);
}
.rozie-switch--checked .rozie-switch-thumb {
transform: translateX(var(--rozie-switch-thumb-travel, calc(2.75rem - 1.5rem)));
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/switch-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './Switch.css';
interface ChildrenCtx { checked: any; toggle: any; }
interface SwitchProps {
/**
* The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.
* @example
* <Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />
*/
modelValue?: boolean;
defaultModelValue?: boolean;
onModelValueChange?: (modelValue: boolean) => void;
/**
* Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.
*/
readonly?: boolean;
/**
* Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.
*/
ariaLabel?: (string) | null;
onChange?: (...args: any[]) => void;
children?: ReactNode | ((ctx: ChildrenCtx) => ReactNode);
slots?: Record<string, () => import('react').ReactNode>;
}
export interface SwitchHandle {
focus: (...args: any[]) => any;
toggle: (...args: any[]) => any;
}
const Switch = forwardRef<SwitchHandle, SwitchProps>(function Switch(_props: SwitchProps, ref): JSX.Element {
const props: Omit<SwitchProps, 'disabled' | 'readonly' | 'ariaLabel'> & { disabled: boolean; readonly: boolean; ariaLabel: (string) | null } = {
..._props,
disabled: _props.disabled ?? false,
readonly: _props.readonly ?? false,
ariaLabel: _props.ariaLabel ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { modelValue, disabled, readonly, ariaLabel, defaultValue, onModelValueChange, defaultModelValue, ...rest } = _props as SwitchProps & Record<string, unknown>;
void modelValue; void disabled; void readonly; void ariaLabel; void defaultValue; void onModelValueChange; void defaultModelValue;
return rest;
})();
const [modelValue, setModelValue] = useControllableState({
value: props.modelValue,
defaultValue: props.defaultModelValue ?? false,
onValueChange: props.onModelValueChange,
});
const control = useRef<HTMLButtonElement | null>(null);
function isChecked() {
return modelValue === true;
}
function commitValue(next: any) {
const v = next === true;
setModelValue(v);
props.onChange && props.onChange({
checked: v
});
}
function toggle() {
if (props.disabled || props.readonly) return;
commitValue(!isChecked());
}
const onClick = useCallback(() => {
toggle();
}, [toggle]);
const onKeydown = useCallback((e: any) => {
if (props.disabled || props.readonly) return;
const key = e ? e.key : '';
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault();
toggle();
}
}, [props.disabled, props.readonly, toggle]);
function controlTabindex() {
return props.disabled ? null : 0;
}
function focus() {
const el = control.current;
if (el && el.focus) el.focus();
}
const _rozieExposeRef = useRef({ focus, toggle });
_rozieExposeRef.current = { focus, toggle };
useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), toggle: (...args: Parameters<typeof toggle>): ReturnType<typeof toggle> => _rozieExposeRef.current.toggle(...args) }), []);
return (
<>
<button ref={control} type="button" role="switch" tabIndex={controlTabindex()} disabled={!!props.disabled} aria-checked={!!modelValue} aria-disabled={!!props.disabled} aria-readonly={!!props.readonly} aria-label={rozieAttr(props.ariaLabel)} {...attrs} className={clsx(clsx("rozie-switch", { "rozie-switch--checked": isChecked(), "rozie-switch--disabled": props.disabled }), (attrs.className as string | undefined))} onClick={($event) => { onClick(); }} onKeyDown={($event) => { onKeydown($event); }} data-rozie-s-5a76e232="">
{typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ checked: isChecked(), toggle }) : ((props.children ?? props.slots?.['']) ?? <span className={"rozie-switch-track"} data-rozie-s-5a76e232="">
<span className={"rozie-switch-thumb"} data-rozie-s-5a76e232="" />
</span>)}
</button>
</>
);
});
export default Switch;vue
<template>
<button ref="controlRef" type="button" :class="['rozie-switch', { 'rozie-switch--checked': isChecked(), 'rozie-switch--disabled': props.disabled }]" role="switch" :tabindex="(controlTabindex()) ?? undefined" :disabled="!!props.disabled" :aria-checked="!!modelValue" :aria-disabled="!!props.disabled" :aria-readonly="!!props.readonly" :aria-label="props.ariaLabel" v-bind="$attrs" @click="onClick()" @keydown="onKeydown($event)">
<slot :checked="isChecked()" :toggle="toggle">
<span class="rozie-switch-track">
<span class="rozie-switch-thumb"></span>
</span>
</slot>
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = withDefaults(
defineProps<{
/**
* Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.
*/
readonly?: boolean;
/**
* Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.
*/
ariaLabel?: string | null;
}>(),
{ disabled: false, readonly: false, ariaLabel: null }
);
/**
* The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.
* @example
* <Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />
*/
const modelValue = defineModel<boolean>('modelValue', { default: false });
const emit = defineEmits<{
change: [...args: any[]];
}>();
defineSlots<{
default(props: { checked: any; toggle: any }): any;
}>();
const controlRef = ref<HTMLButtonElement>();
// ---- derived view (plain function, uniform ×6) -----------------------------
// The current on/off state as a real boolean. Named isChecked, NOT a bare
// `checked` (which would become a Lit class field colliding with inherited DOM)
// nor `valueOf` (which cascades TS1240/1271 across the Lit class). A plain
// function (read in the template AND inside handlers/verbs) — never $computed,
// whose value-vs-accessor form diverges between React and Solid.
const isChecked = () => modelValue.value === true;
// ---- write funnel (single $emit site) --------------------------------------
// Write the model and emit change. Named commitValue (NOT writeValue) so it does
// not collide with the generated Angular ControlValueAccessor.writeValue (TS2300).
// ---- write funnel (single $emit site) --------------------------------------
// Write the model and emit change. Named commitValue (NOT writeValue) so it does
// not collide with the generated Angular ControlValueAccessor.writeValue (TS2300).
const commitValue = (next: any) => {
const v = next === true;
modelValue.value = v;
emit('change', {
checked: v
});
};
// Flip the state, unless disabled / readonly. The public toggle verb + the
// click/keyboard handlers all funnel through here.
// Flip the state, unless disabled / readonly. The public toggle verb + the
// click/keyboard handlers all funnel through here.
const toggle = () => {
if (props.disabled || props.readonly) return;
commitValue(!isChecked());
};
// ---- pointer + keyboard handlers -------------------------------------------
// ---- pointer + keyboard handlers -------------------------------------------
const onClick = () => {
toggle();
};
// Space and Enter toggle the switch (the WAI-ARIA switch keyboard pattern).
// preventDefault on Space so the page does not scroll.
// Space and Enter toggle the switch (the WAI-ARIA switch keyboard pattern).
// preventDefault on Space so the page does not scroll.
const onKeydown = (e: any) => {
if (props.disabled || props.readonly) return;
const key = e ? e.key : '';
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault();
toggle();
}
};
// ---- focusability helper (plain function, uniform ×6) ----------------------
// tabindex is 0 when interactive, dropped (null → attribute omitted) when
// disabled. Returns number | null; rozieAttr drops the attr on null.
// ---- focusability helper (plain function, uniform ×6) ----------------------
// tabindex is 0 when interactive, dropped (null → attribute omitted) when
// disabled. Returns number | null; rozieAttr drops the attr on null.
const controlTabindex = () => props.disabled ? null : 0;
// ---- imperative handle -----------------------------------------------------
// focus() — move DOM focus to the control. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted; the public focus() handle is
// intended). Reads $refs in a post-mount handle call (ROZ123-safe). $refs.control
// types to the generic HTMLElement on the tsdown/vue leaves, so we only touch
// HTMLElement members here (`focus`). toggle() — flip the state (same funnel as
// the UI), a no-op when disabled / readonly.
// ---- imperative handle -----------------------------------------------------
// focus() — move DOM focus to the control. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted; the public focus() handle is
// intended). Reads $refs in a post-mount handle call (ROZ123-safe). $refs.control
// types to the generic HTMLElement on the tsdown/vue leaves, so we only touch
// HTMLElement members here (`focus`). toggle() — flip the state (same funnel as
// the UI), a no-op when disabled / readonly.
const focus = () => {
const el = controlRef.value;
if (el && el.focus) el.focus();
};
defineExpose({ focus, toggle });
</script>
<style scoped>
.rozie-switch {
display: inline-flex;
align-items: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.rozie-switch:focus-visible {
outline: var(--rozie-switch-focus-ring-width, 2px) solid var(--rozie-switch-focus-ring-color, rgba(0, 102, 204, 0.5));
outline-offset: var(--rozie-switch-focus-ring-offset, 2px);
border-radius: var(--rozie-switch-radius, 999px);
}
.rozie-switch--disabled {
cursor: not-allowed;
opacity: var(--rozie-switch-disabled-opacity, 0.55);
}
.rozie-switch-track {
box-sizing: border-box;
display: inline-flex;
align-items: center;
width: var(--rozie-switch-width, 2.75rem);
height: var(--rozie-switch-height, 1.5rem);
padding: var(--rozie-switch-track-padding, 0.125rem);
background: var(--rozie-switch-off-bg, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-switch-radius, 999px);
transition: background-color 0.18s ease;
}
.rozie-switch--checked .rozie-switch-track {
background: var(--rozie-switch-on-bg, #0066cc);
}
.rozie-switch-thumb {
box-sizing: border-box;
width: var(--rozie-switch-thumb-size, 1.25rem);
height: var(--rozie-switch-thumb-size, 1.25rem);
background: var(--rozie-switch-thumb-bg, #fff);
border-radius: 50%;
box-shadow: var(--rozie-switch-thumb-shadow, 0 1px 2px rgba(0, 0, 0, 0.3));
transition: transform 0.18s ease;
transform: translateX(0);
}
.rozie-switch--checked .rozie-switch-thumb {
transform: translateX(var(--rozie-switch-thumb-travel, calc(2.75rem - 1.5rem)));
}
</style>svelte
<script lang="ts">
import { applyListeners, rozieAttr } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.
* @example
* <Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />
*/
modelValue?: boolean;
/**
* Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.
*/
readonly?: boolean;
/**
* Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.
*/
ariaLabel?: (string) | null;
children?: Snippet<[{ checked: any; toggle: any }]>;
snippets?: Record<string, any>;
onchange?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let {
modelValue = $bindable(false),
disabled = false,
readonly = false,
ariaLabel = null,
children: __childrenProp,
snippets,
onchange,
...__rozieAttrs
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
let control = $state<HTMLButtonElement | undefined>(undefined);
// ---- derived view (plain function, uniform ×6) -----------------------------
// The current on/off state as a real boolean. Named isChecked, NOT a bare
// `checked` (which would become a Lit class field colliding with inherited DOM)
// nor `valueOf` (which cascades TS1240/1271 across the Lit class). A plain
// function (read in the template AND inside handlers/verbs) — never $computed,
// whose value-vs-accessor form diverges between React and Solid.
const isChecked = () => modelValue === true;
// ---- write funnel (single $emit site) --------------------------------------
// Write the model and emit change. Named commitValue (NOT writeValue) so it does
// not collide with the generated Angular ControlValueAccessor.writeValue (TS2300).
// ---- write funnel (single $emit site) --------------------------------------
// Write the model and emit change. Named commitValue (NOT writeValue) so it does
// not collide with the generated Angular ControlValueAccessor.writeValue (TS2300).
const commitValue = (next: any) => {
const v = next === true;
modelValue = v;
onchange?.({
checked: v
});
};
// Flip the state, unless disabled / readonly. The public toggle verb + the
// click/keyboard handlers all funnel through here.
// Flip the state, unless disabled / readonly. The public toggle verb + the
// click/keyboard handlers all funnel through here.
export const toggle = () => {
if (disabled || readonly) return;
commitValue(!isChecked());
};
// ---- pointer + keyboard handlers -------------------------------------------
// ---- pointer + keyboard handlers -------------------------------------------
const onClick = () => {
toggle();
};
// Space and Enter toggle the switch (the WAI-ARIA switch keyboard pattern).
// preventDefault on Space so the page does not scroll.
// Space and Enter toggle the switch (the WAI-ARIA switch keyboard pattern).
// preventDefault on Space so the page does not scroll.
const onKeydown = (e: any) => {
if (disabled || readonly) return;
const key = e ? e.key : '';
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault();
toggle();
}
};
// ---- focusability helper (plain function, uniform ×6) ----------------------
// tabindex is 0 when interactive, dropped (null → attribute omitted) when
// disabled. Returns number | null; rozieAttr drops the attr on null.
// ---- focusability helper (plain function, uniform ×6) ----------------------
// tabindex is 0 when interactive, dropped (null → attribute omitted) when
// disabled. Returns number | null; rozieAttr drops the attr on null.
const controlTabindex = () => disabled ? null : 0;
// ---- imperative handle -----------------------------------------------------
// focus() — move DOM focus to the control. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted; the public focus() handle is
// intended). Reads $refs in a post-mount handle call (ROZ123-safe). $refs.control
// types to the generic HTMLElement on the tsdown/vue leaves, so we only touch
// HTMLElement members here (`focus`). toggle() — flip the state (same funnel as
// the UI), a no-op when disabled / readonly.
// ---- imperative handle -----------------------------------------------------
// focus() — move DOM focus to the control. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted; the public focus() handle is
// intended). Reads $refs in a post-mount handle call (ROZ123-safe). $refs.control
// types to the generic HTMLElement on the tsdown/vue leaves, so we only touch
// HTMLElement members here (`focus`). toggle() — flip the state (same funnel as
// the UI), a no-op when disabled / readonly.
export const focus = () => {
const el = control;
if (el && el.focus) el.focus();
};
</script>
<button bind:this={control} type="button" role="switch" tabindex={rozieAttr(controlTabindex())} disabled={!!disabled} aria-checked={!!modelValue} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-label={ariaLabel} {...__rozieAttrs} class={["rozie-switch", { 'rozie-switch--checked': isChecked(), 'rozie-switch--disabled': disabled }, (__rozieAttrs)?.class]} onclick={($event) => { onClick(); }} onkeydown={($event) => { onKeydown($event); }} use:applyListeners={__rozieAttrs} data-rozie-s-5a76e232>{#if children}{@render children({ checked: isChecked(), toggle })}{:else}<span class="rozie-switch-track" data-rozie-s-5a76e232><span class="rozie-switch-thumb" data-rozie-s-5a76e232></span></span>{/if}</button>
<style>
:global {
.rozie-switch[data-rozie-s-5a76e232] {
display: inline-flex;
align-items: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.rozie-switch[data-rozie-s-5a76e232]:focus-visible {
outline: var(--rozie-switch-focus-ring-width, 2px) solid var(--rozie-switch-focus-ring-color, rgba(0, 102, 204, 0.5));
outline-offset: var(--rozie-switch-focus-ring-offset, 2px);
border-radius: var(--rozie-switch-radius, 999px);
}
.rozie-switch--disabled[data-rozie-s-5a76e232] {
cursor: not-allowed;
opacity: var(--rozie-switch-disabled-opacity, 0.55);
}
.rozie-switch-track[data-rozie-s-5a76e232] {
box-sizing: border-box;
display: inline-flex;
align-items: center;
width: var(--rozie-switch-width, 2.75rem);
height: var(--rozie-switch-height, 1.5rem);
padding: var(--rozie-switch-track-padding, 0.125rem);
background: var(--rozie-switch-off-bg, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-switch-radius, 999px);
transition: background-color 0.18s ease;
}
.rozie-switch--checked[data-rozie-s-5a76e232] .rozie-switch-track[data-rozie-s-5a76e232] {
background: var(--rozie-switch-on-bg, #0066cc);
}
.rozie-switch-thumb[data-rozie-s-5a76e232] {
box-sizing: border-box;
width: var(--rozie-switch-thumb-size, 1.25rem);
height: var(--rozie-switch-thumb-size, 1.25rem);
background: var(--rozie-switch-thumb-bg, #fff);
border-radius: 50%;
box-shadow: var(--rozie-switch-thumb-shadow, 0 1px 2px rgba(0, 0, 0, 0.3));
transition: transform 0.18s ease;
transform: translateX(0);
}
.rozie-switch--checked[data-rozie-s-5a76e232] .rozie-switch-thumb[data-rozie-s-5a76e232] {
transform: translateX(var(--rozie-switch-thumb-travel, calc(2.75rem - 1.5rem)));
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
interface DefaultCtx {
$implicit: { checked: any; toggle: any };
checked: any;
toggle: any;
}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-switch',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<button #control type="button" class="rozie-switch" [ngClass]="{ 'rozie-switch--checked': isChecked(), 'rozie-switch--disabled': (disabled() || this.__rozieCvaDisabled()) }" role="switch" [attr.tabindex]="rozieAttr(controlTabindex())" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-checked]="!!modelValue()" [attr.aria-disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-readonly]="!!readonly()" [attr.aria-label]="ariaLabel()" #rozieSpread_0 (click)="onClick()" (keydown)="onKeydown($event)" #rozieListenersTarget_1>
@if ((defaultTpl ?? templates()?.['defaultSlot'])) {
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: { $implicit: { checked: isChecked(), toggle: toggle }, checked: isChecked(), toggle: toggle }" />
} @else {
<span class="rozie-switch-track">
<span class="rozie-switch-thumb"></span>
</span>
}
</button>
`,
styles: [`
.rozie-switch {
display: inline-flex;
align-items: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.rozie-switch:focus-visible {
outline: var(--rozie-switch-focus-ring-width, 2px) solid var(--rozie-switch-focus-ring-color, rgba(0, 102, 204, 0.5));
outline-offset: var(--rozie-switch-focus-ring-offset, 2px);
border-radius: var(--rozie-switch-radius, 999px);
}
.rozie-switch--disabled {
cursor: not-allowed;
opacity: var(--rozie-switch-disabled-opacity, 0.55);
}
.rozie-switch-track {
box-sizing: border-box;
display: inline-flex;
align-items: center;
width: var(--rozie-switch-width, 2.75rem);
height: var(--rozie-switch-height, 1.5rem);
padding: var(--rozie-switch-track-padding, 0.125rem);
background: var(--rozie-switch-off-bg, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-switch-radius, 999px);
transition: background-color 0.18s ease;
}
.rozie-switch--checked .rozie-switch-track {
background: var(--rozie-switch-on-bg, #0066cc);
}
.rozie-switch-thumb {
box-sizing: border-box;
width: var(--rozie-switch-thumb-size, 1.25rem);
height: var(--rozie-switch-thumb-size, 1.25rem);
background: var(--rozie-switch-thumb-bg, #fff);
border-radius: 50%;
box-shadow: var(--rozie-switch-thumb-shadow, 0 1px 2px rgba(0, 0, 0, 0.3));
transition: transform 0.18s ease;
transform: translateX(0);
}
.rozie-switch--checked .rozie-switch-thumb {
transform: translateX(var(--rozie-switch-thumb-travel, calc(2.75rem - 1.5rem)));
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Switch),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Switch {
/**
* The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.
* @example
* <Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />
*/
modelValue = model<boolean>(false);
/**
* Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled = input<boolean>(false);
/**
* Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.
*/
readonly = input<boolean>(false);
/**
* Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.
*/
ariaLabel = input<(string) | null>(null);
control = viewChild<ElementRef<HTMLButtonElement>>('control');
change = output<unknown>();
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
isChecked = () => this.modelValue() === true;
commitValue = (next: any) => {
const v = next === true;
this.modelValue.set(v), this.__rozieCvaOnChange(v);
this.change.emit({
checked: v
});
};
toggle = () => {
if ((this.disabled() || this.__rozieCvaDisabled()) || this.readonly()) return;
this.commitValue(!this.isChecked());
};
onClick = () => {
this.toggle();
};
onKeydown = (e: any) => {
if ((this.disabled() || this.__rozieCvaDisabled()) || this.readonly()) return;
const key = e ? e.key : '';
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault();
this.toggle();
}
};
controlTabindex = () => (this.disabled() || this.__rozieCvaDisabled()) ? null : 0;
focus = () => {
const el = this.control()?.nativeElement;
if (el && el.focus) el.focus();
};
private __rozieCvaOnChange: (v: boolean) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: boolean | null): void {
this.modelValue.set(v ?? false);
}
registerOnChange(fn: (v: boolean) => 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: Switch,
_ctx: unknown,
): _ctx is DefaultCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
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 Switch;tsx
import type { JSX } from 'solid-js';
import { children, mergeProps, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, mergeListeners, rozieAttr, rozieClass } from '@rozie/runtime-solid';
__rozieInjectStyle('Switch-5a76e232', `.rozie-switch[data-rozie-s-5a76e232] {
display: inline-flex;
align-items: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.rozie-switch[data-rozie-s-5a76e232]:focus-visible {
outline: var(--rozie-switch-focus-ring-width, 2px) solid var(--rozie-switch-focus-ring-color, rgba(0, 102, 204, 0.5));
outline-offset: var(--rozie-switch-focus-ring-offset, 2px);
border-radius: var(--rozie-switch-radius, 999px);
}
.rozie-switch--disabled[data-rozie-s-5a76e232] {
cursor: not-allowed;
opacity: var(--rozie-switch-disabled-opacity, 0.55);
}
.rozie-switch-track[data-rozie-s-5a76e232] {
box-sizing: border-box;
display: inline-flex;
align-items: center;
width: var(--rozie-switch-width, 2.75rem);
height: var(--rozie-switch-height, 1.5rem);
padding: var(--rozie-switch-track-padding, 0.125rem);
background: var(--rozie-switch-off-bg, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-switch-radius, 999px);
transition: background-color 0.18s ease;
}
.rozie-switch--checked[data-rozie-s-5a76e232] .rozie-switch-track[data-rozie-s-5a76e232] {
background: var(--rozie-switch-on-bg, #0066cc);
}
.rozie-switch-thumb[data-rozie-s-5a76e232] {
box-sizing: border-box;
width: var(--rozie-switch-thumb-size, 1.25rem);
height: var(--rozie-switch-thumb-size, 1.25rem);
background: var(--rozie-switch-thumb-bg, #fff);
border-radius: 50%;
box-shadow: var(--rozie-switch-thumb-shadow, 0 1px 2px rgba(0, 0, 0, 0.3));
transition: transform 0.18s ease;
transform: translateX(0);
}
.rozie-switch--checked[data-rozie-s-5a76e232] .rozie-switch-thumb[data-rozie-s-5a76e232] {
transform: translateX(var(--rozie-switch-thumb-travel, calc(2.75rem - 1.5rem)));
}`);
interface SwitchProps {
/**
* The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.
* @example
* <Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />
*/
modelValue?: boolean;
defaultModelValue?: boolean;
onModelValueChange?: (modelValue: boolean) => void;
/**
* Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.
*/
readonly?: boolean;
/**
* Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.
*/
ariaLabel?: (string) | null;
onChange?: (...args: unknown[]) => void;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: SwitchHandle) => void;
}
export interface SwitchHandle {
focus: (...args: any[]) => any;
toggle: (...args: any[]) => any;
}
export default function Switch(_props: SwitchProps): JSX.Element {
const _merged = mergeProps({ disabled: false, readonly: false, ariaLabel: null }, _props);
const [local, attrs] = splitProps(_merged, ['modelValue', 'disabled', 'readonly', 'ariaLabel', 'children', 'ref']);
const resolved = children(() => local.children);
onMount(() => { local.ref?.({ focus, toggle }); });
const [modelValue, setModelValue] = createControllableSignal<boolean>(_props as unknown as Record<string, unknown>, 'modelValue', false);
let controlRef: HTMLElement | null = null;
// ---- derived view (plain function, uniform ×6) -----------------------------
// The current on/off state as a real boolean. Named isChecked, NOT a bare
// `checked` (which would become a Lit class field colliding with inherited DOM)
// nor `valueOf` (which cascades TS1240/1271 across the Lit class). A plain
// function (read in the template AND inside handlers/verbs) — never $computed,
// whose value-vs-accessor form diverges between React and Solid.
function isChecked() {
return modelValue() === true;
}
// ---- write funnel (single $emit site) --------------------------------------
// Write the model and emit change. Named commitValue (NOT writeValue) so it does
// not collide with the generated Angular ControlValueAccessor.writeValue (TS2300).
function commitValue(next: any) {
const v = next === true;
setModelValue(v);
_props.onChange?.({
checked: v
});
}
// Flip the state, unless disabled / readonly. The public toggle verb + the
// click/keyboard handlers all funnel through here.
function toggle() {
if (local.disabled || local.readonly) return;
commitValue(!isChecked());
}
// ---- pointer + keyboard handlers -------------------------------------------
function onClick() {
toggle();
}
// Space and Enter toggle the switch (the WAI-ARIA switch keyboard pattern).
// preventDefault on Space so the page does not scroll.
function onKeydown(e: any) {
if (local.disabled || local.readonly) return;
const key = e ? e.key : '';
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault();
toggle();
}
}
// ---- focusability helper (plain function, uniform ×6) ----------------------
// tabindex is 0 when interactive, dropped (null → attribute omitted) when
// disabled. Returns number | null; rozieAttr drops the attr on null.
function controlTabindex() {
return local.disabled ? null : 0;
}
// ---- imperative handle -----------------------------------------------------
// focus() — move DOM focus to the control. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted; the public focus() handle is
// intended). Reads $refs in a post-mount handle call (ROZ123-safe). $refs.control
// types to the generic HTMLElement on the tsdown/vue leaves, so we only touch
// HTMLElement members here (`focus`). toggle() — flip the state (same funnel as
// the UI), a no-op when disabled / readonly.
function focus() {
const el = controlRef;
if (el && el.focus) el.focus();
}
return (
<>
<button ref={(el) => { controlRef = el as HTMLElement; }} type="button" role="switch" tabIndex={rozieAttr(controlTabindex())} disabled={!!local.disabled} aria-checked={!!modelValue()} aria-disabled={!!local.disabled} aria-readonly={!!local.readonly} aria-label={rozieAttr(local.ariaLabel)} {...attrs} class={"rozie-switch" + " " + rozieClass({ 'rozie-switch--checked': isChecked(), 'rozie-switch--disabled': local.disabled }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onClick: ($event) => { onClick(); }, onKeyDown: ($event) => { onKeydown($event); } }, attrs)} data-rozie-s-5a76e232="">
{typeof local.children === 'function' ? (local.children as (s: any) => any)({ checked: isChecked(), toggle }) : (resolved() ?? <span class={"rozie-switch-track"} data-rozie-s-5a76e232="">
<span class={"rozie-switch-thumb"} data-rozie-s-5a76e232="" />
</span>)}
</button>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
interface RozieDefaultSlotCtx {
checked: unknown;
toggle: unknown;
}
@customElement('rozie-switch')
export default class Switch extends SignalWatcher(LitElement) {
static styles = css`
.rozie-switch[data-rozie-s-5a76e232] {
display: inline-flex;
align-items: center;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.rozie-switch[data-rozie-s-5a76e232]:focus-visible {
outline: var(--rozie-switch-focus-ring-width, 2px) solid var(--rozie-switch-focus-ring-color, rgba(0, 102, 204, 0.5));
outline-offset: var(--rozie-switch-focus-ring-offset, 2px);
border-radius: var(--rozie-switch-radius, 999px);
}
.rozie-switch--disabled[data-rozie-s-5a76e232] {
cursor: not-allowed;
opacity: var(--rozie-switch-disabled-opacity, 0.55);
}
.rozie-switch-track[data-rozie-s-5a76e232] {
box-sizing: border-box;
display: inline-flex;
align-items: center;
width: var(--rozie-switch-width, 2.75rem);
height: var(--rozie-switch-height, 1.5rem);
padding: var(--rozie-switch-track-padding, 0.125rem);
background: var(--rozie-switch-off-bg, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-switch-radius, 999px);
transition: background-color 0.18s ease;
}
.rozie-switch--checked[data-rozie-s-5a76e232] .rozie-switch-track[data-rozie-s-5a76e232] {
background: var(--rozie-switch-on-bg, #0066cc);
}
.rozie-switch-thumb[data-rozie-s-5a76e232] {
box-sizing: border-box;
width: var(--rozie-switch-thumb-size, 1.25rem);
height: var(--rozie-switch-thumb-size, 1.25rem);
background: var(--rozie-switch-thumb-bg, #fff);
border-radius: 50%;
box-shadow: var(--rozie-switch-thumb-shadow, 0 1px 2px rgba(0, 0, 0, 0.3));
transition: transform 0.18s ease;
transform: translateX(0);
}
.rozie-switch--checked[data-rozie-s-5a76e232] .rozie-switch-thumb[data-rozie-s-5a76e232] {
transform: translateX(var(--rozie-switch-thumb-travel, calc(2.75rem - 1.5rem)));
}
`;
/**
* The on/off state of the switch (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a switch **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `true` is the checked/on state; reflected as `aria-checked`.
* @example
* <Switch r-model:modelValue="on" ariaLabel="Wi-Fi" />
*/
@property({ type: Boolean, attribute: 'model-value' }) _modelValue_attr: boolean = false;
private _modelValueControllable = createLitControllableProperty<boolean>({ host: this, eventName: 'model-value-change', defaultValue: false, initialControlledValue: undefined });
/**
* Disable the control entirely — it becomes non-focusable (`tabindex` is dropped), non-toggleable (click and keyboard are ignored), and `aria-disabled` is set. Also sets the Angular `ControlValueAccessor` disabled state.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* Make the switch read-only — its state is shown and the control stays focusable, but the user cannot toggle it (click and keyboard are ignored). Reflected as `aria-readonly`.
*/
@property({ type: Boolean, reflect: true }) readonly: boolean = false;
/**
* Accessible name applied to the `role="switch"` control (`aria-label`). Provide this (or an external `<label>`) so the switch is announced.
*/
@property({ type: String, reflect: true }) ariaLabel: string | null = null;
@query('[data-rozie-ref="control"]') private _refControl!: HTMLElement;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@property({ attribute: false }) __rozieDefaultSlot__?: (scope: { checked: unknown; toggle: unknown }) => unknown;
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:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.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._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
}
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 !== null);
}
render() {
return html`
<button class="${Object.entries({ "rozie-switch": true, 'rozie-switch--checked': this.isChecked(), 'rozie-switch--disabled': this.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" type="button" role="switch" tabindex=${rozieAttr(this.controlTabindex())} ?disabled=${!!this.disabled} aria-checked=${!!this.modelValue} aria-disabled=${!!this.disabled} aria-readonly=${!!this.readonly} aria-label=${this.ariaLabel} ${rozieSpread(this.$attrs)} @click=${($event: Event) => { this.onClick(); }} @keydown=${($event: Event) => { this.onKeydown($event); }} ${rozieListeners(this.$listeners)} data-rozie-ref="control" data-rozie-s-5a76e232>
${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({checked: this.isChecked(), toggle: this.toggle}) : html`<slot data-rozie-params=${(() => { try { return JSON.stringify({checked: this.isChecked()}); } catch { return '{}'; } })()} @rozie-default-toggle=${($event: CustomEvent) => ((this.toggle) as (...args: any[]) => any)($event.detail)}>
<span class="rozie-switch-track" data-rozie-s-5a76e232>
<span class="rozie-switch-thumb" data-rozie-s-5a76e232></span>
</span>
</slot>`}
</button>
`;
}
isChecked = () => this.modelValue === true;
commitValue = (next: any) => {
const v = next === true;
this._modelValueControllable.write(v);
this.dispatchEvent(new CustomEvent("change", {
detail: {
checked: v
},
bubbles: true,
composed: true
}));
};
toggle = () => {
if (this.disabled || this.readonly) return;
this.commitValue(!this.isChecked());
};
onClick = () => {
this.toggle();
};
onKeydown = (e: any) => {
if (this.disabled || this.readonly) return;
const key = e ? e.key : '';
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
if (e) e.preventDefault();
this.toggle();
}
};
controlTabindex = () => this.disabled ? null : 0;
focus = () => {
const el = this._refControl;
if (el && el.focus) el.focus();
};
get modelValue(): boolean { return this._modelValueControllable.read(); }
set modelValue(v: boolean) { this._modelValueControllable.notifyPropertyWrite(v); }
/**
* 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).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['model-value', 'modelvalue', 'disabled', 'readonly', 'aria-label', 'arialabel']);
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;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same prop, same change event, same two-way modelValue, same scoped slot, same imperative handle — all from the one source above, built on a native focusable element with no third-party engine behind it.
See also
- Switch — showcase & API — install, quick start, theming, accessibility, and the full reference.
- Headless switch comparison — how
@rozie-ui/switchstacks up against the native checkbox-switch and the per-framework switch libraries.