@zag-js/radio-group
Version:
Core logic for the radio group widget implemented as a state machine
434 lines (429 loc) • 14.2 kB
JavaScript
;
var anatomy$1 = require('@zag-js/anatomy');
var domQuery = require('@zag-js/dom-query');
var focusVisible = require('@zag-js/focus-visible');
var core = require('@zag-js/core');
var utils = require('@zag-js/utils');
var types = require('@zag-js/types');
// src/radio-group.anatomy.ts
var anatomy = anatomy$1.createAnatomy("radio-group").parts(
"root",
"label",
"item",
"itemText",
"itemControl",
"indicator"
);
var parts = anatomy.build();
var getRootId = (ctx) => ctx.ids?.root ?? `radio-group:${ctx.id}`;
var getLabelId = (ctx) => ctx.ids?.label ?? `radio-group:${ctx.id}:label`;
var getItemId = (ctx, value) => ctx.ids?.item?.(value) ?? `radio-group:${ctx.id}:radio:${value}`;
var getItemHiddenInputId = (ctx, value) => ctx.ids?.itemHiddenInput?.(value) ?? `radio-group:${ctx.id}:radio:input:${value}`;
var getItemControlId = (ctx, value) => ctx.ids?.itemControl?.(value) ?? `radio-group:${ctx.id}:radio:control:${value}`;
var getItemLabelId = (ctx, value) => ctx.ids?.itemLabel?.(value) ?? `radio-group:${ctx.id}:radio:label:${value}`;
var getIndicatorId = (ctx) => ctx.ids?.indicator ?? `radio-group:${ctx.id}:indicator`;
var getRootEl = (ctx) => ctx.getById(getRootId(ctx));
var getItemHiddenInputEl = (ctx, value) => ctx.getById(getItemHiddenInputId(ctx, value));
var getIndicatorEl = (ctx) => ctx.getById(getIndicatorId(ctx));
var getFirstEnabledInputEl = (ctx) => getRootEl(ctx)?.querySelector("input:not(:disabled)");
var getFirstEnabledAndCheckedInputEl = (ctx) => getRootEl(ctx)?.querySelector("input:not(:disabled):checked");
var getInputEls = (ctx) => {
const ownerId = CSS.escape(getRootId(ctx));
const selector = `input[type=radio][data-ownedby='${ownerId}']:not([disabled])`;
return domQuery.queryAll(getRootEl(ctx), selector);
};
var getRadioEl = (ctx, value) => {
if (!value) return;
return ctx.getById(getItemId(ctx, value));
};
var getOffsetRect = (el) => ({
left: el?.offsetLeft ?? 0,
top: el?.offsetTop ?? 0,
width: el?.offsetWidth ?? 0,
height: el?.offsetHeight ?? 0
});
var resolveRect = (rect) => ({
width: `${rect.width}px`,
height: `${rect.height}px`,
left: `${rect.left}px`,
top: `${rect.top}px`
});
// src/radio-group.connect.ts
function connect(service, normalize) {
const { context, send, computed, prop, scope, refs } = service;
const groupDisabled = computed("isDisabled");
const readOnly = prop("readOnly");
function getItemState(props2) {
return {
value: props2.value,
invalid: !!props2.invalid,
disabled: !!props2.disabled || groupDisabled,
checked: context.get("value") === props2.value,
focused: context.get("focusedValue") === props2.value,
focusVisible: refs.get("focusVisibleValue") === props2.value,
hovered: context.get("hoveredValue") === props2.value,
active: context.get("activeValue") === props2.value
};
}
function getItemDataAttrs(props2) {
const itemState = getItemState(props2);
return {
"data-focus": domQuery.dataAttr(itemState.focused),
"data-focus-visible": domQuery.dataAttr(itemState.focusVisible),
"data-disabled": domQuery.dataAttr(itemState.disabled),
"data-readonly": domQuery.dataAttr(readOnly),
"data-state": itemState.checked ? "checked" : "unchecked",
"data-hover": domQuery.dataAttr(itemState.hovered),
"data-invalid": domQuery.dataAttr(itemState.invalid),
"data-orientation": prop("orientation"),
"data-ssr": domQuery.dataAttr(context.get("ssr"))
};
}
const focus = () => {
const nodeToFocus = getFirstEnabledAndCheckedInputEl(scope) ?? getFirstEnabledInputEl(scope);
nodeToFocus?.focus();
};
return {
focus,
value: context.get("value"),
setValue(value) {
send({ type: "SET_VALUE", value, isTrusted: false });
},
clearValue() {
send({ type: "SET_VALUE", value: null, isTrusted: false });
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
role: "radiogroup",
id: getRootId(scope),
"aria-labelledby": getLabelId(scope),
"data-orientation": prop("orientation"),
"data-disabled": domQuery.dataAttr(groupDisabled),
"aria-orientation": prop("orientation"),
dir: prop("dir"),
style: {
position: "relative"
}
});
},
getLabelProps() {
return normalize.element({
...parts.label.attrs,
dir: prop("dir"),
"data-orientation": prop("orientation"),
"data-disabled": domQuery.dataAttr(groupDisabled),
id: getLabelId(scope),
onClick: focus
});
},
getItemState,
getItemProps(props2) {
const itemState = getItemState(props2);
return normalize.label({
...parts.item.attrs,
dir: prop("dir"),
id: getItemId(scope, props2.value),
htmlFor: getItemHiddenInputId(scope, props2.value),
...getItemDataAttrs(props2),
onPointerMove() {
if (itemState.disabled) return;
if (itemState.hovered) return;
send({ type: "SET_HOVERED", value: props2.value, hovered: true });
},
onPointerLeave() {
if (itemState.disabled) return;
send({ type: "SET_HOVERED", value: null });
},
onPointerDown(event) {
if (itemState.disabled) return;
if (!domQuery.isLeftClick(event)) return;
if (itemState.focused && event.pointerType === "mouse") {
event.preventDefault();
}
send({ type: "SET_ACTIVE", value: props2.value, active: true });
},
onPointerUp() {
if (itemState.disabled) return;
send({ type: "SET_ACTIVE", value: null });
},
onClick() {
if (!itemState.disabled && domQuery.isSafari()) {
getItemHiddenInputEl(scope, props2.value)?.focus();
}
}
});
},
getItemTextProps(props2) {
return normalize.element({
...parts.itemText.attrs,
dir: prop("dir"),
id: getItemLabelId(scope, props2.value),
...getItemDataAttrs(props2)
});
},
getItemControlProps(props2) {
const itemState = getItemState(props2);
return normalize.element({
...parts.itemControl.attrs,
dir: prop("dir"),
id: getItemControlId(scope, props2.value),
"data-active": domQuery.dataAttr(itemState.active),
"aria-hidden": true,
...getItemDataAttrs(props2)
});
},
getItemHiddenInputProps(props2) {
const itemState = getItemState(props2);
return normalize.input({
"data-ownedby": getRootId(scope),
id: getItemHiddenInputId(scope, props2.value),
type: "radio",
name: prop("name") || prop("id"),
form: prop("form"),
value: props2.value,
onClick(event) {
if (readOnly) {
event.preventDefault();
return;
}
if (event.currentTarget.checked) {
send({ type: "SET_VALUE", value: props2.value, isTrusted: true });
}
},
onBlur() {
send({ type: "SET_FOCUSED", value: null, focused: false });
},
onFocus() {
const focusVisible$1 = focusVisible.isFocusVisible();
send({ type: "SET_FOCUSED", value: props2.value, focused: true, focusVisible: focusVisible$1 });
},
onKeyDown(event) {
if (event.defaultPrevented) return;
if (event.key === " ") {
send({ type: "SET_ACTIVE", value: props2.value, active: true });
}
},
onKeyUp(event) {
if (event.defaultPrevented) return;
if (event.key === " ") {
send({ type: "SET_ACTIVE", value: null });
}
},
disabled: itemState.disabled,
defaultChecked: itemState.checked,
style: domQuery.visuallyHiddenStyle
});
},
getIndicatorProps() {
const rect = context.get("indicatorRect");
return normalize.element({
id: getIndicatorId(scope),
...parts.indicator.attrs,
dir: prop("dir"),
hidden: context.get("value") == null,
"data-disabled": domQuery.dataAttr(groupDisabled),
"data-orientation": prop("orientation"),
style: {
"--transition-property": "left, top, width, height",
"--left": rect?.left,
"--top": rect?.top,
"--width": rect?.width,
"--height": rect?.height,
position: "absolute",
willChange: "var(--transition-property)",
transitionProperty: "var(--transition-property)",
transitionDuration: context.get("canIndicatorTransition") ? "var(--transition-duration, 150ms)" : "0ms",
transitionTimingFunction: "var(--transition-timing-function)",
[prop("orientation") === "horizontal" ? "left" : "top"]: prop("orientation") === "horizontal" ? "var(--left)" : "var(--top)"
}
});
}
};
}
var { not } = core.createGuards();
var machine = core.createMachine({
props({ props: props2 }) {
return {
orientation: "vertical",
...props2
};
},
initialState() {
return "idle";
},
context({ prop, bindable }) {
return {
value: bindable(() => ({
defaultValue: prop("defaultValue"),
value: prop("value"),
onChange(value) {
prop("onValueChange")?.({ value });
}
})),
activeValue: bindable(() => ({
defaultValue: null
})),
focusedValue: bindable(() => ({
defaultValue: null
})),
hoveredValue: bindable(() => ({
defaultValue: null
})),
indicatorRect: bindable(() => ({
defaultValue: {}
})),
canIndicatorTransition: bindable(() => ({
defaultValue: false
})),
fieldsetDisabled: bindable(() => ({
defaultValue: false
})),
ssr: bindable(() => ({
defaultValue: true
}))
};
},
refs() {
return {
indicatorCleanup: null,
focusVisibleValue: null
};
},
computed: {
isDisabled: ({ prop, context }) => !!prop("disabled") || context.get("fieldsetDisabled")
},
entry: ["syncIndicatorRect", "syncSsr"],
exit: ["cleanupObserver"],
effects: ["trackFormControlState", "trackFocusVisible"],
watch({ track, action, context }) {
track([() => context.get("value")], () => {
action(["setIndicatorTransition", "syncIndicatorRect", "syncInputElements"]);
});
},
on: {
SET_VALUE: [
{
guard: not("isTrusted"),
actions: ["setValue", "dispatchChangeEvent"]
},
{
actions: ["setValue"]
}
],
SET_HOVERED: {
actions: ["setHovered"]
},
SET_ACTIVE: {
actions: ["setActive"]
},
SET_FOCUSED: {
actions: ["setFocused"]
}
},
states: {
idle: {}
},
implementations: {
guards: {
isTrusted: ({ event }) => !!event.isTrusted
},
effects: {
trackFormControlState({ context, scope }) {
return domQuery.trackFormControl(getRootEl(scope), {
onFieldsetDisabledChange(disabled) {
context.set("fieldsetDisabled", disabled);
},
onFormReset() {
context.set("value", context.initial("value"));
}
});
},
trackFocusVisible({ scope }) {
return focusVisible.trackFocusVisible({ root: scope.getRootNode?.() });
}
},
actions: {
setValue({ context, event }) {
context.set("value", event.value);
},
setHovered({ context, event }) {
context.set("hoveredValue", event.value);
},
setActive({ context, event }) {
context.set("activeValue", event.value);
},
setFocused({ context, event, refs }) {
context.set("focusedValue", event.value);
refs.set("focusVisibleValue", event.focusVisible ? event.value : null);
},
syncInputElements({ context, scope }) {
const inputs = getInputEls(scope);
inputs.forEach((input) => {
input.checked = input.value === context.get("value");
});
},
setIndicatorTransition({ context }) {
context.set("canIndicatorTransition", utils.isString(context.get("value")));
},
cleanupObserver({ refs }) {
refs.get("indicatorCleanup")?.();
},
syncSsr({ context }) {
context.set("ssr", false);
},
syncIndicatorRect({ context, scope, refs }) {
refs.get("indicatorCleanup")?.();
if (!getIndicatorEl(scope)) return;
const value = context.get("value");
const radioEl = getRadioEl(scope, value);
if (value == null || !radioEl) {
context.set("canIndicatorTransition", false);
context.set("indicatorRect", {});
return;
}
const indicatorCleanup = domQuery.trackElementRect([radioEl], {
measure(el) {
return getOffsetRect(el);
},
onEntry({ rects }) {
context.set("indicatorRect", resolveRect(rects[0]));
}
});
refs.set("indicatorCleanup", indicatorCleanup);
},
dispatchChangeEvent({ context, scope }) {
const inputEls = getInputEls(scope);
inputEls.forEach((inputEl) => {
const checked = inputEl.value === context.get("value");
if (checked === inputEl.checked) return;
domQuery.dispatchInputCheckedEvent(inputEl, { checked });
});
}
}
}
});
var props = types.createProps()([
"dir",
"disabled",
"form",
"getRootNode",
"id",
"ids",
"name",
"onValueChange",
"orientation",
"readOnly",
"value",
"defaultValue"
]);
var splitProps = utils.createSplitProps(props);
var itemProps = types.createProps()(["value", "disabled", "invalid"]);
var splitItemProps = utils.createSplitProps(itemProps);
exports.anatomy = anatomy;
exports.connect = connect;
exports.itemProps = itemProps;
exports.machine = machine;
exports.props = props;
exports.splitItemProps = splitItemProps;
exports.splitProps = splitProps;