@zag-js/radio-group 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zag-js/radio-group",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Core logic for the radio group widget implemented as a state machine",
5
5
  "keywords": [
6
6
  "js",
@@ -18,7 +18,8 @@
18
18
  "repository": "https://github.com/chakra-ui/zag/tree/main/packages/radio-group",
19
19
  "sideEffects": false,
20
20
  "files": [
21
- "dist/**/*"
21
+ "dist",
22
+ "src"
22
23
  ],
23
24
  "publishConfig": {
24
25
  "access": "public"
@@ -27,14 +28,14 @@
27
28
  "url": "https://github.com/chakra-ui/zag/issues"
28
29
  },
29
30
  "dependencies": {
30
- "@zag-js/anatomy": "0.9.2",
31
- "@zag-js/dom-query": "0.9.2",
32
- "@zag-js/element-rect": "0.9.2",
33
- "@zag-js/form-utils": "0.9.2",
34
- "@zag-js/visually-hidden": "0.9.2",
35
- "@zag-js/utils": "0.9.2",
36
- "@zag-js/core": "0.9.2",
37
- "@zag-js/types": "0.9.2"
31
+ "@zag-js/anatomy": "0.10.1",
32
+ "@zag-js/dom-query": "0.10.1",
33
+ "@zag-js/element-rect": "0.10.1",
34
+ "@zag-js/form-utils": "0.10.1",
35
+ "@zag-js/visually-hidden": "0.10.1",
36
+ "@zag-js/utils": "0.10.1",
37
+ "@zag-js/core": "0.10.1",
38
+ "@zag-js/types": "0.10.1"
38
39
  },
39
40
  "devDependencies": {
40
41
  "clean-package": "2.2.0"
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { anatomy } from "./radio-group.anatomy"
2
+ export { connect } from "./radio-group.connect"
3
+ export { machine } from "./radio-group.machine"
4
+ export type { UserDefinedContext as Context } from "./radio-group.types"
@@ -0,0 +1,12 @@
1
+ import { createAnatomy } from "@zag-js/anatomy"
2
+
3
+ export const anatomy = createAnatomy("radio-group").parts(
4
+ "root",
5
+ "label",
6
+ "radio",
7
+ "radioLabel",
8
+ "radioControl",
9
+ "radioInput",
10
+ "indicator",
11
+ )
12
+ export const parts = anatomy.build()
@@ -0,0 +1,224 @@
1
+ import { ariaAttr, dataAttr } from "@zag-js/dom-query"
2
+ import type { NormalizeProps, PropTypes } from "@zag-js/types"
3
+ import { visuallyHiddenStyle } from "@zag-js/visually-hidden"
4
+ import { parts } from "./radio-group.anatomy"
5
+ import { dom } from "./radio-group.dom"
6
+ import type { InputProps, RadioProps, Send, State } from "./radio-group.types"
7
+
8
+ export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
9
+ const isGroupDisabled = state.context.disabled
10
+ const isGroupReadOnly = state.context.readOnly
11
+
12
+ function getRadioState<T extends RadioProps>(props: T) {
13
+ const radioState = {
14
+ isReadOnly: props.readOnly || isGroupReadOnly,
15
+ isInvalid: props.invalid,
16
+ isDisabled: props.disabled || isGroupDisabled,
17
+ isChecked: state.context.value === props.value,
18
+ isFocused: state.context.focusedId === props.value,
19
+ isHovered: state.context.hoveredId === props.value,
20
+ isActive: state.context.activeId === props.value,
21
+ }
22
+ return {
23
+ ...radioState,
24
+ isInteractive: !(radioState.isReadOnly || radioState.isDisabled),
25
+ }
26
+ }
27
+
28
+ function getRadioDataAttrs<T extends RadioProps>(props: T) {
29
+ const radioState = getRadioState(props)
30
+ return {
31
+ "data-focus": dataAttr(radioState.isFocused),
32
+ "data-disabled": dataAttr(radioState.isDisabled),
33
+ "data-checked": dataAttr(radioState.isChecked),
34
+ "data-hover": dataAttr(radioState.isHovered),
35
+ "data-invalid": dataAttr(radioState.isInvalid),
36
+ "data-readonly": dataAttr(radioState.isReadOnly),
37
+ }
38
+ }
39
+
40
+ const focus = () => {
41
+ const firstEnabledAndCheckedInput = dom.getFirstEnabledAndCheckedInputEl(state.context)
42
+
43
+ if (firstEnabledAndCheckedInput) {
44
+ firstEnabledAndCheckedInput.focus()
45
+ return
46
+ }
47
+
48
+ const firstEnabledInput = dom.getFirstEnabledInputEl(state.context)
49
+ firstEnabledInput?.focus()
50
+ }
51
+
52
+ return {
53
+ /**
54
+ * The current value of the radio group
55
+ */
56
+ value: state.context.value,
57
+ /**
58
+ * Function to set the value of the radio group
59
+ */
60
+ setValue(value: string) {
61
+ send({ type: "SET_VALUE", value, manual: true })
62
+ },
63
+ /**
64
+ * Function to clear the value of the radio group
65
+ */
66
+ clearValue() {
67
+ send({ type: "SET_VALUE", value: null, manual: true })
68
+ },
69
+ /**
70
+ * Function to focus the radio group
71
+ */
72
+ focus,
73
+ /**
74
+ * Function to blur the currently focused radio input in the radio group
75
+ */
76
+ blur() {
77
+ const focusedElement = dom.getActiveElement(state.context)
78
+ const inputEls = dom.getInputEls(state.context)
79
+ const radioInputIsFocused = inputEls.some((el) => el === focusedElement)
80
+ if (radioInputIsFocused) focusedElement?.blur()
81
+ },
82
+ /**
83
+ * Returns the state details of a radio input
84
+ */
85
+ getRadioState,
86
+
87
+ rootProps: normalize.element({
88
+ ...parts.root.attrs,
89
+ role: "radiogroup",
90
+ id: dom.getRootId(state.context),
91
+ "aria-labelledby": dom.getLabelId(state.context),
92
+ "data-orientation": state.context.orientation,
93
+ "aria-orientation": state.context.orientation,
94
+ dir: state.context.dir,
95
+ }),
96
+
97
+ labelProps: normalize.element({
98
+ ...parts.label.attrs,
99
+ id: dom.getLabelId(state.context),
100
+ onClick: focus,
101
+ }),
102
+
103
+ getRadioProps(props: RadioProps) {
104
+ const rootState = getRadioState(props)
105
+
106
+ return normalize.label({
107
+ ...parts.radio.attrs,
108
+ id: dom.getRadioId(state.context, props.value),
109
+ htmlFor: dom.getRadioInputId(state.context, props.value),
110
+ ...getRadioDataAttrs(props),
111
+
112
+ onPointerMove() {
113
+ if (!rootState.isInteractive) return
114
+ send({ type: "SET_HOVERED", value: props.value, hovered: true })
115
+ },
116
+ onPointerLeave() {
117
+ if (!rootState.isInteractive) return
118
+ send({ type: "SET_HOVERED", value: null })
119
+ },
120
+ onPointerDown(event) {
121
+ if (!rootState.isInteractive) return
122
+ // On pointerdown, the input blurs and returns focus to the `body`,
123
+ // we need to prevent this.
124
+ if (rootState.isFocused && event.pointerType === "mouse") {
125
+ event.preventDefault()
126
+ }
127
+ send({ type: "SET_ACTIVE", value: props.value, active: true })
128
+ },
129
+ onPointerUp() {
130
+ if (!rootState.isInteractive) return
131
+ send({ type: "SET_ACTIVE", value: null })
132
+ },
133
+ })
134
+ },
135
+
136
+ getRadioLabelProps(props: RadioProps) {
137
+ return normalize.element({
138
+ ...parts.radioLabel.attrs,
139
+ id: dom.getRadioLabelId(state.context, props.value),
140
+ ...getRadioDataAttrs(props),
141
+ })
142
+ },
143
+
144
+ getRadioControlProps(props: RadioProps) {
145
+ const controlState = getRadioState(props)
146
+
147
+ return normalize.element({
148
+ ...parts.radioControl.attrs,
149
+ id: dom.getRadioControlId(state.context, props.value),
150
+ "data-active": dataAttr(controlState.isActive),
151
+ "aria-hidden": true,
152
+ ...getRadioDataAttrs(props),
153
+ })
154
+ },
155
+
156
+ getRadioInputProps(props: InputProps) {
157
+ const inputState = getRadioState(props)
158
+
159
+ const isRequired = props.required
160
+ const trulyDisabled = inputState.isDisabled && !props.focusable
161
+
162
+ return normalize.input({
163
+ ...parts.radioInput.attrs,
164
+ "data-ownedby": dom.getRootId(state.context),
165
+ id: dom.getRadioInputId(state.context, props.value),
166
+ type: "radio",
167
+ name: state.context.name || state.context.id,
168
+ form: state.context.form,
169
+ value: props.value,
170
+ onChange(event) {
171
+ if (inputState.isReadOnly || inputState.isDisabled) {
172
+ return
173
+ }
174
+ if (event.target.checked) {
175
+ send({ type: "SET_VALUE", value: props.value })
176
+ }
177
+ },
178
+ onBlur() {
179
+ send({ type: "SET_FOCUSED", value: null })
180
+ },
181
+ onFocus() {
182
+ send({ type: "SET_FOCUSED", value: props.value, focused: true })
183
+ },
184
+ onKeyDown(event) {
185
+ if (event.key === " ") {
186
+ send({ type: "SET_ACTIVE", value: props.value, active: true })
187
+ }
188
+ },
189
+ onKeyUp(event) {
190
+ if (event.key === " ") {
191
+ send({ type: "SET_ACTIVE", value: null })
192
+ }
193
+ },
194
+ disabled: trulyDisabled,
195
+ required: isRequired,
196
+ defaultChecked: inputState.isChecked,
197
+ "data-disabled": dataAttr(inputState.isDisabled),
198
+ "aria-required": ariaAttr(isRequired),
199
+ "aria-invalid": ariaAttr(inputState.isInvalid),
200
+ readOnly: inputState.isReadOnly,
201
+ "data-readonly": dataAttr(inputState.isReadOnly),
202
+ "aria-disabled": ariaAttr(trulyDisabled),
203
+ "aria-checked": ariaAttr(inputState.isChecked),
204
+ style: visuallyHiddenStyle,
205
+ })
206
+ },
207
+
208
+ indicatorProps: normalize.element({
209
+ id: dom.getIndicatorId(state.context),
210
+ ...parts.indicator.attrs,
211
+ "data-orientation": state.context.orientation,
212
+ style: {
213
+ "--transition-duration": "150ms",
214
+ "--transition-property": "left, top, width, height",
215
+ position: "absolute",
216
+ willChange: "var(--transition-property)",
217
+ transitionProperty: "var(--transition-property)",
218
+ transitionDuration: state.context.canIndicatorTransition ? "var(--transition-duration)" : "0ms",
219
+ transitionTimingFunction: "var(--transition-timing-function)",
220
+ ...state.context.indicatorRect,
221
+ },
222
+ }),
223
+ }
224
+ }
@@ -0,0 +1,57 @@
1
+ import { createScope, queryAll } from "@zag-js/dom-query"
2
+ import type { MachineContext as Ctx } from "./radio-group.types"
3
+
4
+ export const dom = createScope({
5
+ getRootId: (ctx: Ctx) => ctx.ids?.root ?? `radio-group:${ctx.id}`,
6
+ getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `radio-group:${ctx.id}:label`,
7
+ getRadioId: (ctx: Ctx, value: string) => ctx.ids?.radio?.(value) ?? `radio-group:${ctx.id}:radio:${value}`,
8
+ getRadioInputId: (ctx: Ctx, value: string) =>
9
+ ctx.ids?.radioInput?.(value) ?? `radio-group:${ctx.id}:radio:input:${value}`,
10
+ getRadioControlId: (ctx: Ctx, value: string) =>
11
+ ctx.ids?.radioControl?.(value) ?? `radio-group:${ctx.id}:radio:control:${value}`,
12
+ getRadioLabelId: (ctx: Ctx, value: string) =>
13
+ ctx.ids?.radioLabel?.(value) ?? `radio-group:${ctx.id}:radio:label:${value}`,
14
+ getIndicatorId: (ctx: Ctx) => ctx.ids?.indicator ?? `tabs:${ctx.id}:indicator`,
15
+
16
+ getRootEl: (ctx: Ctx) => dom.getById(ctx, dom.getRootId(ctx)),
17
+ getRadioInputEl: (ctx: Ctx, value: string) => dom.getById<HTMLInputElement>(ctx, dom.getRadioInputId(ctx, value)),
18
+ getIndicatorEl: (ctx: Ctx) => dom.getById(ctx, dom.getIndicatorId(ctx)),
19
+
20
+ getFirstEnabledInputEl: (ctx: Ctx) => dom.getRootEl(ctx)?.querySelector<HTMLInputElement>("input:not(:disabled)"),
21
+ getFirstEnabledAndCheckedInputEl: (ctx: Ctx) =>
22
+ dom.getRootEl(ctx)?.querySelector<HTMLInputElement>("input:not(:disabled):checked"),
23
+
24
+ getInputEls: (ctx: Ctx) => {
25
+ const ownerId = CSS.escape(dom.getRootId(ctx))
26
+ const selector = `input[type=radio][data-ownedby='${ownerId}']:not([disabled])`
27
+ return queryAll<HTMLInputElement>(dom.getRootEl(ctx), selector)
28
+ },
29
+
30
+ getActiveRadioEl: (ctx: Ctx) => {
31
+ if (!ctx.value) return
32
+ return dom.getById(ctx, dom.getRadioId(ctx, ctx.value))
33
+ },
34
+
35
+ getOffsetRect: (el: HTMLElement | undefined) => {
36
+ return {
37
+ left: el?.offsetLeft ?? 0,
38
+ top: el?.offsetTop ?? 0,
39
+ width: el?.offsetWidth ?? 0,
40
+ height: el?.offsetHeight ?? 0,
41
+ }
42
+ },
43
+
44
+ getRectById: (ctx: Ctx, id: string) => {
45
+ const tab = dom.queryById(ctx, dom.getRadioId(ctx, id))
46
+ return dom.resolveRect(dom.getOffsetRect(tab))
47
+ },
48
+
49
+ resolveRect(rect: Record<"width" | "height" | "left" | "top", number>) {
50
+ return {
51
+ width: `${rect.width}px`,
52
+ height: `${rect.height}px`,
53
+ left: `${rect.left}px`,
54
+ top: `${rect.top}px`,
55
+ }
56
+ },
57
+ })
@@ -0,0 +1,138 @@
1
+ import { createMachine } from "@zag-js/core"
2
+ import { nextTick } from "@zag-js/dom-query"
3
+ import { trackElementRect } from "@zag-js/element-rect"
4
+ import { trackFormControl } from "@zag-js/form-utils"
5
+ import { compact, isString } from "@zag-js/utils"
6
+ import { dom } from "./radio-group.dom"
7
+ import type { MachineContext, MachineState, UserDefinedContext } from "./radio-group.types"
8
+
9
+ export function machine(userContext: UserDefinedContext) {
10
+ const ctx = compact(userContext)
11
+ return createMachine<MachineContext, MachineState>(
12
+ {
13
+ id: "radio",
14
+ initial: "idle",
15
+ context: {
16
+ previousValue: null,
17
+ value: null,
18
+ activeId: null,
19
+ focusedId: null,
20
+ hoveredId: null,
21
+ indicatorRect: {},
22
+ canIndicatorTransition: false,
23
+ ...ctx,
24
+ },
25
+
26
+ entry: ["syncIndicatorRect"],
27
+
28
+ exit: ["cleanupObserver"],
29
+
30
+ activities: ["trackFormControlState"],
31
+
32
+ watch: {
33
+ value: [
34
+ "setIndicatorTransition",
35
+ // important to set this after `setIndicatorTransition`
36
+ "setPreviousValue",
37
+ "invokeOnChange",
38
+ "syncIndicatorRect",
39
+ "syncInputElements",
40
+ ],
41
+ },
42
+
43
+ on: {
44
+ SET_VALUE: {
45
+ actions: ["setValue"],
46
+ },
47
+ SET_HOVERED: {
48
+ actions: "setHovered",
49
+ },
50
+ SET_ACTIVE: {
51
+ actions: "setActive",
52
+ },
53
+ SET_FOCUSED: {
54
+ actions: "setFocused",
55
+ },
56
+ },
57
+
58
+ states: {
59
+ idle: {},
60
+ },
61
+ },
62
+
63
+ {
64
+ activities: {
65
+ trackFormControlState(ctx, _evt, { send, initialContext }) {
66
+ return trackFormControl(dom.getRootEl(ctx), {
67
+ onFieldsetDisabled() {
68
+ ctx.disabled = true
69
+ },
70
+ onFormReset() {
71
+ send({ type: "SET_VALUE", value: initialContext.value })
72
+ },
73
+ })
74
+ },
75
+ },
76
+
77
+ actions: {
78
+ setValue(ctx, evt) {
79
+ ctx.value = evt.value
80
+ },
81
+ setHovered(ctx, evt) {
82
+ ctx.hoveredId = evt.value
83
+ },
84
+ setActive(ctx, evt) {
85
+ ctx.activeId = evt.value
86
+ },
87
+ setFocused(ctx, evt) {
88
+ ctx.focusedId = evt.value
89
+ },
90
+ invokeOnChange(ctx, evt) {
91
+ ctx.onChange?.({ value: evt.value })
92
+ },
93
+ syncInputElements(ctx) {
94
+ const inputs = dom.getInputEls(ctx)
95
+ inputs.forEach((input) => {
96
+ input.checked = input.value === ctx.value
97
+ })
98
+ },
99
+ setPreviousValue(ctx) {
100
+ ctx.previousValue = ctx.value
101
+ },
102
+ setIndicatorTransition(ctx) {
103
+ ctx.canIndicatorTransition = isString(ctx.previousValue) && isString(ctx.value)
104
+ },
105
+ cleanupObserver(ctx) {
106
+ ctx.indicatorCleanup?.()
107
+ },
108
+ syncIndicatorRect(ctx) {
109
+ ctx.indicatorCleanup?.()
110
+
111
+ if (!dom.getIndicatorEl(ctx)) return
112
+
113
+ const value = ctx.value
114
+
115
+ if (value == null) {
116
+ ctx.indicatorRect = {}
117
+ return
118
+ }
119
+
120
+ const radioEl = dom.getActiveRadioEl(ctx)
121
+ if (!radioEl) return
122
+
123
+ ctx.indicatorCleanup = trackElementRect(radioEl, {
124
+ getRect(el) {
125
+ return dom.getOffsetRect(el)
126
+ },
127
+ onChange(rect) {
128
+ ctx.indicatorRect = dom.resolveRect(rect)
129
+ nextTick(() => {
130
+ ctx.canIndicatorTransition = false
131
+ })
132
+ },
133
+ })
134
+ },
135
+ },
136
+ },
137
+ )
138
+ }
@@ -0,0 +1,131 @@
1
+ import type { StateMachine as S } from "@zag-js/core"
2
+ import type { CommonProperties, Context, DirectionProperty, RequiredBy } from "@zag-js/types"
3
+
4
+ type ElementIds = Partial<{
5
+ root: string
6
+ label: string
7
+ indicator: string
8
+ radio(value: string): string
9
+ radioLabel(value: string): string
10
+ radioControl(value: string): string
11
+ radioInput(value: string): string
12
+ }>
13
+
14
+ type PublicContext = DirectionProperty &
15
+ CommonProperties & {
16
+ /**
17
+ * The ids of the elements in the radio. Useful for composition.
18
+ */
19
+ ids?: ElementIds
20
+ /**
21
+ * The value of the checked radio
22
+ */
23
+ value: string | null
24
+
25
+ /**
26
+ * The name of the input fields in the radio
27
+ * (Useful for form submission).
28
+ */
29
+ name?: string
30
+ /**
31
+ * The associate form of the underlying input.
32
+ */
33
+ form?: string
34
+ /**
35
+ * If `true`, the radio group will be disabled
36
+ */
37
+ disabled?: boolean
38
+ /**
39
+ * If `true`, the radio group will be readonly
40
+ */
41
+ readOnly?: boolean
42
+ /**
43
+ * Function called once a radio is checked
44
+ * @param value the value of the checked radio
45
+ */
46
+ onChange?(details: { value: string }): void
47
+ /**
48
+ * Orientation of the radio group
49
+ */
50
+ orientation?: "horizontal" | "vertical"
51
+ }
52
+
53
+ type PrivateContext = Context<{
54
+ /**
55
+ * @internal
56
+ * The id of the active radio
57
+ */
58
+ activeId: string | null
59
+ /**
60
+ * @internal
61
+ * The id of the focused radio
62
+ */
63
+ focusedId: string | null
64
+ /**
65
+ * @internal
66
+ * The id of the hovered radio
67
+ */
68
+ hoveredId: string | null
69
+ /**
70
+ * @internal
71
+ * The active tab indicator's dom rect
72
+ */
73
+ indicatorRect?: Partial<{ left: string; top: string; width: string; height: string }>
74
+ /**
75
+ * @internal
76
+ * Whether the active tab indicator's rect can transition
77
+ */
78
+ canIndicatorTransition?: boolean
79
+ /**
80
+ * @internal
81
+ * Function to clean up the observer for the active tab's rect
82
+ */
83
+ indicatorCleanup?: VoidFunction | null
84
+ /**
85
+ * @internal
86
+ * The previous value of the radio group
87
+ */
88
+ previousValue: string | null
89
+ }>
90
+
91
+ export type UserDefinedContext = RequiredBy<PublicContext, "id">
92
+
93
+ type ComputedContext = Readonly<{}>
94
+
95
+ export type MachineContext = PublicContext & PrivateContext & ComputedContext
96
+
97
+ export type MachineState = {
98
+ value: "idle"
99
+ }
100
+
101
+ export type State = S.State<MachineContext, MachineState>
102
+
103
+ export type Send = S.Send<S.AnyEventObject>
104
+
105
+ export type RadioProps = {
106
+ value: string
107
+ /**
108
+ * If `true`, the radio will be disabled
109
+ */
110
+ disabled?: boolean
111
+ /**
112
+ * If `true`, the radio will be readonly
113
+ */
114
+ readOnly?: boolean
115
+ /**
116
+ * If `true`, the radio is marked as invalid.
117
+ */
118
+ invalid?: boolean
119
+ }
120
+
121
+ export type InputProps = RadioProps & {
122
+ /**
123
+ * If `true` and `disabled` is passed, the radio will
124
+ * remain tabbable but not interactive
125
+ */
126
+ focusable?: boolean
127
+ /**
128
+ * If `true`, the radio input is marked as required,
129
+ */
130
+ required?: boolean
131
+ }