From 8f678c04afafc3bb261261f153ed660261989db4 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 20:45:39 +0100 Subject: [PATCH 01/45] refactor: accordion --- .changeset/sixty-files-shop.md | 36 ++++++++ .xstate/accordion.js | 14 +-- .xstate/tags-input.js | 2 +- .xstate/toggle-group.js | 4 +- .../accordion/src/accordion.connect.ts | 12 +-- .../accordion/src/accordion.machine.ts | 89 ++++++++++--------- .../machines/accordion/src/accordion.types.ts | 62 +++++++------ packages/machines/accordion/src/index.ts | 2 +- 8 files changed, 139 insertions(+), 82 deletions(-) create mode 100644 .changeset/sixty-files-shop.md diff --git a/.changeset/sixty-files-shop.md b/.changeset/sixty-files-shop.md new file mode 100644 index 0000000000..11229b735a --- /dev/null +++ b/.changeset/sixty-files-shop.md @@ -0,0 +1,36 @@ +--- +"@zag-js/color-picker": minor +"@zag-js/number-input": minor +"@zag-js/range-slider": minor +"@zag-js/rating-group": minor +"@zag-js/toggle-group": minor +"@zag-js/date-picker": minor +"@zag-js/file-upload": minor +"@zag-js/radio-group": minor +"@zag-js/hover-card": minor +"@zag-js/pagination": minor +"@zag-js/tags-input": minor +"@zag-js/dom-query": minor +"@zag-js/accordion": minor +"@zag-js/pin-input": minor +"@zag-js/pressable": minor +"@zag-js/carousel": minor +"@zag-js/checkbox": minor +"@zag-js/combobox": minor +"@zag-js/editable": minor +"@zag-js/presence": minor +"@zag-js/splitter": minor +"@zag-js/popover": minor +"@zag-js/tooltip": minor +"@zag-js/avatar": minor +"@zag-js/dialog": minor +"@zag-js/select": minor +"@zag-js/slider": minor +"@zag-js/switch": minor +"@zag-js/toggle": minor +"@zag-js/toast": minor +"@zag-js/menu": minor +"@zag-js/tabs": minor +--- + +Refactor machine event handling and rename `PublicApi` to `Api` diff --git a/.xstate/accordion.js b/.xstate/accordion.js index 5eb8a6c48f..9ee8053e00 100644 --- a/.xstate/accordion.js +++ b/.xstate/accordion.js @@ -18,7 +18,7 @@ const fetchMachine = createMachine({ }, on: { "VALUE.SET": { - actions: ["setValue", "invokeOnChange"] + actions: ["setValue"] } }, on: { @@ -38,23 +38,23 @@ const fetchMachine = createMachine({ focused: { on: { "GOTO.NEXT": { - actions: "focusNext" + actions: "focusNextTrigger" }, "GOTO.PREV": { - actions: "focusPrev" + actions: "focusPrevTrigger" }, "TRIGGER.CLICK": [{ cond: "isExpanded && canToggle", - actions: ["collapse", "invokeOnChange"] + actions: ["collapse"] }, { cond: "!isExpanded", - actions: ["expand", "invokeOnChange"] + actions: ["expand"] }], "GOTO.FIRST": { - actions: "focusFirst" + actions: "focusFirstTrigger" }, "GOTO.LAST": { - actions: "focusLast" + actions: "focusLastTrigger" }, "TRIGGER.BLUR": { target: "idle", diff --git a/.xstate/tags-input.js b/.xstate/tags-input.js index 886199d75e..c92f217e48 100644 --- a/.xstate/tags-input.js +++ b/.xstate/tags-input.js @@ -72,7 +72,7 @@ const fetchMachine = createMachine({ actions: "clearInputValue" }] }, - entry: ["setupDocument", "checkValue"], + entry: ["setupDocument"], on: { UPDATE_CONTEXT: { actions: "updateContext" diff --git a/.xstate/toggle-group.js b/.xstate/toggle-group.js index 7bc99da87b..74fae65531 100644 --- a/.xstate/toggle-group.js +++ b/.xstate/toggle-group.js @@ -10,7 +10,7 @@ const { choose } = actions; const fetchMachine = createMachine({ - id: "1", + id: "toggle-group", initial: "idle", context: { "!(isClickFocus && isTabbingBackward)": false @@ -21,7 +21,7 @@ const fetchMachine = createMachine({ actions: ["setValue"] }, "TOGGLE.CLICK": { - actions: ["setValue", "invokeOnChange"] + actions: ["setValue"] }, "ROOT.MOUSE_DOWN": { actions: ["setClickFocus"] diff --git a/packages/machines/accordion/src/accordion.connect.ts b/packages/machines/accordion/src/accordion.connect.ts index 795f7ffade..2c45777db7 100644 --- a/packages/machines/accordion/src/accordion.connect.ts +++ b/packages/machines/accordion/src/accordion.connect.ts @@ -3,24 +3,24 @@ import { dataAttr, isSafari } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./accordion.anatomy" import { dom } from "./accordion.dom" -import type { ItemProps, ItemState, PublicApi, Send, State } from "./accordion.types" +import type { ItemProps, ItemState, MachineApi, Send, State } from "./accordion.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const focusedValue = state.context.focusedValue const value = state.context.value const multiple = state.context.multiple - function setValue(value: string | string[]) { + function setValue(value: string[]) { let nextValue = value - if (multiple && !Array.isArray(nextValue)) { - nextValue = [nextValue] + if (multiple && nextValue.length > 1) { + nextValue = [nextValue[0]] } send({ type: "VALUE.SET", value: nextValue }) } function getItemState(props: ItemProps): ItemState { return { - isOpen: Array.isArray(value) ? value.includes(props.value) : props.value === value, + isOpen: value.includes(props.value), isFocused: focusedValue === props.value, isDisabled: Boolean(props.disabled ?? state.context.disabled), } diff --git a/packages/machines/accordion/src/accordion.machine.ts b/packages/machines/accordion/src/accordion.machine.ts index 4808c70eb7..c1e86ac04b 100644 --- a/packages/machines/accordion/src/accordion.machine.ts +++ b/packages/machines/accordion/src/accordion.machine.ts @@ -1,12 +1,10 @@ import { createMachine, guards } from "@zag-js/core" -import { add, compact, isString, remove, toArray, warn } from "@zag-js/utils" +import { add, compact, remove, warn } from "@zag-js/utils" import { dom } from "./accordion.dom" import type { MachineContext, MachineState, UserDefinedContext } from "./accordion.types" const { and, not } = guards -const valueMismatchMessage = `[accordion/invalid-value] Expected value for multiple accordion to be an 'array' but received 'string'. Value will be coarsed to 'array'` - export function machine(userContext: UserDefinedContext) { const ctx = compact(userContext) return createMachine( @@ -16,7 +14,7 @@ export function machine(userContext: UserDefinedContext) { context: { focusedValue: null, - value: null, + value: [], collapsible: false, multiple: false, orientation: "vertical", @@ -24,11 +22,11 @@ export function machine(userContext: UserDefinedContext) { }, watch: { - value: "sanitizeValue", - multiple: "sanitizeValue", + value: "coarseValue", + multiple: "coarseValue", }, - created: "sanitizeValue", + created: "coarseValue", computed: { isHorizontal: (ctx) => ctx.orientation === "horizontal", @@ -36,7 +34,7 @@ export function machine(userContext: UserDefinedContext) { on: { "VALUE.SET": { - actions: ["setValue", "invokeOnChange"], + actions: ["setValue"], }, }, @@ -52,26 +50,26 @@ export function machine(userContext: UserDefinedContext) { focused: { on: { "GOTO.NEXT": { - actions: "focusNext", + actions: "focusNextTrigger", }, "GOTO.PREV": { - actions: "focusPrev", + actions: "focusPrevTrigger", }, "TRIGGER.CLICK": [ { guard: and("isExpanded", "canToggle"), - actions: ["collapse", "invokeOnChange"], + actions: ["collapse"], }, { guard: not("isExpanded"), - actions: ["expand", "invokeOnChange"], + actions: ["expand"], }, ], "GOTO.FIRST": { - actions: "focusFirst", + actions: "focusFirstTrigger", }, "GOTO.LAST": { - actions: "focusLast", + actions: "focusLastTrigger", }, "TRIGGER.BLUR": { target: "idle", @@ -84,38 +82,32 @@ export function machine(userContext: UserDefinedContext) { { guards: { canToggle: (ctx) => !!ctx.collapsible || !!ctx.multiple, - isExpanded: (ctx, evt) => { - if (ctx.multiple && Array.isArray(ctx.value)) { - return ctx.value.includes(evt.value) - } - return ctx.value === evt.value - }, + isExpanded: (ctx, evt) => ctx.value.includes(evt.value), }, actions: { - invokeOnChange(ctx) { - ctx.onChange?.({ value: ctx.value }) - }, collapse(ctx, evt) { - ctx.value = ctx.multiple ? remove(toArray(ctx.value), evt.value) : null + const next = ctx.multiple ? remove(ctx.value, evt.value) : [] + set.value(ctx, ctx.multiple ? next : []) }, expand(ctx, evt) { - ctx.value = ctx.multiple ? add(toArray(ctx.value), evt.value) : evt.value + const next = ctx.multiple ? add(ctx.value, evt.value) : [evt.value] + set.value(ctx, next) }, - focusFirst(ctx) { + focusFirstTrigger(ctx) { dom.getFirstTriggerEl(ctx)?.focus() }, - focusLast(ctx) { + focusLastTrigger(ctx) { dom.getLastTriggerEl(ctx)?.focus() }, - focusNext(ctx) { + focusNextTrigger(ctx) { if (!ctx.focusedValue) return - const el = dom.getNextTriggerEl(ctx, ctx.focusedValue) - el?.focus() + const triggerEl = dom.getNextTriggerEl(ctx, ctx.focusedValue) + triggerEl?.focus() }, - focusPrev(ctx) { + focusPrevTrigger(ctx) { if (!ctx.focusedValue) return - const el = dom.getPrevTriggerEl(ctx, ctx.focusedValue) - el?.focus() + const triggerEl = dom.getPrevTriggerEl(ctx, ctx.focusedValue) + triggerEl?.focus() }, setFocusedValue(ctx, evt) { ctx.focusedValue = evt.value @@ -124,17 +116,34 @@ export function machine(userContext: UserDefinedContext) { ctx.focusedValue = null }, setValue(ctx, evt) { - ctx.value = evt.value + set.value(ctx, evt.value) }, - sanitizeValue(ctx) { - if (ctx.multiple && isString(ctx.value)) { - warn(valueMismatchMessage) - ctx.value = [ctx.value] - } else if (!ctx.multiple && Array.isArray(ctx.value) && ctx.value.length > 0) { - ctx.value = ctx.value[0] + coarseValue(ctx) { + if (!ctx.multiple && ctx.value.length > 1) { + warn(`The value of accordion should be a single value when multiple is false.`) + ctx.value = [ctx.value[0]] } }, }, }, ) } + +const invoke = { + change(ctx: MachineContext) { + ctx.onChange?.({ value: ctx.value }) + }, + focusChange(ctx: MachineContext) { + ctx.onFocusChange?.({ focusedValue: ctx.focusedValue }) + }, +} + +const set = { + value(ctx: MachineContext, value: string[]) { + ctx.value = value + invoke.change(ctx) + }, + focusedValue(ctx: MachineContext, value: string | null) { + ctx.focusedValue = value + }, +} diff --git a/packages/machines/accordion/src/accordion.types.ts b/packages/machines/accordion/src/accordion.types.ts index 6357618c54..e496630a87 100644 --- a/packages/machines/accordion/src/accordion.types.ts +++ b/packages/machines/accordion/src/accordion.types.ts @@ -8,6 +8,14 @@ type ElementIds = Partial<{ trigger(value: string): string }> +export type ChangeDetails = { + value: string[] +} + +export type FocusChangeDetails = { + focusedValue: string | null +} + type PublicContext = DirectionProperty & CommonProperties & { /** @@ -27,7 +35,7 @@ type PublicContext = DirectionProperty & /** * The `id` of the accordion item that is currently being opened. */ - value: string | string[] | null + value: string[] /** * Whether the accordion items are disabled */ @@ -35,36 +43,17 @@ type PublicContext = DirectionProperty & /** * The callback fired when the state of opened/closed accordion items changes. */ - onChange?: (details: { value: string | string[] | null }) => void + onChange?: (details: ChangeDetails) => void + /** + * The callback fired when the focused accordion item changes. + */ + onFocusChange?: (details: FocusChangeDetails) => void /** * The orientation of the accordion items. */ orientation?: "horizontal" | "vertical" } -export type PublicApi = { - rootProps: T["element"] - getItemProps(props: ItemProps): T["element"] - getContentProps(props: ItemProps): T["element"] - getTriggerProps(props: ItemProps): T["button"] - /** - * The value of the focused accordion item. - */ - focusedValue: string | null - /** - * The value of the accordion - */ - value: string | string[] | null - /** - * Sets the value of the accordion. - */ - setValue: (value: string | string[]) => void - /** - * Gets the state of an accordion item. - */ - getItemState: (props: ItemProps) => ItemState -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -103,3 +92,26 @@ export type ItemState = { isFocused: boolean isDisabled: boolean } + +export type MachineApi = { + /** + * The value of the focused accordion item. + */ + focusedValue: string | null + /** + * The value of the accordion + */ + value: string[] + /** + * Sets the value of the accordion. + */ + setValue: (value: string[]) => void + /** + * Gets the state of an accordion item. + */ + getItemState: (props: ItemProps) => ItemState + rootProps: T["element"] + getItemProps(props: ItemProps): T["element"] + getContentProps(props: ItemProps): T["element"] + getTriggerProps(props: ItemProps): T["button"] +} diff --git a/packages/machines/accordion/src/index.ts b/packages/machines/accordion/src/index.ts index d82d91544f..fff3f7b08d 100644 --- a/packages/machines/accordion/src/index.ts +++ b/packages/machines/accordion/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./accordion.anatomy" export { connect } from "./accordion.connect" export { machine } from "./accordion.machine" -export type { UserDefinedContext as Context, ItemProps, ItemState, PublicApi } from "./accordion.types" +export type { UserDefinedContext as Context, ItemProps, ItemState, MachineApi as Api } from "./accordion.types" From d066422d4b6e0ec62ebc06dc8123242d8756484e Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 20:46:13 +0100 Subject: [PATCH 02/45] refactor: avatar --- .../machines/avatar/src/avatar.connect.ts | 4 +-- packages/machines/avatar/src/avatar.types.ts | 34 +++++++++---------- packages/machines/avatar/src/index.ts | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/machines/avatar/src/avatar.connect.ts b/packages/machines/avatar/src/avatar.connect.ts index c78d9b2333..be03419e7f 100644 --- a/packages/machines/avatar/src/avatar.connect.ts +++ b/packages/machines/avatar/src/avatar.connect.ts @@ -1,9 +1,9 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./avatar.anatomy" import { dom } from "./avatar.dom" -import type { PublicApi, Send, State } from "./avatar.types" +import type { MachineApi, Send, State } from "./avatar.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isLoaded = state.matches("loaded") const showFallback = !isLoaded diff --git a/packages/machines/avatar/src/avatar.types.ts b/packages/machines/avatar/src/avatar.types.ts index 77cdb9b42d..7b0a827c95 100644 --- a/packages/machines/avatar/src/avatar.types.ts +++ b/packages/machines/avatar/src/avatar.types.ts @@ -6,7 +6,23 @@ type PublicContext = CommonProperties & { onError?: () => void } -export type PublicApi = { +type PrivateContext = Context<{}> + +type ComputedContext = Readonly<{}> + +export type UserDefinedContext = RequiredBy + +export type MachineContext = PublicContext & PrivateContext & ComputedContext + +export type MachineState = { + value: "loading" | "error" | "loaded" +} + +export type State = S.State + +export type Send = S.Send + +export type MachineApi = { /** * Whether the image is loaded. */ @@ -31,19 +47,3 @@ export type PublicApi = { imageProps: T["img"] fallbackProps: T["element"] } - -type PrivateContext = Context<{}> - -type ComputedContext = Readonly<{}> - -export type UserDefinedContext = RequiredBy - -export type MachineContext = PublicContext & PrivateContext & ComputedContext - -export type MachineState = { - value: "loading" | "error" | "loaded" -} - -export type State = S.State - -export type Send = S.Send diff --git a/packages/machines/avatar/src/index.ts b/packages/machines/avatar/src/index.ts index bbc125a736..80ace849ff 100644 --- a/packages/machines/avatar/src/index.ts +++ b/packages/machines/avatar/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./avatar.anatomy" export { connect } from "./avatar.connect" export { machine } from "./avatar.machine" -export type { UserDefinedContext as Context, PublicApi } from "./avatar.types" +export type { UserDefinedContext as Context, MachineApi as Api } from "./avatar.types" From 4d056e7d136a3575c21e5b18406b7fd10bb49fc5 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 20:48:21 +0100 Subject: [PATCH 03/45] refactor: color picker --- .../color-picker/src/color-picker.connect.ts | 4 +- .../color-picker/src/color-picker.machine.ts | 88 ++++++++++--------- .../color-picker/src/color-picker.types.ts | 2 +- packages/machines/color-picker/src/index.ts | 2 +- .../src/utils/get-channel-input-value.ts | 4 +- 5 files changed, 55 insertions(+), 45 deletions(-) diff --git a/packages/machines/color-picker/src/color-picker.connect.ts b/packages/machines/color-picker/src/color-picker.connect.ts index b95fb079ee..1ffd703399 100644 --- a/packages/machines/color-picker/src/color-picker.connect.ts +++ b/packages/machines/color-picker/src/color-picker.connect.ts @@ -18,7 +18,7 @@ import type { ColorChannelInputProps, ColorChannelProps, ColorSwatchProps, - PublicApi, + MachineApi, Send, State, } from "./color-picker.types" @@ -28,7 +28,7 @@ import { getChannelInputRange, getChannelInputValue } from "./utils/get-channel- import { getColorAreaGradient } from "./utils/get-color-area-gradient" import { getSliderBgImage } from "./utils/get-slider-background" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const valueAsColor = state.context.valueAsColor const value = state.context.value const isDisabled = state.context.disabled diff --git a/packages/machines/color-picker/src/color-picker.machine.ts b/packages/machines/color-picker/src/color-picker.machine.ts index 328e98b455..2a913dc8ac 100644 --- a/packages/machines/color-picker/src/color-picker.machine.ts +++ b/packages/machines/color-picker/src/color-picker.machine.ts @@ -13,7 +13,6 @@ import { getChannelInputValue } from "./utils/get-channel-input-value" export function machine(userContext: UserDefinedContext) { const ctx = compact(userContext) - return createMachine( { id: "color-picker", @@ -42,7 +41,7 @@ export function machine(userContext: UserDefinedContext) { activities: ["trackFormControl"], watch: { - value: ["syncInputElements", "invokeOnChange"], + value: ["syncInputElements"], }, states: { @@ -217,7 +216,7 @@ export function machine(userContext: UserDefinedContext) { .then(({ sRGBHex }: { sRGBHex: string }) => { const format = ctx.valueAsColor.getColorSpace() const color = parseColor(sRGBHex).toFormat(format) - setColor(ctx, color) + set.value(ctx, color) ctx.onChangeEnd?.({ value: ctx.value, valueAsColor: color }) }) .catch(() => void 0) @@ -246,7 +245,7 @@ export function machine(userContext: UserDefinedContext) { const color = getColorFromPoint(percent.x, percent.y) if (!color) return - setColor(ctx, color) + set.value(ctx, color) }, setChannelColorFromPoint(ctx, evt) { const channel = evt.channel || ctx.activeId @@ -263,24 +262,24 @@ export function machine(userContext: UserDefinedContext) { const value = snapValueToStep(channelValue - step, minValue, maxValue, step) const newColor = ctx.valueAsColor.withChannelValue(channel, value) - setColor(ctx, newColor) + set.value(ctx, newColor) }, setValue(ctx, evt) { - setColor(ctx, evt.value) + set.value(ctx, evt.value) }, syncInputElements(ctx) { // sync channel inputs const inputs = dom.getChannelInputEls(ctx) inputs.forEach((input) => { const channel = input.dataset.channel as ExtendedColorChannel | null - if (!channel) return - input.value = getChannelInputValue(ctx.valueAsColor, channel) + dom.setValue(input, getChannelInputValue(ctx.valueAsColor, channel)) }) // sync hidden input - const hiddenInput = dom.getHiddenInputEl(ctx) - if (!hiddenInput) return - hiddenInput.value = ctx.value + dom.setValue(dom.getHiddenInputEl(ctx), ctx.value) + }, + invokeOnChangeEnd(ctx) { + invoke.changeEnd(ctx) }, setChannelColorFromInput(ctx, evt) { const { channel, isTextField, value } = evt @@ -291,13 +290,12 @@ export function machine(userContext: UserDefinedContext) { ? parseColor(value).toFormat(format) : ctx.valueAsColor.withChannelValue(channel, value) - setColor(ctx, newColor) + set.value(ctx, newColor) // } catch { // reset input value - const input = dom.getChannelInputEl(ctx, channel) - if (!input) return - input.value = getChannelInputValue(ctx.valueAsColor, channel) + const inputEl = dom.getChannelInputEl(ctx, channel) + dom.setValue(inputEl, getChannelInputValue(ctx.valueAsColor, channel)) } }, @@ -305,59 +303,52 @@ export function machine(userContext: UserDefinedContext) { const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(evt.channel) const channelValue = ctx.valueAsColor.getChannelValue(evt.channel) const value = snapValueToStep(channelValue + evt.step, minValue, maxValue, step) - const newColor = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue)) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue)) + set.value(ctx, color) }, decrementChannel(ctx, evt) { const { minValue, maxValue, step } = ctx.valueAsColor.getChannelRange(evt.channel) const channelValue = ctx.valueAsColor.getChannelValue(evt.channel) const value = snapValueToStep(channelValue - evt.step, minValue, maxValue, step) - const newColor = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue)) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(evt.channel, clampValue(value, minValue, maxValue)) + set.value(ctx, color) }, incrementXChannel(ctx, evt) { const { xChannel, yChannel } = evt.channel const { incrementX } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const newColor = ctx.valueAsColor.withChannelValue(xChannel, incrementX(evt.step)) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(xChannel, incrementX(evt.step)) + set.value(ctx, color) }, decrementXChannel(ctx, evt) { const { xChannel, yChannel } = evt.channel const { decrementX } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const newColor = ctx.valueAsColor.withChannelValue(xChannel, decrementX(evt.step)) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(xChannel, decrementX(evt.step)) + set.value(ctx, color) }, incrementYChannel(ctx, evt) { const { xChannel, yChannel } = evt.channel const { incrementY } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const newColor = ctx.valueAsColor.withChannelValue(yChannel, incrementY(evt.step)) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(yChannel, incrementY(evt.step)) + set.value(ctx, color) }, decrementYChannel(ctx, evt) { const { xChannel, yChannel } = evt.channel const { decrementY } = getChannelDetails(ctx.valueAsColor, xChannel, yChannel) - const newColor = ctx.valueAsColor.withChannelValue(yChannel, decrementY(evt.step)) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(yChannel, decrementY(evt.step)) + set.value(ctx, color) }, setChannelToMax(ctx, evt) { const { maxValue } = ctx.valueAsColor.getChannelRange(evt.channel) - const newColor = ctx.valueAsColor.withChannelValue(evt.channel, maxValue) - setColor(ctx, newColor) + const color = ctx.valueAsColor.withChannelValue(evt.channel, maxValue) + set.value(ctx, color) }, setChannelToMin(ctx, evt) { const { minValue } = ctx.valueAsColor.getChannelRange(evt.channel) - const newColor = ctx.valueAsColor.withChannelValue(evt.channel, minValue) - setColor(ctx, newColor) - }, - - invokeOnChangeEnd(ctx) { - ctx.onChangeEnd?.({ value: ctx.value, valueAsColor: ctx.valueAsColor }) - }, - invokeOnChange(ctx) { - ctx.onChange?.({ value: ctx.value, valueAsColor: ctx.valueAsColor }) + const color = ctx.valueAsColor.withChannelValue(evt.channel, minValue) + set.value(ctx, color) }, focusAreaThumb(ctx) { raf(() => { @@ -374,6 +365,23 @@ export function machine(userContext: UserDefinedContext) { ) } -const setColor = (ctx: MachineContext, color: Color) => { - ctx.value = color.toString("css") +const getDetails = (ctx: MachineContext) => ({ + value: ctx.value, + valueAsColor: ctx.valueAsColor, +}) + +const invoke = { + changeEnd(ctx: MachineContext) { + ctx.onChangeEnd?.(getDetails(ctx)) + }, + change(ctx: MachineContext) { + ctx.onChange?.(getDetails(ctx)) + }, +} + +const set = { + value(ctx: MachineContext, color: Color) { + ctx.value = color.toString("css") + invoke.change(ctx) + }, } diff --git a/packages/machines/color-picker/src/color-picker.types.ts b/packages/machines/color-picker/src/color-picker.types.ts index e8c910ecb3..5d39aad005 100644 --- a/packages/machines/color-picker/src/color-picker.types.ts +++ b/packages/machines/color-picker/src/color-picker.types.ts @@ -118,7 +118,7 @@ export type State = S.State export type Send = S.Send -export type PublicApi = { +export type MachineApi = { /** * Whether the color picker is being dragged */ diff --git a/packages/machines/color-picker/src/index.ts b/packages/machines/color-picker/src/index.ts index 59d74fed9d..6f51016b66 100644 --- a/packages/machines/color-picker/src/index.ts +++ b/packages/machines/color-picker/src/index.ts @@ -12,5 +12,5 @@ export type { ColorSwatchProps, ColorType, UserDefinedContext as Context, - PublicApi, + MachineApi as Api, } from "./color-picker.types" diff --git a/packages/machines/color-picker/src/utils/get-channel-input-value.ts b/packages/machines/color-picker/src/utils/get-channel-input-value.ts index 3f1230a631..d97bc88402 100644 --- a/packages/machines/color-picker/src/utils/get-channel-input-value.ts +++ b/packages/machines/color-picker/src/utils/get-channel-input-value.ts @@ -1,7 +1,9 @@ import type { Color } from "@zag-js/color-utils" import type { ExtendedColorChannel } from "../color-picker.types" -export function getChannelInputValue(color: Color, channel: ExtendedColorChannel) { +export function getChannelInputValue(color: Color, channel: ExtendedColorChannel | null | undefined) { + if (channel == null) return + switch (channel) { case "hex": return color.toString("hex") From 1ad1560d451ee7bdf6ed7fd290fa45dccd22210c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 21:25:36 +0100 Subject: [PATCH 04/45] refactor: pagination --- packages/machines/pagination/src/index.ts | 7 +- .../pagination/src/pagination.connect.ts | 18 +-- .../pagination/src/pagination.machine.ts | 23 ++-- .../pagination/src/pagination.types.ts | 103 +++++++++--------- 4 files changed, 80 insertions(+), 71 deletions(-) diff --git a/packages/machines/pagination/src/index.ts b/packages/machines/pagination/src/index.ts index b23bd373d3..27c42a5c78 100644 --- a/packages/machines/pagination/src/index.ts +++ b/packages/machines/pagination/src/index.ts @@ -1,4 +1,9 @@ export { anatomy } from "./pagination.anatomy" export { connect } from "./pagination.connect" export { machine } from "./pagination.machine" -export type { UserDefinedContext as Context, EllipsisProps, PageTriggerProps, PublicApi } from "./pagination.types" +export type { + UserDefinedContext as Context, + EllipsisProps, + PageTriggerProps, + MachineApi as Api, +} from "./pagination.types" diff --git a/packages/machines/pagination/src/pagination.connect.ts b/packages/machines/pagination/src/pagination.connect.ts index bf43a69052..248bb2e4a8 100644 --- a/packages/machines/pagination/src/pagination.connect.ts +++ b/packages/machines/pagination/src/pagination.connect.ts @@ -2,10 +2,10 @@ import { dataAttr } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./pagination.anatomy" import { dom } from "./pagination.dom" -import type { EllipsisProps, PageTriggerProps, PublicApi, Send, State } from "./pagination.types" +import type { EllipsisProps, PageTriggerProps, MachineApi, Send, State } from "./pagination.types" import { utils } from "./pagination.utils" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const totalPages = state.context.totalPages const page = state.context.page const translations = state.context.translations @@ -43,7 +43,7 @@ export function connect(state: State, send: Send, normalize }, setPage(page: number) { - send({ type: "SET_PAGE", page, srcElement: null }) + send({ type: "SET_PAGE", page }) }, rootProps: normalize.element({ @@ -69,8 +69,8 @@ export function connect(state: State, send: Send, normalize "data-selected": dataAttr(isCurrentPage), "aria-current": isCurrentPage ? "page" : undefined, "aria-label": translations.pageTriggerLabel?.({ page: index, totalPages }), - onClick(evt) { - send({ type: "SET_PAGE", page: index, srcElement: evt.currentTarget }) + onClick() { + send({ type: "SET_PAGE", page: index }) }, ...(isButton && { type: "button" }), }) @@ -81,8 +81,8 @@ export function connect(state: State, send: Send, normalize ...parts.prevPageTrigger.attrs, "data-disabled": dataAttr(isFirstPage), "aria-label": translations.prevPageTriggerLabel, - onClick(evt) { - send({ type: "PREVIOUS_PAGE", srcElement: evt.currentTarget }) + onClick() { + send({ type: "PREVIOUS_PAGE" }) }, ...(isButton && { disabled: isFirstPage, type: "button" }), }), @@ -92,8 +92,8 @@ export function connect(state: State, send: Send, normalize ...parts.nextPageTrigger.attrs, "data-disabled": dataAttr(isLastPage), "aria-label": translations.nextPageTriggerLabel, - onClick(evt) { - send({ type: "NEXT_PAGE", srcElement: evt.currentTarget }) + onClick() { + send({ type: "NEXT_PAGE" }) }, ...(isButton && { disabled: isLastPage, type: "button" }), }), diff --git a/packages/machines/pagination/src/pagination.machine.ts b/packages/machines/pagination/src/pagination.machine.ts index eef73ca74d..d0dece5a07 100644 --- a/packages/machines/pagination/src/pagination.machine.ts +++ b/packages/machines/pagination/src/pagination.machine.ts @@ -27,7 +27,6 @@ export function machine(userContext: UserDefinedContext) { }, watch: { - page: ["invokeOnChange"], pageSize: ["setPageIfNeeded"], }, @@ -91,27 +90,27 @@ export function machine(userContext: UserDefinedContext) { setPageSize(ctx, evt) { ctx.pageSize = evt.size }, - invokeOnChange(ctx, evt) { - ctx.onChange?.({ - page: ctx.page, - pageSize: ctx.pageSize, - srcElement: evt.srcElement || null, - }) - }, goToFirstPage(ctx) { - ctx.page = 1 + set.page(ctx, 1) }, goToPrevPage(ctx) { - ctx.page = ctx.page - 1 + set.page(ctx, ctx.page - 1) }, goToNextPage(ctx) { - ctx.page = ctx.page + 1 + set.page(ctx, ctx.page + 1) }, setPageIfNeeded(ctx, _evt) { if (ctx.isValidPage) return - ctx.page = 1 + set.page(ctx, 1) }, }, }, ) } + +const set = { + page: (ctx: MachineContext, value: number) => { + ctx.page = value + ctx.onChange?.({ page: ctx.page, pageSize: ctx.pageSize }) + }, +} diff --git a/packages/machines/pagination/src/pagination.types.ts b/packages/machines/pagination/src/pagination.types.ts index e8745cd43d..d309d2961a 100644 --- a/packages/machines/pagination/src/pagination.types.ts +++ b/packages/machines/pagination/src/pagination.types.ts @@ -25,6 +25,11 @@ type ElementIds = Partial<{ pageTrigger(page: number): string }> +export type ChangeDetails = { + page: number + pageSize: number +} + export type PaginationRange = ({ type: "ellipsis" } | { type: "page"; value: number })[] type PublicContext = DirectionProperty & @@ -56,7 +61,7 @@ type PublicContext = DirectionProperty & /** * Called when the page number is changed, and it takes the resulting page number argument */ - onChange?: (details: { page: number; pageSize: number; srcElement: HTMLElement | null }) => void + onChange?: (details: ChangeDetails) => void /** * The type of the trigger element * @default "button" @@ -64,7 +69,54 @@ type PublicContext = DirectionProperty & type: "button" | "link" } -export type PublicApi = { +type PrivateContext = Context<{}> + +type ComputedContext = Readonly<{ + /** + * @computed + * Total number of pages + */ + totalPages: number + /** + * @computed + * Pages to render in pagination + */ + items: PaginationRange + /** + * @computed + * Index of first and last data items on current page + */ + pageRange: { start: number; end: number } + /** + * @computed + * The previous page index + */ + previousPage: number | null + /** + * @computed + * The next page index + */ + nextPage: number | null + /** + * @computed + * Whether the current page is valid + */ + isValidPage: boolean +}> + +export type UserDefinedContext = RequiredBy + +export type MachineContext = PublicContext & PrivateContext & ComputedContext + +export type MachineState = { + value: "idle" +} + +export type State = S.State + +export type Send = S.Send + +export type MachineApi = { /** * The current page. */ @@ -122,50 +174,3 @@ export type PublicApi = { prevPageTriggerProps: T["element"] nextPageTriggerProps: T["element"] } - -type PrivateContext = Context<{}> - -type ComputedContext = Readonly<{ - /** - * @computed - * Total number of pages - */ - totalPages: number - /** - * @computed - * Pages to render in pagination - */ - items: PaginationRange - /** - * @computed - * Index of first and last data items on current page - */ - pageRange: { start: number; end: number } - /** - * @computed - * The previous page index - */ - previousPage: number | null - /** - * @computed - * The next page index - */ - nextPage: number | null - /** - * @computed - * Whether the current page is valid - */ - isValidPage: boolean -}> - -export type UserDefinedContext = RequiredBy - -export type MachineContext = PublicContext & PrivateContext & ComputedContext - -export type MachineState = { - value: "idle" -} - -export type State = S.State - -export type Send = S.Send From 77078f9d5c95be9d37b3ca376f3f872e33ab9211 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 21:44:11 +0100 Subject: [PATCH 05/45] refactor: pin input --- .xstate/pin-input.js | 22 +-- packages/machines/pin-input/src/index.ts | 2 +- .../pin-input/src/pin-input.connect.ts | 6 +- .../machines/pin-input/src/pin-input.dom.ts | 6 +- .../pin-input/src/pin-input.machine.ts | 136 ++++++++++-------- .../machines/pin-input/src/pin-input.types.ts | 72 +++++----- 6 files changed, 130 insertions(+), 114 deletions(-) diff --git a/.xstate/pin-input.js b/.xstate/pin-input.js index 3ec0108342..30214da78b 100644 --- a/.xstate/pin-input.js +++ b/.xstate/pin-input.js @@ -27,15 +27,15 @@ const fetchMachine = createMachine({ on: { SET_VALUE: [{ cond: "hasIndex", - actions: ["setValueAtIndex", "invokeOnChange"] + actions: ["setValueAtIndex"] }, { - actions: ["setValue", "invokeOnChange"] + actions: ["setValue"] }], CLEAR_VALUE: [{ cond: "isDisabled", - actions: ["clearValue", "invokeOnChange"] + actions: ["clearValue"] }, { - actions: ["clearValue", "invokeOnChange", "setFocusIndexToFirst"] + actions: ["clearValue", "setFocusIndexToFirst"] }] }, on: { @@ -56,16 +56,16 @@ const fetchMachine = createMachine({ on: { INPUT: [{ cond: "isFinalValue && isValidValue", - actions: ["setFocusedValue", "invokeOnChange", "syncInputValue"] + actions: ["setFocusedValue", "syncInputValue"] }, { cond: "isValidValue", - actions: ["setFocusedValue", "invokeOnChange", "setNextFocusedIndex", "syncInputValue"] + actions: ["setFocusedValue", "setNextFocusedIndex", "syncInputValue"] }], PASTE: [{ cond: "isValidValue", - actions: ["setPastedValue", "invokeOnChange", "setLastValueFocusIndex"] + actions: ["setPastedValue", "setLastValueFocusIndex"] }, { - actions: ["resetFocusedValue", "invokeOnChange"] + actions: ["revertInputValue"] }], BLUR: { target: "idle", @@ -73,7 +73,7 @@ const fetchMachine = createMachine({ }, DELETE: { cond: "hasValue", - actions: ["clearFocusedValue", "invokeOnChange"] + actions: ["clearFocusedValue"] }, ARROW_LEFT: { actions: "setPrevFocusedIndex" @@ -83,9 +83,9 @@ const fetchMachine = createMachine({ }, BACKSPACE: [{ cond: "hasValue", - actions: ["clearFocusedValue", "invokeOnChange"] + actions: ["clearFocusedValue"] }, { - actions: ["setPrevFocusedIndex", "clearFocusedValue", "invokeOnChange"] + actions: ["setPrevFocusedIndex", "clearFocusedValue"] }], ENTER: { cond: "isValueComplete", diff --git a/packages/machines/pin-input/src/index.ts b/packages/machines/pin-input/src/index.ts index 24464452d0..e89903e479 100644 --- a/packages/machines/pin-input/src/index.ts +++ b/packages/machines/pin-input/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./pin-input.anatomy" export { connect } from "./pin-input.connect" export { machine } from "./pin-input.machine" -export type { UserDefinedContext as Context, PublicApi } from "./pin-input.types" +export type { UserDefinedContext as Context, MachineApi as Api } from "./pin-input.types" diff --git a/packages/machines/pin-input/src/pin-input.connect.ts b/packages/machines/pin-input/src/pin-input.connect.ts index d790352e5e..b0ccfc7999 100644 --- a/packages/machines/pin-input/src/pin-input.connect.ts +++ b/packages/machines/pin-input/src/pin-input.connect.ts @@ -1,13 +1,13 @@ -import { type EventKeyMap, getEventKey, getNativeEvent, isModifiedEvent } from "@zag-js/dom-event" +import { getEventKey, getNativeEvent, isModifiedEvent, type EventKeyMap } from "@zag-js/dom-event" import { ariaAttr, dataAttr } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { invariant } from "@zag-js/utils" import { visuallyHiddenStyle } from "@zag-js/visually-hidden" import { parts } from "./pin-input.anatomy" import { dom } from "./pin-input.dom" -import type { PublicApi, Send, State } from "./pin-input.types" +import type { MachineApi, Send, State } from "./pin-input.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isValueComplete = state.context.isValueComplete const isInvalid = state.context.invalid const focusedIndex = state.context.focusedIndex diff --git a/packages/machines/pin-input/src/pin-input.dom.ts b/packages/machines/pin-input/src/pin-input.dom.ts index b3cc404042..704ba6c3c9 100644 --- a/packages/machines/pin-input/src/pin-input.dom.ts +++ b/packages/machines/pin-input/src/pin-input.dom.ts @@ -9,13 +9,13 @@ export const dom = createScope({ getControlId: (ctx: Ctx) => ctx.ids?.control ?? `pin-input:${ctx.id}:control`, getRootEl: (ctx: Ctx) => dom.getById(ctx, dom.getRootId(ctx)), - getElements: (ctx: Ctx) => { + getInputEls: (ctx: Ctx) => { const ownerId = CSS.escape(dom.getRootId(ctx)) const selector = `input[data-ownedby=${ownerId}]` return queryAll(dom.getRootEl(ctx), selector) }, getInputEl: (ctx: Ctx, id: string) => dom.getById(ctx, dom.getInputId(ctx, id)), - getFocusedInputEl: (ctx: Ctx) => dom.getElements(ctx)[ctx.focusedIndex], - getFirstInputEl: (ctx: Ctx) => dom.getElements(ctx)[0], + getFocusedInputEl: (ctx: Ctx) => dom.getInputEls(ctx)[ctx.focusedIndex], + getFirstInputEl: (ctx: Ctx) => dom.getInputEls(ctx)[0], getHiddenInputEl: (ctx: Ctx) => dom.getById(ctx, dom.getHiddenInputId(ctx)), }) diff --git a/packages/machines/pin-input/src/pin-input.machine.ts b/packages/machines/pin-input/src/pin-input.machine.ts index 661c12002f..8f37dfc9c1 100644 --- a/packages/machines/pin-input/src/pin-input.machine.ts +++ b/packages/machines/pin-input/src/pin-input.machine.ts @@ -35,8 +35,8 @@ export function machine(userContext: UserDefinedContext) { }, watch: { - focusedIndex: ["focusInput", "setInputSelection"], - value: ["dispatchInputEvent", "syncInputElements"], + focusedIndex: ["focusInput", "selectInputIfNeeded"], + value: ["syncInputElements"], isValueComplete: ["invokeOnComplete", "blurFocusedInputIfNeeded"], }, @@ -46,17 +46,17 @@ export function machine(userContext: UserDefinedContext) { SET_VALUE: [ { guard: "hasIndex", - actions: ["setValueAtIndex", "invokeOnChange"], + actions: ["setValueAtIndex"], }, - { actions: ["setValue", "invokeOnChange"] }, + { actions: ["setValue"] }, ], CLEAR_VALUE: [ { guard: "isDisabled", - actions: ["clearValue", "invokeOnChange"], + actions: ["clearValue"], }, { - actions: ["clearValue", "invokeOnChange", "setFocusIndexToFirst"], + actions: ["clearValue", "setFocusIndexToFirst"], }, ], }, @@ -75,19 +75,19 @@ export function machine(userContext: UserDefinedContext) { INPUT: [ { guard: and("isFinalValue", "isValidValue"), - actions: ["setFocusedValue", "invokeOnChange", "syncInputValue"], + actions: ["setFocusedValue", "syncInputValue"], }, { guard: "isValidValue", - actions: ["setFocusedValue", "invokeOnChange", "setNextFocusedIndex", "syncInputValue"], + actions: ["setFocusedValue", "setNextFocusedIndex", "syncInputValue"], }, ], PASTE: [ { guard: "isValidValue", - actions: ["setPastedValue", "invokeOnChange", "setLastValueFocusIndex"], + actions: ["setPastedValue", "setLastValueFocusIndex"], }, - { actions: ["resetFocusedValue", "invokeOnChange"] }, + { actions: ["revertInputValue"] }, ], BLUR: { target: "idle", @@ -95,7 +95,7 @@ export function machine(userContext: UserDefinedContext) { }, DELETE: { guard: "hasValue", - actions: ["clearFocusedValue", "invokeOnChange"], + actions: ["clearFocusedValue"], }, ARROW_LEFT: { actions: "setPrevFocusedIndex", @@ -106,10 +106,10 @@ export function machine(userContext: UserDefinedContext) { BACKSPACE: [ { guard: "hasValue", - actions: ["clearFocusedValue", "invokeOnChange"], + actions: ["clearFocusedValue"], }, { - actions: ["setPrevFocusedIndex", "clearFocusedValue", "invokeOnChange"], + actions: ["setPrevFocusedIndex", "clearFocusedValue"], }, ], ENTER: { @@ -130,12 +130,12 @@ export function machine(userContext: UserDefinedContext) { isValueEmpty: (_ctx, evt) => evt.value === "", hasValue: (ctx) => ctx.value[ctx.focusedIndex] !== "", isValueComplete: (ctx) => ctx.isValueComplete, - isValidValue: (ctx, evt) => { + isValidValue(ctx, evt) { if (!ctx.pattern) return isValidType(evt.value, ctx.type) const regex = new RegExp(ctx.pattern, "g") return regex.test(evt.value) }, - isFinalValue: (ctx) => { + isFinalValue(ctx) { return ( ctx.filledValueLength + 1 === ctx.valueLength && ctx.value.findIndex((v) => v.trim() === "") === ctx.focusedIndex @@ -146,19 +146,19 @@ export function machine(userContext: UserDefinedContext) { isDisabled: (ctx) => !!ctx.disabled, }, actions: { - setupValue: (ctx) => { + setupValue(ctx) { if (ctx.value.length) return - const inputs = dom.getElements(ctx) + const inputs = dom.getInputEls(ctx) const emptyValues = Array.from({ length: inputs.length }).fill("") - assign(ctx, emptyValues) + assignValue(ctx, emptyValues) }, - focusInput: (ctx) => { + focusInput(ctx) { raf(() => { if (ctx.focusedIndex === -1) return dom.getFocusedInputEl(ctx)?.focus() }) }, - setInputSelection: (ctx) => { + selectInputIfNeeded(ctx) { raf(() => { if (ctx.focusedIndex === -1) return const input = dom.getFocusedInputEl(ctx) @@ -167,74 +167,68 @@ export function machine(userContext: UserDefinedContext) { input.selectionEnd = length }) }, - invokeOnComplete: (ctx) => { + invokeOnComplete(ctx) { if (!ctx.isValueComplete) return ctx.onComplete?.({ value: Array.from(ctx.value), valueAsString: ctx.valueAsString }) }, - invokeOnChange: (ctx) => { - ctx.onChange?.({ value: Array.from(ctx.value) }) - }, - dispatchInputEvent: (ctx) => { - const inputEl = dom.getHiddenInputEl(ctx) - dispatchInputValueEvent(inputEl, { value: ctx.valueAsString }) - }, - invokeOnInvalid: (ctx, evt) => { + invokeOnInvalid(ctx, evt) { ctx.onInvalid?.({ value: evt.value, index: ctx.focusedIndex }) }, - clearFocusedIndex: (ctx) => { + clearFocusedIndex(ctx) { ctx.focusedIndex = -1 }, - setValue: (ctx, evt) => { - assign(ctx, evt.value) - }, - setFocusedIndex: (ctx, evt) => { + setFocusedIndex(ctx, evt) { ctx.focusedIndex = evt.index }, - setFocusedValue: (ctx, evt) => { - ctx.value[ctx.focusedIndex] = getNextValue(ctx.focusedValue, evt.value) + setValue(ctx, evt) { + set.value(ctx, evt.value) + }, + setFocusedValue(ctx, evt) { + const nextValue = getNextValue(ctx.focusedValue, evt.value) + set.valueAtIndex(ctx, ctx.focusedIndex, nextValue) + }, + revertInputValue(ctx) { + const inputEl = dom.getFocusedInputEl(ctx) + dom.setValue(inputEl, ctx.focusedValue) }, syncInputValue(ctx, evt) { - const input = dom.getInputEl(ctx, evt.index.toString()) - if (!input) return - input.value = ctx.value[evt.index] + const inputEl = dom.getInputEl(ctx, evt.index.toString()) + dom.setValue(inputEl, ctx.value[evt.index]) }, syncInputElements(ctx) { - const inputs = dom.getElements(ctx) - inputs.forEach((input, index) => { - input.value = ctx.value[index] + const inputEls = dom.getInputEls(ctx) + inputEls.forEach((inputEl, index) => { + dom.setValue(inputEl, ctx.value[index]) }) }, setPastedValue(ctx, evt) { raf(() => { const startIndex = ctx.focusedValue ? 1 : 0 const value = evt.value.substring(startIndex, startIndex + ctx.valueLength) - assign(ctx, value) + set.value(ctx, value) }) }, - setValueAtIndex: (ctx, evt) => { - ctx.value[evt.index] = getNextValue(ctx.focusedValue, evt.value) + setValueAtIndex(ctx, evt) { + const nextValue = getNextValue(ctx.focusedValue, evt.value) + set.valueAtIndex(ctx, evt.index, nextValue) }, - clearValue: (ctx) => { + clearValue(ctx) { const nextValue = Array.from({ length: ctx.valueLength }).fill("") - assign(ctx, nextValue) - }, - clearFocusedValue: (ctx) => { - ctx.value[ctx.focusedIndex] = "" + set.value(ctx, nextValue) }, - resetFocusedValue: (ctx) => { - const input = dom.getFocusedInputEl(ctx) - input.value = ctx.focusedValue + clearFocusedValue(ctx) { + set.valueAtIndex(ctx, ctx.focusedIndex, "") }, - setFocusIndexToFirst: (ctx) => { + setFocusIndexToFirst(ctx) { ctx.focusedIndex = 0 }, - setNextFocusedIndex: (ctx) => { + setNextFocusedIndex(ctx) { ctx.focusedIndex = Math.min(ctx.focusedIndex + 1, ctx.valueLength - 1) }, - setPrevFocusedIndex: (ctx) => { + setPrevFocusedIndex(ctx) { ctx.focusedIndex = Math.max(ctx.focusedIndex - 1, 0) }, - setLastValueFocusIndex: (ctx) => { + setLastValueFocusIndex(ctx) { raf(() => { ctx.focusedIndex = Math.min(ctx.filledValueLength, ctx.valueLength - 1) }) @@ -250,8 +244,8 @@ export function machine(userContext: UserDefinedContext) { }, requestFormSubmit(ctx) { if (!ctx.name || !ctx.isValueComplete) return - const input = dom.getHiddenInputEl(ctx) - input?.form?.requestSubmit() + const inputEl = dom.getHiddenInputEl(ctx) + inputEl?.form?.requestSubmit() }, }, }, @@ -269,7 +263,7 @@ function isValidType(value: string, type: MachineContext["type"]) { return !!REGEX[type]?.test(value) } -function assign(ctx: MachineContext, value: string | string[]) { +function assignValue(ctx: MachineContext, value: string | string[]) { const arr = Array.isArray(value) ? value : value.split("").filter(Boolean) arr.forEach((value, index) => { ctx.value[index] = value @@ -282,3 +276,25 @@ function getNextValue(current: string, next: string) { else if (current[0] === next[1]) nextValue = next[0] return nextValue } + +const invoke = { + change(ctx: MachineContext) { + // callback + ctx.onChange?.({ value: Array.from(ctx.value) }) + + // form event + const inputEl = dom.getHiddenInputEl(ctx) + dispatchInputValueEvent(inputEl, { value: ctx.valueAsString }) + }, +} + +const set = { + value(ctx: MachineContext, values: string[]) { + assignValue(ctx, values) + invoke.change(ctx) + }, + valueAtIndex(ctx: MachineContext, index: number, value: string) { + ctx.value[index] = value + invoke.change(ctx) + }, +} diff --git a/packages/machines/pin-input/src/pin-input.types.ts b/packages/machines/pin-input/src/pin-input.types.ts index 70f55f154e..066f313043 100644 --- a/packages/machines/pin-input/src/pin-input.types.ts +++ b/packages/machines/pin-input/src/pin-input.types.ts @@ -90,42 +90,6 @@ type PublicContext = DirectionProperty & translations: IntlTranslations } -export type PublicApi = { - /** - * The value of the input as an array of strings. - */ - value: string[] - /** - * The value of the input as a string. - */ - valueAsString: string - /** - * Whether all inputs are filled. - */ - isValueComplete: boolean - /** - * Function to set the value of the inputs. - */ - setValue(value: string[]): void - /** - * Function to clear the value of the inputs. - */ - clearValue(): void - /** - * Function to set the value of the input at a specific index. - */ - setValueAtIndex(index: number, value: string): void - /** - * Function to focus the pin-input. This will focus the first input. - */ - focus: () => void - rootProps: T["element"] - labelProps: T["label"] - hiddenInputProps: T["input"] - controlProps: T["element"] - getInputProps({ index }: { index: number }): T["input"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -173,3 +137,39 @@ export type MachineState = { export type State = S.State export type Send = S.Send + +export type MachineApi = { + /** + * The value of the input as an array of strings. + */ + value: string[] + /** + * The value of the input as a string. + */ + valueAsString: string + /** + * Whether all inputs are filled. + */ + isValueComplete: boolean + /** + * Function to set the value of the inputs. + */ + setValue(value: string[]): void + /** + * Function to clear the value of the inputs. + */ + clearValue(): void + /** + * Function to set the value of the input at a specific index. + */ + setValueAtIndex(index: number, value: string): void + /** + * Function to focus the pin-input. This will focus the first input. + */ + focus: () => void + rootProps: T["element"] + labelProps: T["label"] + hiddenInputProps: T["input"] + controlProps: T["element"] + getInputProps({ index }: { index: number }): T["input"] +} From cee10906a0550782492d59474595dc3887a0457b Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 21:44:32 +0100 Subject: [PATCH 06/45] chore: update scope --- packages/utilities/dom-query/src/create-scope.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/utilities/dom-query/src/create-scope.ts b/packages/utilities/dom-query/src/create-scope.ts index 37a075e1a1..b2d5550eab 100644 --- a/packages/utilities/dom-query/src/create-scope.ts +++ b/packages/utilities/dom-query/src/create-scope.ts @@ -13,6 +13,10 @@ export function createScope(methods: T) { getActiveElement: (ctx: Ctx) => screen.getDoc(ctx).activeElement as HTMLElement | null, getById: (ctx: Ctx, id: string) => screen.getRootNode(ctx).getElementById(id) as T | null, + setValue: (elem: T | null, value: string | number | null | undefined) => { + if (elem == null || value == null) return + elem.value = value.toString() + }, } return { ...screen, ...methods } } From 0ce2b9b7cf79955991c4b8d6a30fc51644bb86b2 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 21:45:19 +0100 Subject: [PATCH 07/45] refactor: tooltip --- packages/machines/tooltip/src/index.ts | 2 +- .../machines/tooltip/src/tooltip.connect.ts | 4 +- .../machines/tooltip/src/tooltip.types.ts | 48 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/machines/tooltip/src/index.ts b/packages/machines/tooltip/src/index.ts index 667e6e30b0..7d0464219d 100644 --- a/packages/machines/tooltip/src/index.ts +++ b/packages/machines/tooltip/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./tooltip.anatomy" export { connect } from "./tooltip.connect" export { machine } from "./tooltip.machine" -export type { UserDefinedContext as Context, Placement, PositioningOptions, PublicApi } from "./tooltip.types" +export type { UserDefinedContext as Context, Placement, PositioningOptions, MachineApi as Api } from "./tooltip.types" diff --git a/packages/machines/tooltip/src/tooltip.connect.ts b/packages/machines/tooltip/src/tooltip.connect.ts index ed18153029..b945d7fc5a 100644 --- a/packages/machines/tooltip/src/tooltip.connect.ts +++ b/packages/machines/tooltip/src/tooltip.connect.ts @@ -4,9 +4,9 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./tooltip.anatomy" import { dom } from "./tooltip.dom" import { store } from "./tooltip.store" -import type { PublicApi, Send, State } from "./tooltip.types" +import type { MachineApi, Send, State } from "./tooltip.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const id = state.context.id const hasAriaLabel = state.context.hasAriaLabel diff --git a/packages/machines/tooltip/src/tooltip.types.ts b/packages/machines/tooltip/src/tooltip.types.ts index 66848402d3..b167ee1b93 100644 --- a/packages/machines/tooltip/src/tooltip.types.ts +++ b/packages/machines/tooltip/src/tooltip.types.ts @@ -66,30 +66,6 @@ type PublicContext = CommonProperties & { open?: boolean } -export type PublicApi = { - /** - * Whether the tooltip is open. - */ - isOpen: boolean - /** - * Function to open the tooltip. - */ - open(): void - /** - * Function to close the tooltip. - */ - close(): void - /** - * Function to reposition the popover - */ - setPositioning(options?: Partial): void - triggerProps: T["button"] - arrowProps: T["element"] - arrowTipProps: T["element"] - positionerProps: T["element"] - contentProps: T["element"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -124,3 +100,27 @@ export type State = S.State export type Send = S.Send export type { PositioningOptions, Placement } + +export type MachineApi = { + /** + * Whether the tooltip is open. + */ + isOpen: boolean + /** + * Function to open the tooltip. + */ + open(): void + /** + * Function to close the tooltip. + */ + close(): void + /** + * Function to reposition the popover + */ + setPositioning(options?: Partial): void + triggerProps: T["button"] + arrowProps: T["element"] + arrowTipProps: T["element"] + positionerProps: T["element"] + contentProps: T["element"] +} From 345b6fb6355169d7202bbfddfbcdfb496dda9cf9 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 22:10:22 +0100 Subject: [PATCH 08/45] refactor: number input --- .xstate/number-input.js | 19 ++- packages/machines/number-input/src/index.ts | 2 +- .../number-input/src/number-input.connect.ts | 4 +- .../number-input/src/number-input.machine.ts | 94 ++++++------- .../number-input/src/number-input.types.ts | 124 +++++++++--------- 5 files changed, 121 insertions(+), 122 deletions(-) diff --git a/.xstate/number-input.js b/.xstate/number-input.js index a5a02c0fd0..f91bd42a79 100644 --- a/.xstate/number-input.js +++ b/.xstate/number-input.js @@ -13,7 +13,6 @@ const fetchMachine = createMachine({ id: "number-input", initial: "idle", context: { - "clampOnBlur": false, "isInvalidExponential": false, "clampOnBlur && !isInRange && !isEmptyValue": false, "isIncrementHint": false, @@ -24,12 +23,9 @@ const fetchMachine = createMachine({ }, entry: ["syncInputValue"], on: { - SET_VALUE: [{ - cond: "clampOnBlur", + SET_VALUE: { actions: ["setValue", "clampValue", "setHintToSet"] - }, { - actions: ["setValue", "setHintToSet"] - }], + }, CLEAR_VALUE: { actions: ["clearValue"] }, @@ -47,17 +43,19 @@ const fetchMachine = createMachine({ }, states: { idle: { - exit: "invokeOnFocus", on: { PRESS_DOWN: { target: "before:spin", - actions: ["focusInput", "setHint"] + actions: ["focusInput", "invokeOnFocus", "setHint"] }, PRESS_DOWN_SCRUBBER: { target: "scrubbing", - actions: ["focusInput", "setHint", "setCursorPoint"] + actions: ["focusInput", "invokeOnFocus", "setHint", "setCursorPoint"] }, - FOCUS: "focused" + FOCUS: { + target: "focused", + actions: ["focusInput", "invokeOnFocus"] + } } }, focused: { @@ -168,7 +166,6 @@ const fetchMachine = createMachine({ CHANGE_INTERVAL: 50 }, guards: { - "clampOnBlur": ctx => ctx["clampOnBlur"], "isInvalidExponential": ctx => ctx["isInvalidExponential"], "clampOnBlur && !isInRange && !isEmptyValue": ctx => ctx["clampOnBlur && !isInRange && !isEmptyValue"], "isIncrementHint": ctx => ctx["isIncrementHint"], diff --git a/packages/machines/number-input/src/index.ts b/packages/machines/number-input/src/index.ts index 88db377c94..d861067e90 100644 --- a/packages/machines/number-input/src/index.ts +++ b/packages/machines/number-input/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./number-input.anatomy" export { connect } from "./number-input.connect" export { machine } from "./number-input.machine" -export type { UserDefinedContext as Context, PublicApi } from "./number-input.types" +export type { UserDefinedContext as Context, MachineApi as Api } from "./number-input.types" diff --git a/packages/machines/number-input/src/number-input.connect.ts b/packages/machines/number-input/src/number-input.connect.ts index 9538ca38ad..51343c48c8 100644 --- a/packages/machines/number-input/src/number-input.connect.ts +++ b/packages/machines/number-input/src/number-input.connect.ts @@ -4,10 +4,10 @@ import { roundToDevicePixel } from "@zag-js/number-utils" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./number-input.anatomy" import { dom } from "./number-input.dom" -import type { PublicApi, Send, State } from "./number-input.types" +import type { MachineApi, Send, State } from "./number-input.types" import { utils } from "./number-input.utils" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isFocused = state.hasTag("focus") const isInvalid = state.context.isOutOfRange || !!state.context.invalid diff --git a/packages/machines/number-input/src/number-input.machine.ts b/packages/machines/number-input/src/number-input.machine.ts index 93443828f8..bd9f39bc5e 100644 --- a/packages/machines/number-input/src/number-input.machine.ts +++ b/packages/machines/number-input/src/number-input.machine.ts @@ -54,7 +54,6 @@ export function machine(userContext: UserDefinedContext) { }, watch: { - value: ["invokeOnChange", "dispatchChangeEvent"], isOutOfRange: ["invokeOnInvalid"], scrubberCursorPoint: ["setVirtualCursorPosition"], }, @@ -62,15 +61,9 @@ export function machine(userContext: UserDefinedContext) { entry: ["syncInputValue"], on: { - SET_VALUE: [ - { - guard: "clampOnBlur", - actions: ["setValue", "clampValue", "setHintToSet"], - }, - { - actions: ["setValue", "setHintToSet"], - }, - ], + SET_VALUE: { + actions: ["setValue", "clampValue", "setHintToSet"], + }, CLEAR_VALUE: { actions: ["clearValue"], }, @@ -84,17 +77,19 @@ export function machine(userContext: UserDefinedContext) { states: { idle: { - exit: "invokeOnFocus", on: { PRESS_DOWN: { target: "before:spin", - actions: ["focusInput", "setHint"], + actions: ["focusInput", "invokeOnFocus", "setHint"], }, PRESS_DOWN_SCRUBBER: { target: "scrubbing", - actions: ["focusInput", "setHint", "setCursorPoint"], + actions: ["focusInput", "invokeOnFocus", "setHint", "setCursorPoint"], + }, + FOCUS: { + target: "focused", + actions: ["focusInput", "invokeOnFocus"], }, - FOCUS: "focused", }, }, @@ -290,35 +285,37 @@ export function machine(userContext: UserDefinedContext) { actions: { focusInput(ctx) { if (!ctx.focusInputOnChange) return - const input = dom.getInputEl(ctx) - raf(() => input?.focus()) + const inputEl = dom.getInputEl(ctx) + raf(() => { + inputEl?.focus() + }) }, increment(ctx, evt) { - ctx.value = utils.increment(ctx, evt.step) + set.value(ctx, utils.increment(ctx, evt.step)) }, decrement(ctx, evt) { - ctx.value = utils.decrement(ctx, evt.step) + set.value(ctx, utils.decrement(ctx, evt.step)) }, clampValue(ctx) { - ctx.value = utils.clamp(ctx) + set.value(ctx, utils.clamp(ctx)) }, roundValue(ctx) { - if (ctx.value !== "") { - ctx.value = utils.round(ctx) - } + if (ctx.value === "") return + set.value(ctx, utils.round(ctx)) }, setValue(ctx, evt) { - const value = evt.target?.value ?? evt.value - ctx.value = utils.sanitize(ctx, utils.parse(ctx, value.toString())) + let value = evt.target?.value ?? evt.value + value = utils.sanitize(ctx, utils.parse(ctx, value.toString())) + set.value(ctx, value) }, clearValue(ctx) { - ctx.value = "" + set.value(ctx, "") }, setToMax(ctx) { - ctx.value = ctx.max.toString() + set.value(ctx, ctx.max.toString()) }, setToMin(ctx) { - ctx.value = ctx.min.toString() + set.value(ctx, ctx.min.toString()) }, setHint(ctx, evt) { ctx.hint = evt.hint @@ -329,12 +326,6 @@ export function machine(userContext: UserDefinedContext) { setHintToSet(ctx) { ctx.hint = "set" }, - invokeOnChange(ctx) { - ctx.onChange?.({ - value: ctx.value, - valueAsNumber: ctx.valueAsNumber, - }) - }, invokeOnFocus(ctx, evt) { let srcElement: HTMLElement | null = null @@ -353,10 +344,7 @@ export function machine(userContext: UserDefinedContext) { }) }, invokeOnBlur(ctx) { - ctx.onBlur?.({ - value: ctx.value, - valueAsNumber: ctx.valueAsNumber, - }) + ctx.onBlur?.({ value: ctx.value, valueAsNumber: ctx.valueAsNumber }) }, invokeOnInvalid(ctx) { if (!ctx.isOutOfRange) return @@ -367,12 +355,12 @@ export function machine(userContext: UserDefinedContext) { valueAsNumber: ctx.valueAsNumber, }) }, - // sync input value, in event it was set from form libraries via `ref`, `bind:this`, etc. syncInputValue(ctx) { - const input = dom.getInputEl(ctx) - if (!input || input.value == ctx.value) return - const value = utils.parse(ctx, input.value) - ctx.value = utils.sanitize(ctx, value) + const inputEl = dom.getInputEl(ctx) + if (!inputEl || inputEl.value == ctx.value) return + + const value = utils.parse(ctx, inputEl.value) + set.value(ctx, utils.sanitize(ctx, value)) }, setCursorPoint(ctx, evt) { ctx.scrubberCursorPoint = evt.point @@ -386,11 +374,25 @@ export function machine(userContext: UserDefinedContext) { const { x, y } = ctx.scrubberCursorPoint cursor.style.transform = `translate3d(${x}px, ${y}px, 0px)` }, - dispatchChangeEvent(ctx) { - const inputEl = dom.getInputEl(ctx) - dispatchInputValueEvent(inputEl, { value: ctx.formattedValue }) - }, }, }, ) } + +const invoke = { + onChange: (ctx: MachineContext) => { + // invoke callback + ctx.onChange?.({ value: ctx.value, valueAsNumber: ctx.valueAsNumber }) + + // form event + const inputEl = dom.getInputEl(ctx) + dispatchInputValueEvent(inputEl, { value: ctx.formattedValue }) + }, +} + +const set = { + value: (ctx: MachineContext, value: string) => { + ctx.value = value + invoke.onChange(ctx) + }, +} diff --git a/packages/machines/number-input/src/number-input.types.ts b/packages/machines/number-input/src/number-input.types.ts index 15e4422716..4ce9b579b7 100644 --- a/packages/machines/number-input/src/number-input.types.ts +++ b/packages/machines/number-input/src/number-input.types.ts @@ -154,68 +154,6 @@ type PublicContext = DirectionProperty & spinOnPress?: boolean } -export type PublicApi = { - /** - * Whether the input is focused. - */ - isFocused: boolean - /** - * Whether the input is invalid. - */ - isInvalid: boolean - /** - * Whether the input value is empty. - */ - isValueEmpty: boolean - /** - * The formatted value of the input. - */ - value: string - /** - * The value of the input as a number. - */ - valueAsNumber: number - /** - * Function to set the value of the input. - */ - setValue(value: string | number): void - /** - * Function to clear the value of the input. - */ - clearValue(): void - /** - * Function to increment the value of the input by the step. - */ - increment(): void - /** - * Function to decrement the value of the input by the step. - */ - decrement(): void - /** - * Function to set the value of the input to the max. - */ - setToMax(): void - /** - * Function to set the value of the input to the min. - */ - setToMin(): void - /** - * Function to focus the input. - */ - focus(): void - /** - * Function to blur the input. - */ - blur(): void - rootProps: T["element"] - labelProps: T["label"] - controlProps: T["element"] - inputProps: T["input"] - decrementTriggerProps: T["button"] - incrementTriggerProps: T["button"] - scrubberProps: T["element"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -294,3 +232,65 @@ export type MachineState = { export type State = S.State export type Send = S.Send + +export type MachineApi = { + /** + * Whether the input is focused. + */ + isFocused: boolean + /** + * Whether the input is invalid. + */ + isInvalid: boolean + /** + * Whether the input value is empty. + */ + isValueEmpty: boolean + /** + * The formatted value of the input. + */ + value: string + /** + * The value of the input as a number. + */ + valueAsNumber: number + /** + * Function to set the value of the input. + */ + setValue(value: string | number): void + /** + * Function to clear the value of the input. + */ + clearValue(): void + /** + * Function to increment the value of the input by the step. + */ + increment(): void + /** + * Function to decrement the value of the input by the step. + */ + decrement(): void + /** + * Function to set the value of the input to the max. + */ + setToMax(): void + /** + * Function to set the value of the input to the min. + */ + setToMin(): void + /** + * Function to focus the input. + */ + focus(): void + /** + * Function to blur the input. + */ + blur(): void + rootProps: T["element"] + labelProps: T["label"] + controlProps: T["element"] + inputProps: T["input"] + decrementTriggerProps: T["button"] + incrementTriggerProps: T["button"] + scrubberProps: T["element"] +} From d176d6d75f7d87083f3bf7a255f9411d1b9b353c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Sun, 13 Aug 2023 22:13:09 +0100 Subject: [PATCH 09/45] fix: website demo --- website/components/machines/accordion.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/components/machines/accordion.tsx b/website/components/machines/accordion.tsx index 32145394bd..8c6bb57962 100644 --- a/website/components/machines/accordion.tsx +++ b/website/components/machines/accordion.tsx @@ -31,7 +31,7 @@ type AccordionProps = { export function Accordion(props: AccordionProps) { const [state, send] = useMachine( - accordion.machine({ id: useId(), value: "Aircrafts" }), + accordion.machine({ id: useId(), value: ["Aircrafts"] }), { context: props.controls, }, From aac09468919d3473865f343057e3e577ee0e6959 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:17:34 +0100 Subject: [PATCH 10/45] refactor: carousel --- .../machines/carousel/src/carousel.connect.ts | 4 +- .../machines/carousel/src/carousel.machine.ts | 27 ++++++-- .../machines/carousel/src/carousel.types.ts | 68 +++++++++---------- packages/machines/carousel/src/index.ts | 7 +- 4 files changed, 62 insertions(+), 44 deletions(-) diff --git a/packages/machines/carousel/src/carousel.connect.ts b/packages/machines/carousel/src/carousel.connect.ts index 98d2a5f1d8..2f335ccb8f 100644 --- a/packages/machines/carousel/src/carousel.connect.ts +++ b/packages/machines/carousel/src/carousel.connect.ts @@ -2,10 +2,10 @@ import { dataAttr, isDom } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./carousel.anatomy" import { dom } from "./carousel.dom" -import type { PublicApi, Send, SlideIndicatorProps, SlideProps, State } from "./carousel.types" +import type { MachineApi, Send, SlideIndicatorProps, SlideProps, State } from "./carousel.types" import { getSlidesInView } from "./utils/get-slide-in-view" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const canScrollNext = state.context.canScrollNext const canScrollPrev = state.context.canScrollPrev const isHorizontal = state.context.isHorizontal diff --git a/packages/machines/carousel/src/carousel.machine.ts b/packages/machines/carousel/src/carousel.machine.ts index df4cb49eeb..8238a0e7f9 100644 --- a/packages/machines/carousel/src/carousel.machine.ts +++ b/packages/machines/carousel/src/carousel.machine.ts @@ -25,7 +25,7 @@ export function machine(userContext: UserDefinedContext) { }, watch: { - index: ["invokeOnSlideChange", "setScrollSnaps"], + index: ["setScrollSnaps"], }, on: { @@ -139,14 +139,13 @@ export function machine(userContext: UserDefinedContext) { isFirstSlide: (ctx) => ctx.index === 0, }, actions: { - invokeOnSlideChange(ctx, _evt) { - ctx.onSlideChange?.({ index: ctx.index }) - }, scrollToNext(ctx) { - ctx.index = nextIndex(ctx.slideRects, ctx.index) + const index = nextIndex(ctx.slideRects, ctx.index) + set.index(ctx, index) }, scrollToPrev(ctx) { - ctx.index = prevIndex(ctx.slideRects, ctx.index) + const index = prevIndex(ctx.slideRects, ctx.index) + set.index(ctx, index) }, setScrollSnaps(ctx) { const { snapsAligned, scrollProgress } = getScrollSnaps(ctx) @@ -154,7 +153,8 @@ export function machine(userContext: UserDefinedContext) { ctx.scrollProgress = scrollProgress }, scrollTo(ctx, evt) { - ctx.index = Math.max(0, Math.min(evt.index, ctx.slideRects.length - 1)) + const index = Math.max(0, Math.min(evt.index, ctx.slideRects.length - 1)) + set.index(ctx, index) }, measureElements, }, @@ -169,3 +169,16 @@ const measureElements = (ctx: MachineContext) => { ctx.containerSize = ctx.isHorizontal ? ctx.containerRect.width : ctx.containerRect.height ctx.slideRects = ref(dom.getSlideEls(ctx).map((slide) => slide.getBoundingClientRect())) } + +const invoke = { + slideChange: (ctx: MachineContext) => { + ctx.onSlideChange?.({ index: ctx.index }) + }, +} + +const set = { + index: (ctx: MachineContext, index: number) => { + ctx.index = index + invoke.slideChange(ctx) + }, +} diff --git a/packages/machines/carousel/src/carousel.types.ts b/packages/machines/carousel/src/carousel.types.ts index 7aeda427dd..b4828c60ee 100644 --- a/packages/machines/carousel/src/carousel.types.ts +++ b/packages/machines/carousel/src/carousel.types.ts @@ -60,7 +60,40 @@ type PublicContext = DirectionProperty & ids?: ElementIds } -export type PublicApi = { +type PrivateContext = Context<{ + slideRects: DOMRect[] + containerRect?: DOMRect + containerSize: number + scrollSnaps: number[] + scrollProgress: number +}> + +type RectEdge = "top" | "right" | "bottom" | "left" + +type ComputedContext = Readonly<{ + isRtl: boolean + isHorizontal: boolean + isVertical: boolean + startEdge: RectEdge + endEdge: RectEdge + translateValue: string + canScrollNext: boolean + canScrollPrev: boolean +}> + +export type UserDefinedContext = RequiredBy + +export type MachineContext = PublicContext & PrivateContext & ComputedContext + +export type MachineState = { + value: "idle" | "dragging" | "autoplay" +} + +export type State = S.State + +export type Send = S.Send + +export type MachineApi = { /** * The current index of the carousel */ @@ -120,36 +153,3 @@ export type PublicApi = { indicatorGroupProps: T["element"] getIndicatorProps(props: SlideIndicatorProps): T["button"] } - -type PrivateContext = Context<{ - slideRects: DOMRect[] - containerRect?: DOMRect - containerSize: number - scrollSnaps: number[] - scrollProgress: number -}> - -type RectEdge = "top" | "right" | "bottom" | "left" - -type ComputedContext = Readonly<{ - isRtl: boolean - isHorizontal: boolean - isVertical: boolean - startEdge: RectEdge - endEdge: RectEdge - translateValue: string - canScrollNext: boolean - canScrollPrev: boolean -}> - -export type UserDefinedContext = RequiredBy - -export type MachineContext = PublicContext & PrivateContext & ComputedContext - -export type MachineState = { - value: "idle" | "dragging" | "autoplay" -} - -export type State = S.State - -export type Send = S.Send diff --git a/packages/machines/carousel/src/index.ts b/packages/machines/carousel/src/index.ts index b882ef751d..d939303683 100644 --- a/packages/machines/carousel/src/index.ts +++ b/packages/machines/carousel/src/index.ts @@ -1,4 +1,9 @@ export { anatomy } from "./carousel.anatomy" export { connect } from "./carousel.connect" export { machine } from "./carousel.machine" -export type { UserDefinedContext as Context, PublicApi, SlideIndicatorProps, SlideProps } from "./carousel.types" +export type { + UserDefinedContext as Context, + MachineApi as Api, + SlideIndicatorProps, + SlideProps, +} from "./carousel.types" From 4fa66d399781e19b29a98cf76aa22817ea76f8e3 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:34:21 +0100 Subject: [PATCH 11/45] refactor: checkbox --- .../machines/checkbox/src/checkbox.connect.ts | 4 +- .../machines/checkbox/src/checkbox.machine.ts | 34 ++++++--- .../machines/checkbox/src/checkbox.types.ts | 70 +++++++++---------- packages/machines/checkbox/src/index.ts | 2 +- 4 files changed, 61 insertions(+), 49 deletions(-) diff --git a/packages/machines/checkbox/src/checkbox.connect.ts b/packages/machines/checkbox/src/checkbox.connect.ts index e4af46b821..a5837e3816 100644 --- a/packages/machines/checkbox/src/checkbox.connect.ts +++ b/packages/machines/checkbox/src/checkbox.connect.ts @@ -3,9 +3,9 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" import { visuallyHiddenStyle } from "@zag-js/visually-hidden" import { parts } from "./checkbox.anatomy" import { dom } from "./checkbox.dom" -import type { CheckedState, PublicApi, Send, State } from "./checkbox.types" +import type { CheckedState, MachineApi, Send, State } from "./checkbox.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isDisabled = state.context.disabled const isFocused = !isDisabled && state.context.focused const isChecked = state.context.isChecked diff --git a/packages/machines/checkbox/src/checkbox.machine.ts b/packages/machines/checkbox/src/checkbox.machine.ts index 3e07b800c3..c41b5049db 100644 --- a/packages/machines/checkbox/src/checkbox.machine.ts +++ b/packages/machines/checkbox/src/checkbox.machine.ts @@ -19,7 +19,7 @@ export function machine(userContext: UserDefinedContext) { watch: { disabled: "removeFocusIfNeeded", - checked: ["invokeOnChange", "syncInputElement"], + checked: "syncInputElement", }, activities: ["trackFormControlState"], @@ -60,9 +60,6 @@ export function machine(userContext: UserDefinedContext) { }, actions: { - invokeOnChange(ctx) { - ctx.onChange?.({ checked: ctx.checked }) - }, setContext(ctx, evt) { Object.assign(ctx, evt.context) }, @@ -72,21 +69,17 @@ export function machine(userContext: UserDefinedContext) { inputEl.checked = ctx.isChecked inputEl.indeterminate = ctx.isIndeterminate }, - dispatchCheckedEvent(ctx, evt) { - const inputEl = dom.getHiddenInputEl(ctx) - const checked = isIndeterminate(evt.checked) ? false : evt.checked - dispatchInputCheckedEvent(inputEl, { checked, bubbles: true }) - }, removeFocusIfNeeded(ctx) { if (ctx.disabled && ctx.focused) { ctx.focused = false } }, setChecked(ctx, evt) { - ctx.checked = evt.checked + set.checked(ctx, evt.checked) }, toggleChecked(ctx) { - ctx.checked = isIndeterminate(ctx.checked) ? true : !ctx.checked + const checked = isIndeterminate(ctx.checked) ? true : !ctx.checked + set.checked(ctx, checked) }, }, }, @@ -96,3 +89,22 @@ export function machine(userContext: UserDefinedContext) { function isIndeterminate(checked?: CheckedState): checked is "indeterminate" { return checked === "indeterminate" } + +const invoke = { + change: (ctx: MachineContext) => { + // invoke fn + ctx.onChange?.({ checked: ctx.checked }) + + // form event + const inputEl = dom.getHiddenInputEl(ctx) + const checked = isIndeterminate(ctx.checked) ? false : ctx.checked + dispatchInputCheckedEvent(inputEl, { checked, bubbles: true }) + }, +} + +const set = { + checked: (ctx: MachineContext, checked: CheckedState) => { + ctx.checked = checked + invoke.change(ctx) + }, +} diff --git a/packages/machines/checkbox/src/checkbox.types.ts b/packages/machines/checkbox/src/checkbox.types.ts index 4e70ee39da..264c7e792b 100644 --- a/packages/machines/checkbox/src/checkbox.types.ts +++ b/packages/machines/checkbox/src/checkbox.types.ts @@ -51,41 +51,6 @@ type PublicContext = DirectionProperty & value: string } -export type PublicApi = { - /** - * Whether the checkbox is checked - */ - isChecked: boolean - /** - * Whether the checkbox is disabled - */ - isDisabled: boolean | undefined - /** - * Whether the checkbox is indeterminate - */ - isIndeterminate: boolean - /** - * Whether the checkbox is focused - */ - isFocused: boolean | undefined - /** - * The checked state of the checkbox - */ - checkedState: CheckedState - /** - * Function to set the checked state of the checkbox - */ - setChecked(checked: CheckedState): void - /** - * Function to toggle the checked state of the checkbox - */ - toggleChecked(): void - rootProps: T["label"] - labelProps: T["element"] - controlProps: T["element"] - hiddenInputProps: T["input"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -126,3 +91,38 @@ export type MachineState = { export type State = S.State export type Send = S.Send + +export type MachineApi = { + /** + * Whether the checkbox is checked + */ + isChecked: boolean + /** + * Whether the checkbox is disabled + */ + isDisabled: boolean | undefined + /** + * Whether the checkbox is indeterminate + */ + isIndeterminate: boolean + /** + * Whether the checkbox is focused + */ + isFocused: boolean | undefined + /** + * The checked state of the checkbox + */ + checkedState: CheckedState + /** + * Function to set the checked state of the checkbox + */ + setChecked(checked: CheckedState): void + /** + * Function to toggle the checked state of the checkbox + */ + toggleChecked(): void + rootProps: T["label"] + labelProps: T["element"] + controlProps: T["element"] + hiddenInputProps: T["input"] +} diff --git a/packages/machines/checkbox/src/index.ts b/packages/machines/checkbox/src/index.ts index 44b0017e82..aaeb3e94e8 100644 --- a/packages/machines/checkbox/src/index.ts +++ b/packages/machines/checkbox/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./checkbox.anatomy" export { connect } from "./checkbox.connect" export { machine } from "./checkbox.machine" -export type { CheckedState, UserDefinedContext as Context, PublicApi } from "./checkbox.types" +export type { CheckedState, UserDefinedContext as Context, MachineApi as Api } from "./checkbox.types" From adca9ff15a275a0e68d5aa18e7646981003ff45c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:36:17 +0100 Subject: [PATCH 12/45] refactor: combobox --- .../machines/combobox/src/combobox.connect.ts | 4 +- .../machines/combobox/src/combobox.types.ts | 121 +++++++++--------- packages/machines/combobox/src/index.ts | 2 +- 3 files changed, 65 insertions(+), 62 deletions(-) diff --git a/packages/machines/combobox/src/combobox.connect.ts b/packages/machines/combobox/src/combobox.connect.ts index 1662eb25ff..341b11269a 100644 --- a/packages/machines/combobox/src/combobox.connect.ts +++ b/packages/machines/combobox/src/combobox.connect.ts @@ -9,12 +9,12 @@ import type { OptionGroupLabelProps, OptionGroupProps, OptionProps, - PublicApi, + MachineApi, Send, State, } from "./combobox.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const translations = state.context.translations const isDisabled = state.context.disabled diff --git a/packages/machines/combobox/src/combobox.types.ts b/packages/machines/combobox/src/combobox.types.ts index 260b96c5a3..9ac201ae4a 100644 --- a/packages/machines/combobox/src/combobox.types.ts +++ b/packages/machines/combobox/src/combobox.types.ts @@ -149,65 +149,6 @@ type PublicContext = DirectionProperty & onInteractOutside?: (event: InteractOutsideEvent) => void } -export type PublicApi = { - /** - * Whether the combobox is focused - */ - isFocused: boolean - /** - * Whether the combobox content or listbox is open - */ - isOpen: boolean - /** - * Whether the combobox input is empty - */ - isInputValueEmpty: boolean - /** - * The current value of the combobox input - */ - inputValue: string - /** - * The currently focused option (by pointer or keyboard) - */ - focusedOption: OptionData | null - /** - * The currently selected option value - */ - selectedValue: string | undefined - /** - * Function to set the combobox value - */ - setValue(value: string | OptionData): void - /** - * Function to set the combobox input value - */ - setInputValue(value: string): void - /** - * Function to clear the combobox input value and selected value - */ - clearValue(): void - /** - * Function to focus the combobox input - */ - focus(): void - rootProps: T["element"] - labelProps: T["label"] - controlProps: T["element"] - positionerProps: T["element"] - inputProps: T["input"] - triggerProps: T["button"] - contentProps: T["element"] - clearTriggerProps: T["button"] - getOptionState(props: OptionProps): { - isDisabled: boolean - isHighlighted: boolean - isChecked: boolean - } - getOptionProps(props: OptionProps): T["element"] - getOptionGroupProps(props: OptionGroupProps): T["element"] - getOptionGroupLabelProps(props: OptionGroupLabelProps): T["element"] -} - /** * This is the actual context exposed to the user. */ @@ -330,3 +271,65 @@ export type OptionGroupLabelProps = { } export type { InteractOutsideEvent, Placement, PositioningOptions } + +export type MachineApi = { + /** + * Whether the combobox is focused + */ + isFocused: boolean + /** + * Whether the combobox content or listbox is open + */ + isOpen: boolean + /** + * Whether the combobox input is empty + */ + isInputValueEmpty: boolean + /** + * The current value of the combobox input + */ + inputValue: string + /** + * The currently focused option (by pointer or keyboard) + */ + focusedOption: OptionData | null + /** + * The currently selected option value + */ + selectedValue: string | undefined + /** + * Function to set the combobox value + */ + setValue(value: string | OptionData): void + /** + * Function to set the combobox input value + */ + setInputValue(value: string): void + /** + * Function to clear the combobox input value and selected value + */ + clearValue(): void + /** + * Function to focus the combobox input + */ + focus(): void + rootProps: T["element"] + labelProps: T["label"] + controlProps: T["element"] + positionerProps: T["element"] + inputProps: T["input"] + triggerProps: T["button"] + contentProps: T["element"] + clearTriggerProps: T["button"] + /** + * Returns the state of an option + */ + getOptionState(props: OptionProps): { + isDisabled: boolean + isHighlighted: boolean + isChecked: boolean + } + getOptionProps(props: OptionProps): T["element"] + getOptionGroupProps(props: OptionGroupProps): T["element"] + getOptionGroupLabelProps(props: OptionGroupLabelProps): T["element"] +} diff --git a/packages/machines/combobox/src/index.ts b/packages/machines/combobox/src/index.ts index 3a46f111e6..1ddbd51f52 100644 --- a/packages/machines/combobox/src/index.ts +++ b/packages/machines/combobox/src/index.ts @@ -10,5 +10,5 @@ export type { OptionProps, Placement, PositioningOptions, - PublicApi, + MachineApi as Api, } from "./combobox.types" From f6e10324dbf601419f48b29955a920915f2b0254 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:37:18 +0100 Subject: [PATCH 13/45] refactor: dialog --- .../machines/dialog/src/dialog.connect.ts | 4 +- packages/machines/dialog/src/dialog.types.ts | 44 +++++++++---------- packages/machines/dialog/src/index.ts | 2 +- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/machines/dialog/src/dialog.connect.ts b/packages/machines/dialog/src/dialog.connect.ts index 19d3df8123..b70c8cc5f6 100644 --- a/packages/machines/dialog/src/dialog.connect.ts +++ b/packages/machines/dialog/src/dialog.connect.ts @@ -1,9 +1,9 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./dialog.anatomy" import { dom } from "./dialog.dom" -import type { PublicApi, Send, State } from "./dialog.types" +import type { MachineApi, Send, State } from "./dialog.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const ariaLabel = state.context["aria-label"] const isOpen = state.matches("open") const rendered = state.context.renderedElements diff --git a/packages/machines/dialog/src/dialog.types.ts b/packages/machines/dialog/src/dialog.types.ts index 77257d182c..6a25c8d5c8 100644 --- a/packages/machines/dialog/src/dialog.types.ts +++ b/packages/machines/dialog/src/dialog.types.ts @@ -80,28 +80,6 @@ type PublicContext = DirectionProperty & open?: boolean } -export type PublicApi = { - /** - * Whether the dialog is open - */ - isOpen: boolean - /** - * Function to open the dialog - */ - open(): void - /** - * Function to close the dialog - */ - close(): void - triggerProps: T["button"] - backdropProps: T["element"] - containerProps: T["element"] - contentProps: T["element"] - titleProps: T["element"] - descriptionProps: T["element"] - closeTriggerProps: T["button"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{}> @@ -126,3 +104,25 @@ export type MachineState = { export type State = S.State export type Send = S.Send + +export type MachineApi = { + /** + * Whether the dialog is open + */ + isOpen: boolean + /** + * Function to open the dialog + */ + open(): void + /** + * Function to close the dialog + */ + close(): void + triggerProps: T["button"] + backdropProps: T["element"] + containerProps: T["element"] + contentProps: T["element"] + titleProps: T["element"] + descriptionProps: T["element"] + closeTriggerProps: T["button"] +} diff --git a/packages/machines/dialog/src/index.ts b/packages/machines/dialog/src/index.ts index 7bb60b9490..d65743d55a 100644 --- a/packages/machines/dialog/src/index.ts +++ b/packages/machines/dialog/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./dialog.anatomy" export { connect } from "./dialog.connect" export { machine } from "./dialog.machine" -export type { UserDefinedContext as Context, PublicApi } from "./dialog.types" +export type { MachineApi as Api, UserDefinedContext as Context } from "./dialog.types" From b04e22e47891d162315e4d18462058bf92d6f7ab Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:38:54 +0100 Subject: [PATCH 14/45] refactor: editable --- .../machines/editable/src/editable.connect.ts | 4 +- .../machines/editable/src/editable.machine.ts | 30 ++++--- .../machines/editable/src/editable.types.ts | 88 +++++++++---------- packages/machines/editable/src/index.ts | 2 +- 4 files changed, 67 insertions(+), 57 deletions(-) diff --git a/packages/machines/editable/src/editable.connect.ts b/packages/machines/editable/src/editable.connect.ts index 56cfc0a650..6a869cb17c 100644 --- a/packages/machines/editable/src/editable.connect.ts +++ b/packages/machines/editable/src/editable.connect.ts @@ -3,9 +3,9 @@ import { ariaAttr, dataAttr } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./editable.anatomy" import { dom } from "./editable.dom" -import type { PublicApi, Send, State } from "./editable.types" +import type { MachineApi, Send, State } from "./editable.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isDisabled = state.context.disabled const isInteractive = state.context.isInteractive const isReadOnly = state.context.readOnly diff --git a/packages/machines/editable/src/editable.machine.ts b/packages/machines/editable/src/editable.machine.ts index 188ed8441b..11260ce233 100644 --- a/packages/machines/editable/src/editable.machine.ts +++ b/packages/machines/editable/src/editable.machine.ts @@ -30,7 +30,7 @@ export function machine(userContext: UserDefinedContext) { }, watch: { - value: ["invokeOnChange", "syncInputValue"], + value: ["syncInputValue"], }, computed: { @@ -142,13 +142,13 @@ export function machine(userContext: UserDefinedContext) { }, focusInput(ctx) { raf(() => { - const input = dom.getInputEl(ctx) - if (!input) return + const inputEl = dom.getInputEl(ctx) + if (!inputEl) return if (ctx.selectOnFocus) { - input.select() + inputEl.select() } else { - input.focus({ preventScroll: true }) + inputEl.focus({ preventScroll: true }) } }) }, @@ -161,22 +161,19 @@ export function machine(userContext: UserDefinedContext) { invokeOnEdit(ctx) { ctx.onEdit?.() }, - invokeOnChange(ctx) { - ctx.onChange?.({ value: ctx.value }) - }, syncInputValue(ctx) { const input = dom.getInputEl(ctx) if (!input) return input.value = ctx.value }, setValue(ctx, evt) { - ctx.value = evt.value + set.value(ctx, evt.value) }, setPreviousValue(ctx) { ctx.previousValue = ctx.value }, revertValue(ctx) { - ctx.value = ctx.previousValue + set.value(ctx, ctx.previousValue) }, blurInputIfNeeded(ctx) { dom.getInputEl(ctx)?.blur() @@ -185,3 +182,16 @@ export function machine(userContext: UserDefinedContext) { }, ) } + +const invoke = { + change(ctx: MachineContext) { + ctx.onChange?.({ value: ctx.value }) + }, +} + +const set = { + value(ctx: MachineContext, value: string) { + ctx.value = value + invoke.change(ctx) + }, +} diff --git a/packages/machines/editable/src/editable.types.ts b/packages/machines/editable/src/editable.types.ts index b7e9e18940..d9eb0fa054 100644 --- a/packages/machines/editable/src/editable.types.ts +++ b/packages/machines/editable/src/editable.types.ts @@ -127,50 +127,6 @@ type PublicContext = DirectionProperty & onInteractOutside?: (event: InteractOutsideEvent) => void } -export type PublicApi = { - /** - * Whether the editable is in edit mode - */ - isEditing: boolean - /** - * Whether the editable value is empty - */ - isValueEmpty: boolean - /** - * The current value of the editable - */ - value: string - /** - * Function to set the value of the editable - */ - setValue(value: string): void - /** - * Function to clear the value of the editable - */ - clearValue(): void - /** - * Function to enter edit mode - */ - edit(): void - /** - * Function to exit edit mode, and discard any changes - */ - cancel(): void - /** - * Function to exit edit mode, and submit any changes - */ - submit(): void - rootProps: T["element"] - areaProps: T["element"] - labelProps: T["label"] - inputProps: T["input"] - previewProps: T["element"] - editTriggerProps: T["button"] - controlProps: T["element"] - submitTriggerProps: T["button"] - cancelTriggerProps: T["button"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -220,3 +176,47 @@ export type State = S.State export type Send = S.Send export type { InteractOutsideEvent } + +export type MachineApi = { + /** + * Whether the editable is in edit mode + */ + isEditing: boolean + /** + * Whether the editable value is empty + */ + isValueEmpty: boolean + /** + * The current value of the editable + */ + value: string + /** + * Function to set the value of the editable + */ + setValue(value: string): void + /** + * Function to clear the value of the editable + */ + clearValue(): void + /** + * Function to enter edit mode + */ + edit(): void + /** + * Function to exit edit mode, and discard any changes + */ + cancel(): void + /** + * Function to exit edit mode, and submit any changes + */ + submit(): void + rootProps: T["element"] + areaProps: T["element"] + labelProps: T["label"] + inputProps: T["input"] + previewProps: T["element"] + editTriggerProps: T["button"] + controlProps: T["element"] + submitTriggerProps: T["button"] + cancelTriggerProps: T["button"] +} diff --git a/packages/machines/editable/src/index.ts b/packages/machines/editable/src/index.ts index b8b6fe3ebc..8a4a39d547 100644 --- a/packages/machines/editable/src/index.ts +++ b/packages/machines/editable/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./editable.anatomy" export { connect } from "./editable.connect" export { machine } from "./editable.machine" -export type { UserDefinedContext as Context, PublicApi, InteractOutsideEvent } from "./editable.types" +export type { UserDefinedContext as Context, MachineApi, InteractOutsideEvent } from "./editable.types" From 01271def1744fdb21de99f7bb29fb7d0d926e9ab Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:46:58 +0100 Subject: [PATCH 15/45] refactor: file upload --- .xstate/file-upload.js | 6 ++-- .../file-upload/src/file-upload.connect.ts | 4 +-- .../file-upload/src/file-upload.machine.ts | 32 +++++++++++++------ .../file-upload/src/file-upload.types.ts | 2 +- packages/machines/file-upload/src/index.ts | 2 +- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/.xstate/file-upload.js b/.xstate/file-upload.js index 2f6d4bc60c..66a8b8a2a3 100644 --- a/.xstate/file-upload.js +++ b/.xstate/file-upload.js @@ -17,10 +17,10 @@ const fetchMachine = createMachine({ }, on: { "FILES.SET": { - actions: ["setFilesFromEvent", "invokeOnChange"] + actions: ["setFilesFromEvent"] }, "FILE.DELETE": { - actions: ["removeFile", "invokeOnChange"] + actions: ["removeFile"] } }, on: { @@ -55,7 +55,7 @@ const fetchMachine = createMachine({ on: { "DROPZONE.DROP": { target: "idle", - actions: ["clearInvalid", "setFilesFromEvent", "invokeOnChange"] + actions: ["clearInvalid", "setFilesFromEvent"] }, "DROPZONE.DRAG_LEAVE": { target: "idle", diff --git a/packages/machines/file-upload/src/file-upload.connect.ts b/packages/machines/file-upload/src/file-upload.connect.ts index 5281b22393..375f4bdbd7 100644 --- a/packages/machines/file-upload/src/file-upload.connect.ts +++ b/packages/machines/file-upload/src/file-upload.connect.ts @@ -3,10 +3,10 @@ import { type NormalizeProps, type PropTypes } from "@zag-js/types" import { visuallyHiddenStyle } from "@zag-js/visually-hidden" import { parts } from "./file-upload.anatomy" import { dom } from "./file-upload.dom" -import { type PublicApi, type Send, type State } from "./file-upload.types" +import { type MachineApi, type Send, type State } from "./file-upload.types" import { isEventWithFiles } from "./file-upload.utils" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const disabled = state.context.disabled const isDragging = state.matches("dragging") const isFocused = state.matches("focused") && !disabled diff --git a/packages/machines/file-upload/src/file-upload.machine.ts b/packages/machines/file-upload/src/file-upload.machine.ts index dc45079e38..ea679ee10f 100644 --- a/packages/machines/file-upload/src/file-upload.machine.ts +++ b/packages/machines/file-upload/src/file-upload.machine.ts @@ -2,7 +2,7 @@ import { createMachine, guards, ref } from "@zag-js/core" import { raf } from "@zag-js/dom-query" import { compact } from "@zag-js/utils" import { dom } from "./file-upload.dom" -import type { MachineContext, MachineState, UserDefinedContext } from "./file-upload.types" +import type { MachineContext, MachineState, RejectedFile, UserDefinedContext } from "./file-upload.types" import { getAcceptAttrString, getFilesFromEvent, isFilesWithinRange } from "./file-upload.utils" const { not } = guards @@ -29,10 +29,10 @@ export function machine(userContext: UserDefinedContext) { }, on: { "FILES.SET": { - actions: ["setFilesFromEvent", "invokeOnChange"], + actions: ["setFilesFromEvent"], }, "FILE.DELETE": { - actions: ["removeFile", "invokeOnChange"], + actions: ["removeFile"], }, }, states: { @@ -63,7 +63,7 @@ export function machine(userContext: UserDefinedContext) { on: { "DROPZONE.DROP": { target: "idle", - actions: ["clearInvalid", "setFilesFromEvent", "invokeOnChange"], + actions: ["clearInvalid", "setFilesFromEvent"], }, "DROPZONE.DRAG_LEAVE": { target: "idle", @@ -110,20 +110,20 @@ export function machine(userContext: UserDefinedContext) { const result = getFilesFromEvent(ctx, evt.files) const { acceptedFiles, rejectedFiles } = result - ctx.rejectedFiles = ref(rejectedFiles) - if (ctx.multiple) { - ctx.files = ref([...ctx.files, ...acceptedFiles]) + const files = ref([...ctx.files, ...acceptedFiles]) + set.files(ctx, files, rejectedFiles) return } if (acceptedFiles.length) { - ctx.files = ref([acceptedFiles[0]]) + const files = ref([acceptedFiles[0]]) + set.files(ctx, files, rejectedFiles) } }, removeFile(ctx, evt) { const nextFiles = ctx.files.filter((file) => file !== evt.file) - ctx.files = ref(nextFiles) + set.files(ctx, nextFiles) }, invokeOnChange(ctx) { ctx.onChange?.({ @@ -138,3 +138,17 @@ export function machine(userContext: UserDefinedContext) { }, ) } + +const invoke = { + change: (ctx: MachineContext) => { + ctx.onChange?.({ acceptedFiles: ctx.files, rejectedFiles: ctx.rejectedFiles }) + }, +} + +const set = { + files: (ctx: MachineContext, acceptedFiles: File[], rejectedFiles?: RejectedFile[]) => { + ctx.files = ref(acceptedFiles) + if (rejectedFiles) ctx.rejectedFiles = ref(rejectedFiles) + invoke.change(ctx) + }, +} diff --git a/packages/machines/file-upload/src/file-upload.types.ts b/packages/machines/file-upload/src/file-upload.types.ts index b2fb6e6f9b..e14160732c 100644 --- a/packages/machines/file-upload/src/file-upload.types.ts +++ b/packages/machines/file-upload/src/file-upload.types.ts @@ -82,7 +82,7 @@ export type State = S.State export type Send = S.Send -export type PublicApi = { +export type MachineApi = { /** * Whether the user is dragging something over the root element */ diff --git a/packages/machines/file-upload/src/index.ts b/packages/machines/file-upload/src/index.ts index 419ec399f2..9d5ed1443c 100644 --- a/packages/machines/file-upload/src/index.ts +++ b/packages/machines/file-upload/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./file-upload.anatomy" export { connect } from "./file-upload.connect" export { machine } from "./file-upload.machine" -export type { UserDefinedContext as Context } from "./file-upload.types" +export type { MachineApi as Api, UserDefinedContext as Context } from "./file-upload.types" From 519e01880c1c41d1ee77ef5478164219f8ad6f28 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:51:23 +0100 Subject: [PATCH 16/45] refactor: radio group --- packages/machines/radio-group/src/index.ts | 2 +- .../radio-group/src/radio-group.connect.ts | 4 +- .../radio-group/src/radio-group.dom.ts | 28 +++--- .../radio-group/src/radio-group.machine.ts | 21 +++-- .../radio-group/src/radio-group.types.ts | 87 ++++++++++--------- 5 files changed, 75 insertions(+), 67 deletions(-) diff --git a/packages/machines/radio-group/src/index.ts b/packages/machines/radio-group/src/index.ts index 5804b79587..6d7aed4ef1 100644 --- a/packages/machines/radio-group/src/index.ts +++ b/packages/machines/radio-group/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./radio-group.anatomy" export { connect } from "./radio-group.connect" export { machine } from "./radio-group.machine" -export type { UserDefinedContext as Context, PublicApi } from "./radio-group.types" +export type { UserDefinedContext as Context, MachineApi as Api } from "./radio-group.types" diff --git a/packages/machines/radio-group/src/radio-group.connect.ts b/packages/machines/radio-group/src/radio-group.connect.ts index 11efd20f28..8fcee7ae31 100644 --- a/packages/machines/radio-group/src/radio-group.connect.ts +++ b/packages/machines/radio-group/src/radio-group.connect.ts @@ -3,9 +3,9 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" import { visuallyHiddenStyle } from "@zag-js/visually-hidden" import { parts } from "./radio-group.anatomy" import { dom } from "./radio-group.dom" -import type { InputProps, PublicApi, RadioProps, Send, State } from "./radio-group.types" +import type { InputProps, MachineApi, RadioProps, Send, State } from "./radio-group.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isGroupDisabled = state.context.disabled function getRadioState(props: T) { diff --git a/packages/machines/radio-group/src/radio-group.dom.ts b/packages/machines/radio-group/src/radio-group.dom.ts index e0f39cb6c3..a5b2fd4c8e 100644 --- a/packages/machines/radio-group/src/radio-group.dom.ts +++ b/packages/machines/radio-group/src/radio-group.dom.ts @@ -33,14 +33,12 @@ export const dom = createScope({ return dom.getById(ctx, dom.getRadioId(ctx, ctx.value)) }, - getOffsetRect: (el: HTMLElement | undefined) => { - return { - left: el?.offsetLeft ?? 0, - top: el?.offsetTop ?? 0, - width: el?.offsetWidth ?? 0, - height: el?.offsetHeight ?? 0, - } - }, + getOffsetRect: (el: HTMLElement | undefined) => ({ + left: el?.offsetLeft ?? 0, + top: el?.offsetTop ?? 0, + width: el?.offsetWidth ?? 0, + height: el?.offsetHeight ?? 0, + }), getRectById: (ctx: Ctx, id: string) => { const radioEl = dom.getById(ctx, dom.getRadioId(ctx, id)) @@ -48,12 +46,10 @@ export const dom = createScope({ return dom.resolveRect(dom.getOffsetRect(radioEl)) }, - resolveRect(rect: Record<"width" | "height" | "left" | "top", number>) { - return { - width: `${rect.width}px`, - height: `${rect.height}px`, - left: `${rect.left}px`, - top: `${rect.top}px`, - } - }, + resolveRect: (rect: Record<"width" | "height" | "left" | "top", number>) => ({ + width: `${rect.width}px`, + height: `${rect.height}px`, + left: `${rect.left}px`, + top: `${rect.top}px`, + }), }) diff --git a/packages/machines/radio-group/src/radio-group.machine.ts b/packages/machines/radio-group/src/radio-group.machine.ts index 703bee46b8..239b9bd726 100644 --- a/packages/machines/radio-group/src/radio-group.machine.ts +++ b/packages/machines/radio-group/src/radio-group.machine.ts @@ -29,7 +29,7 @@ export function machine(userContext: UserDefinedContext) { activities: ["trackFormControlState"], watch: { - value: ["setIndicatorTransition", "invokeOnChange", "syncIndicatorRect", "syncInputElements"], + value: ["setIndicatorTransition", "syncIndicatorRect", "syncInputElements"], }, on: { @@ -68,7 +68,7 @@ export function machine(userContext: UserDefinedContext) { actions: { setValue(ctx, evt) { - ctx.value = evt.value + set.value(ctx, evt.value) }, setHovered(ctx, evt) { ctx.hoveredId = evt.value @@ -79,9 +79,6 @@ export function machine(userContext: UserDefinedContext) { setFocused(ctx, evt) { ctx.focusedId = evt.value }, - invokeOnChange(ctx, evt) { - ctx.onChange?.({ value: evt.value }) - }, syncInputElements(ctx) { const inputs = dom.getInputEls(ctx) inputs.forEach((input) => { @@ -125,3 +122,17 @@ export function machine(userContext: UserDefinedContext) { }, ) } + +const invoke = { + change: (ctx: MachineContext) => { + if (ctx.value == null) return + ctx.onChange?.({ value: ctx.value }) + }, +} + +const set = { + value: (ctx: MachineContext, value: string) => { + ctx.value = value + invoke.change(ctx) + }, +} diff --git a/packages/machines/radio-group/src/radio-group.types.ts b/packages/machines/radio-group/src/radio-group.types.ts index bdc5b96f29..f2a67123da 100644 --- a/packages/machines/radio-group/src/radio-group.types.ts +++ b/packages/machines/radio-group/src/radio-group.types.ts @@ -49,49 +49,6 @@ type PublicContext = DirectionProperty & */ orientation?: "horizontal" | "vertical" } -export type PublicApi = { - /** - * The current value of the radio group - */ - value: string | null - /** - * Function to set the value of the radio group - */ - setValue(value: string): void - /** - * Function to clear the value of the radio group - */ - clearValue(): void - /** - * Function to focus the radio group - */ - focus: () => void - /** - * Function to blur the currently focused radio input in the radio group - */ - blur(): void - /** - * Returns the state details of a radio input - */ - getRadioState: ( - props: T_1, - ) => { - isInteractive: boolean - isInvalid: boolean | undefined - isDisabled: boolean | undefined - isChecked: boolean - isFocused: boolean - isHovered: boolean - isActive: boolean - } - rootProps: T["element"] - labelProps: T["element"] - getRadioProps(props: RadioProps): T["label"] - getRadioLabelProps(props: RadioProps): T["element"] - getRadioControlProps(props: RadioProps): T["element"] - getRadioHiddenInputProps(props: InputProps): T["input"] - indicatorProps: T["element"] -} type PrivateContext = Context<{ /** @@ -167,3 +124,47 @@ export type InputProps = RadioProps & { */ required?: boolean } + +export type MachineApi = { + /** + * The current value of the radio group + */ + value: string | null + /** + * Function to set the value of the radio group + */ + setValue(value: string): void + /** + * Function to clear the value of the radio group + */ + clearValue(): void + /** + * Function to focus the radio group + */ + focus: () => void + /** + * Function to blur the currently focused radio input in the radio group + */ + blur(): void + /** + * Returns the state details of a radio input + */ + getRadioState: ( + props: T_1, + ) => { + isInteractive: boolean + isInvalid: boolean | undefined + isDisabled: boolean | undefined + isChecked: boolean + isFocused: boolean + isHovered: boolean + isActive: boolean + } + rootProps: T["element"] + labelProps: T["element"] + getRadioProps(props: RadioProps): T["label"] + getRadioLabelProps(props: RadioProps): T["element"] + getRadioControlProps(props: RadioProps): T["element"] + getRadioHiddenInputProps(props: InputProps): T["input"] + indicatorProps: T["element"] +} From 4dadf0dea0e0d5dea37e8c2356f84caa26ca74de Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:52:15 +0100 Subject: [PATCH 17/45] refactor: presence --- packages/machines/presence/src/index.ts | 2 +- packages/machines/presence/src/presence.connect.ts | 4 ++-- packages/machines/presence/src/presence.types.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/machines/presence/src/index.ts b/packages/machines/presence/src/index.ts index 80c56e8a6b..c44f0739cc 100644 --- a/packages/machines/presence/src/index.ts +++ b/packages/machines/presence/src/index.ts @@ -1,3 +1,3 @@ export { connect } from "./presence.connect" export { machine } from "./presence.machine" -export type { UserDefinedContext as Context, PublicApi } from "./presence.types" +export type { UserDefinedContext as Context, MachineApi as Api } from "./presence.types" diff --git a/packages/machines/presence/src/presence.connect.ts b/packages/machines/presence/src/presence.connect.ts index ea24ec3419..79d45fe402 100644 --- a/packages/machines/presence/src/presence.connect.ts +++ b/packages/machines/presence/src/presence.connect.ts @@ -1,7 +1,7 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" -import type { PublicApi, Send, State } from "./presence.types" +import type { MachineApi, Send, State } from "./presence.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { void normalize return { isPresent: state.matches("mounted", "unmountSuspended"), diff --git a/packages/machines/presence/src/presence.types.ts b/packages/machines/presence/src/presence.types.ts index 6a69bd0bf0..09d4a1693e 100644 --- a/packages/machines/presence/src/presence.types.ts +++ b/packages/machines/presence/src/presence.types.ts @@ -5,7 +5,7 @@ type PublicContext = { onExitComplete?: () => void } -export type PublicApi = { +export type MachineApi = { /** * Whether the node is present in the DOM. */ From 4d1eb2bc87c67ac8766fac78dcd4ea778a1878a2 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 08:52:53 +0100 Subject: [PATCH 18/45] refactor: hover card --- packages/machines/hover-card/src/hover-card.connect.ts | 4 ++-- packages/machines/hover-card/src/hover-card.types.ts | 2 +- packages/machines/hover-card/src/index.ts | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/machines/hover-card/src/hover-card.connect.ts b/packages/machines/hover-card/src/hover-card.connect.ts index 767d729e07..bce3f4b657 100644 --- a/packages/machines/hover-card/src/hover-card.connect.ts +++ b/packages/machines/hover-card/src/hover-card.connect.ts @@ -2,9 +2,9 @@ import { getPlacementStyles, type PositioningOptions } from "@zag-js/popper" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./hover-card.anatomy" import { dom } from "./hover-card.dom" -import type { PublicApi, Send, State } from "./hover-card.types" +import type { MachineApi, Send, State } from "./hover-card.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isOpen = state.hasTag("open") const popperStyles = getPlacementStyles({ diff --git a/packages/machines/hover-card/src/hover-card.types.ts b/packages/machines/hover-card/src/hover-card.types.ts index 328b941c2b..368f2eedc3 100644 --- a/packages/machines/hover-card/src/hover-card.types.ts +++ b/packages/machines/hover-card/src/hover-card.types.ts @@ -41,7 +41,7 @@ type PublicContext = DirectionProperty & positioning: PositioningOptions } -export type PublicApi = { +export type MachineApi = { /** * Whether the hover card is open */ diff --git a/packages/machines/hover-card/src/index.ts b/packages/machines/hover-card/src/index.ts index f22fab1607..bdc31b57a4 100644 --- a/packages/machines/hover-card/src/index.ts +++ b/packages/machines/hover-card/src/index.ts @@ -1,4 +1,9 @@ export { anatomy } from "./hover-card.anatomy" export { connect } from "./hover-card.connect" export { machine } from "./hover-card.machine" -export type { UserDefinedContext as Context, Placement, PositioningOptions, PublicApi } from "./hover-card.types" +export type { + UserDefinedContext as Context, + Placement, + PositioningOptions, + MachineApi as Api, +} from "./hover-card.types" From 7e020ddacecc3f7a9c8899fda193fac9ae20adec Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 09:00:42 +0100 Subject: [PATCH 19/45] refactor: rating group --- packages/machines/rating-group/src/index.ts | 2 +- .../rating-group/src/rating-group.connect.ts | 4 +- .../rating-group/src/rating-group.machine.ts | 57 ++++++++++------- .../rating-group/src/rating-group.types.ts | 62 +++++++++---------- 4 files changed, 68 insertions(+), 57 deletions(-) diff --git a/packages/machines/rating-group/src/index.ts b/packages/machines/rating-group/src/index.ts index 9f614fc526..7f7bdee8ee 100644 --- a/packages/machines/rating-group/src/index.ts +++ b/packages/machines/rating-group/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./rating-group.anatomy" export { connect } from "./rating-group.connect" export { machine } from "./rating-group.machine" -export type { UserDefinedContext as Context, ItemProps, ItemState, PublicApi } from "./rating-group.types" +export type { UserDefinedContext as Context, ItemProps, ItemState, MachineApi as Api } from "./rating-group.types" diff --git a/packages/machines/rating-group/src/rating-group.connect.ts b/packages/machines/rating-group/src/rating-group.connect.ts index 014014cba9..78f6e6068b 100644 --- a/packages/machines/rating-group/src/rating-group.connect.ts +++ b/packages/machines/rating-group/src/rating-group.connect.ts @@ -10,9 +10,9 @@ import { ariaAttr, dataAttr } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./rating-group.anatomy" import { dom } from "./rating-group.dom" -import type { ItemProps, ItemState, PublicApi, Send, State } from "./rating-group.types" +import type { ItemProps, ItemState, MachineApi, Send, State } from "./rating-group.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isInteractive = state.context.isInteractive const value = state.context.value const hoveredValue = state.context.hoveredValue diff --git a/packages/machines/rating-group/src/rating-group.machine.ts b/packages/machines/rating-group/src/rating-group.machine.ts index f84039577f..61bb70bd1f 100644 --- a/packages/machines/rating-group/src/rating-group.machine.ts +++ b/packages/machines/rating-group/src/rating-group.machine.ts @@ -30,7 +30,6 @@ export function machine(userContext: UserDefinedContext) { watch: { allowHalf: ["roundValueIfNeeded"], - value: ["invokeOnChange", "dispatchChangeEvent"], }, computed: { @@ -129,60 +128,72 @@ export function machine(userContext: UserDefinedContext) { ctx.disabled = true }, onFormReset() { - ctx.value = initialContext.value + set.value(ctx, initialContext.value) }, }) }, }, actions: { clearHoveredValue(ctx) { - ctx.hoveredValue = -1 + set.hoveredValue(ctx, -1) }, focusActiveRadio(ctx) { raf(() => dom.getRadioEl(ctx)?.focus()) }, setPrevValue(ctx) { const factor = ctx.allowHalf ? 0.5 : 1 - ctx.value = Math.max(0, ctx.value - factor) + set.value(ctx, Math.max(0, ctx.value - factor)) }, setNextValue(ctx) { const factor = ctx.allowHalf ? 0.5 : 1 const value = ctx.value === -1 ? 0 : ctx.value - ctx.value = Math.min(ctx.max, value + factor) + set.value(ctx, Math.min(ctx.max, value + factor)) }, setValueToMin(ctx) { - ctx.value = 1 + set.value(ctx, 1) }, setValueToMax(ctx) { - ctx.value = ctx.max + set.value(ctx, ctx.max) }, setValue(ctx, evt) { - ctx.value = ctx.hoveredValue === -1 ? evt.value : ctx.hoveredValue + const value = ctx.hoveredValue === -1 ? evt.value : ctx.hoveredValue + set.value(ctx, value) }, clearValue(ctx) { - ctx.value = -1 + set.value(ctx, -1) }, setHoveredValue(ctx, evt) { const half = ctx.allowHalf && evt.isMidway const factor = half ? 0.5 : 0 - let value = evt.index - factor - ctx.hoveredValue = value - }, - dispatchChangeEvent(ctx) { - dom.dispatchChangeEvent(ctx) - }, - invokeOnChange(ctx) { - ctx.onChange?.({ value: ctx.value }) - }, - invokeOnHover(ctx) { - ctx.onHover?.({ value: ctx.hoveredValue }) + set.hoveredValue(ctx, evt.index - factor) }, roundValueIfNeeded(ctx) { - if (!ctx.allowHalf) { - ctx.value = Math.round(ctx.value) - } + if (ctx.allowHalf) return + // doesn't use set.value(...) because it's an implicit coarsing (used in watch and created) + ctx.value = Math.round(ctx.value) }, }, }, ) } + +const invoke = { + change: (ctx: MachineContext) => { + ctx.onChange?.({ value: ctx.value }) + dom.dispatchChangeEvent(ctx) + }, + hoverChange: (ctx: MachineContext) => { + ctx.onHover?.({ value: ctx.hoveredValue }) + }, +} + +const set = { + value: (ctx: MachineContext, value: number) => { + ctx.value = value + invoke.change(ctx) + }, + hoveredValue: (ctx: MachineContext, value: number) => { + ctx.hoveredValue = value + invoke.hoverChange(ctx) + }, +} diff --git a/packages/machines/rating-group/src/rating-group.types.ts b/packages/machines/rating-group/src/rating-group.types.ts index 3687f31f3a..692bd0b4d8 100644 --- a/packages/machines/rating-group/src/rating-group.types.ts +++ b/packages/machines/rating-group/src/rating-group.types.ts @@ -77,7 +77,37 @@ type PublicContext = DirectionProperty & onHover?: (details: { value: number }) => void } -export type PublicApi = { +export type UserDefinedContext = RequiredBy + +type ComputedContext = Readonly<{ + /** + * @computed Whether the rating is interactive + */ + readonly isInteractive: boolean + /** + * @computed Whether the pointer is hovering over the rating + */ + readonly isHovering: boolean +}> + +type PrivateContext = Context<{ + /** + * @internal The value of the hovered rating. + */ + hoveredValue: number +}> + +export type MachineContext = PublicContext & ComputedContext & PrivateContext + +export type MachineState = { + value: "idle" | "hover" | "focus" +} + +export type State = S.State + +export type Send = S.Send + +export type MachineApi = { /** * Sets the value of the rating group */ @@ -116,33 +146,3 @@ export type PublicApi = { controlProps: T["element"] getRatingProps(props: ItemProps): T["element"] } - -export type UserDefinedContext = RequiredBy - -type ComputedContext = Readonly<{ - /** - * @computed Whether the rating is interactive - */ - readonly isInteractive: boolean - /** - * @computed Whether the pointer is hovering over the rating - */ - readonly isHovering: boolean -}> - -type PrivateContext = Context<{ - /** - * @internal The value of the hovered rating. - */ - hoveredValue: number -}> - -export type MachineContext = PublicContext & ComputedContext & PrivateContext - -export type MachineState = { - value: "idle" | "hover" | "focus" -} - -export type State = S.State - -export type Send = S.Send From 1357801e332bf0d45fa077611853c7a0c42bdb56 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 09:06:12 +0100 Subject: [PATCH 20/45] refactor: tabs --- packages/machines/tabs/src/index.ts | 2 +- packages/machines/tabs/src/tabs.connect.ts | 4 +- packages/machines/tabs/src/tabs.machine.ts | 43 +++++++++------ packages/machines/tabs/src/tabs.types.ts | 64 +++++++++++----------- 4 files changed, 60 insertions(+), 53 deletions(-) diff --git a/packages/machines/tabs/src/index.ts b/packages/machines/tabs/src/index.ts index 32e8e75e08..8d6eb8f1dd 100644 --- a/packages/machines/tabs/src/index.ts +++ b/packages/machines/tabs/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./tabs.anatomy" export { connect } from "./tabs.connect" export { machine } from "./tabs.machine" -export type { ContentProps, UserDefinedContext as Context, PublicApi, TriggerProps } from "./tabs.types" +export type { ContentProps, UserDefinedContext as Context, MachineApi as Api, TriggerProps } from "./tabs.types" diff --git a/packages/machines/tabs/src/tabs.connect.ts b/packages/machines/tabs/src/tabs.connect.ts index 0e733a4764..a83849640e 100644 --- a/packages/machines/tabs/src/tabs.connect.ts +++ b/packages/machines/tabs/src/tabs.connect.ts @@ -3,9 +3,9 @@ import { dataAttr, isSafari, isSelfEvent } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./tabs.anatomy" import { dom } from "./tabs.dom" -import type { ContentProps, PublicApi, Send, State, TriggerProps } from "./tabs.types" +import type { ContentProps, MachineApi, Send, State, TriggerProps } from "./tabs.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const translations = state.context.translations const isFocused = state.matches("focused") diff --git a/packages/machines/tabs/src/tabs.machine.ts b/packages/machines/tabs/src/tabs.machine.ts index aa86c60b89..12603c7b60 100644 --- a/packages/machines/tabs/src/tabs.machine.ts +++ b/packages/machines/tabs/src/tabs.machine.ts @@ -46,14 +46,7 @@ export function machine(userContext: UserDefinedContext) { exit: ["cleanupObserver"], watch: { - focusedValue: "invokeOnFocus", - value: [ - "enableIndicatorTransition", - "invokeOnChange", - "setPrevSelectedTabs", - "syncIndicatorRect", - "setContentTabIndex", - ], + value: ["enableIndicatorTransition", "setPrevSelectedTabs", "syncIndicatorRect", "setContentTabIndex"], dir: ["syncIndicatorRect"], orientation: ["syncIndicatorRect"], }, @@ -146,16 +139,16 @@ export function machine(userContext: UserDefinedContext) { actions: { setFocusedValue(ctx, evt) { - ctx.focusedValue = evt.value + set.focusedValue(ctx, evt.value) }, clearFocusedValue(ctx) { - ctx.focusedValue = null + set.focusedValue(ctx, null) }, setValue(ctx, evt) { - ctx.value = evt.value + set.value(ctx, evt.value) }, clearValue(ctx) { - ctx.value = null + set.value(ctx, null) }, focusFirstTab(ctx) { raf(() => dom.getFirstEl(ctx)?.focus()) @@ -176,12 +169,6 @@ export function machine(userContext: UserDefinedContext) { checkRenderedElements(ctx) { ctx.isIndicatorRendered = !!dom.getIndicatorEl(ctx) }, - invokeOnChange(ctx) { - ctx.onChange?.({ value: ctx.value }) - }, - invokeOnFocus(ctx) { - ctx.onFocus?.({ value: ctx.focusedValue }) - }, setPrevSelectedTabs(ctx) { if (ctx.value != null) { ctx.previousValues = pushUnique(ctx.previousValues, ctx.value) @@ -254,3 +241,23 @@ function pushUnique(arr: string[], value: any) { newArr.push(value) return newArr } + +const invoke = { + change: (ctx: MachineContext) => { + ctx.onChange?.({ value: ctx.value }) + }, + focusChange: (ctx: MachineContext) => { + ctx.onFocus?.({ value: ctx.focusedValue }) + }, +} + +const set = { + value: (ctx: MachineContext, value: string | null) => { + ctx.value = value + invoke.change(ctx) + }, + focusedValue: (ctx: MachineContext, value: string | null) => { + ctx.focusedValue = value + invoke.focusChange(ctx) + }, +} diff --git a/packages/machines/tabs/src/tabs.types.ts b/packages/machines/tabs/src/tabs.types.ts index 576cd02a91..dd08487997 100644 --- a/packages/machines/tabs/src/tabs.types.ts +++ b/packages/machines/tabs/src/tabs.types.ts @@ -70,38 +70,6 @@ type PublicContext = DirectionProperty & onDelete?: (details: { value: string }) => void } -export type PublicApi = { - /** - * The current value of the tabs. - */ - value: string | null - /** - * The value of the tab that is currently focused. - */ - focusedValue: string | null - /** - * The previous values of the tabs in sequence of selection. - */ - previousValues: string[] - /** - * Sets the value of the tabs. - */ - setValue(value: string): void - /** - * Clears the value of the tabs. - */ - clearValue(): void - /** - * Sets the indicator rect to the tab with the given id. - */ - setIndicatorRect(id: string | null | undefined): void - rootProps: T["element"] - tablistProps: T["element"] - getTriggerProps(props: TriggerProps): T["button"] - getContentProps({ value }: ContentProps): T["element"] - indicatorProps: T["element"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{ @@ -159,3 +127,35 @@ export type MachineState = { export type State = S.State export type Send = S.Send + +export type MachineApi = { + /** + * The current value of the tabs. + */ + value: string | null + /** + * The value of the tab that is currently focused. + */ + focusedValue: string | null + /** + * The previous values of the tabs in sequence of selection. + */ + previousValues: string[] + /** + * Sets the value of the tabs. + */ + setValue(value: string): void + /** + * Clears the value of the tabs. + */ + clearValue(): void + /** + * Sets the indicator rect to the tab with the given id. + */ + setIndicatorRect(id: string | null | undefined): void + rootProps: T["element"] + tablistProps: T["element"] + getTriggerProps(props: TriggerProps): T["button"] + getContentProps({ value }: ContentProps): T["element"] + indicatorProps: T["element"] +} From 6389a309a2f63ea8babd8c0590238e9269c62a87 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 09:29:15 +0100 Subject: [PATCH 21/45] refactor: select --- .xstate/select.js | 51 +++--- packages/machines/select/src/index.ts | 2 +- .../machines/select/src/select.connect.ts | 4 +- packages/machines/select/src/select.dom.ts | 3 +- .../machines/select/src/select.machine.ts | 161 +++++++++--------- packages/machines/select/src/select.types.ts | 114 ++++++------- 6 files changed, 163 insertions(+), 172 deletions(-) diff --git a/.xstate/select.js b/.xstate/select.js index 054393621a..aba1208985 100644 --- a/.xstate/select.js +++ b/.xstate/select.js @@ -23,16 +23,15 @@ const fetchMachine = createMachine({ initial: "idle", on: { HIGHLIGHT_OPTION: { - actions: ["setHighlightedOption", "invokeOnHighlight"] + actions: ["setHighlightedOption"] }, SELECT_OPTION: { - actions: ["setSelectedOption", "invokeOnSelect"] + actions: ["setSelectedOption"] }, CLEAR_SELECTED: { - actions: ["clearSelectedOption", "invokeOnSelect"] + actions: ["clearSelectedOption"] } }, - entry: ["setInitialSelectedOption"], activities: ["trackFormControlState"], on: { UPDATE_CONTEXT: { @@ -70,32 +69,32 @@ const fetchMachine = createMachine({ }, ARROW_UP: { target: "open", - actions: ["highlightLastOption", "invokeOnHighlight"] + actions: ["highlightLastOption"] }, ARROW_DOWN: { target: "open", - actions: ["highlightFirstOption", "invokeOnHighlight"] + actions: ["highlightFirstOption"] }, ARROW_LEFT: [{ cond: "hasSelectedOption", - actions: ["selectPreviousOption", "invokeOnSelect"] + actions: ["selectPreviousOption"] }, { - actions: ["selectLastOption", "invokeOnSelect"] + actions: ["selectLastOption"] }], ARROW_RIGHT: [{ cond: "hasSelectedOption", - actions: ["selectNextOption", "invokeOnSelect"] + actions: ["selectNextOption"] }, { - actions: ["selectFirstOption", "invokeOnSelect"] + actions: ["selectFirstOption"] }], HOME: { - actions: ["selectFirstOption", "invokeOnSelect"] + actions: ["selectFirstOption"] }, END: { - actions: ["selectLastOption", "invokeOnSelect"] + actions: ["selectLastOption"] }, TYPEAHEAD: { - actions: ["selectMatchingOption", "invokeOnSelect"] + actions: ["selectMatchingOption"] }, OPEN: { target: "open" @@ -118,52 +117,52 @@ const fetchMachine = createMachine({ }, OPTION_CLICK: [{ target: "focused", - actions: ["selectHighlightedOption", "invokeOnSelect", "invokeOnClose"], + actions: ["selectHighlightedOption", "invokeOnClose"], cond: "closeOnSelect" }, { - actions: ["selectHighlightedOption", "invokeOnSelect"] + actions: ["selectHighlightedOption"] }], TRIGGER_KEY: [{ target: "focused", - actions: ["selectHighlightedOption", "invokeOnSelect", "invokeOnClose"], + actions: ["selectHighlightedOption", "invokeOnClose"], cond: "closeOnSelect" }, { - actions: ["selectHighlightedOption", "invokeOnSelect"] + actions: ["selectHighlightedOption"] }], BLUR: { target: "focused", actions: ["invokeOnClose"] }, HOME: { - actions: ["highlightFirstOption", "invokeOnHighlight"] + actions: ["highlightFirstOption"] }, END: { - actions: ["highlightLastOption", "invokeOnHighlight"] + actions: ["highlightLastOption"] }, ARROW_DOWN: [{ cond: "hasHighlightedOption", - actions: ["highlightNextOption", "invokeOnHighlight"] + actions: ["highlightNextOption"] }, { - actions: ["highlightFirstOption", "invokeOnHighlight"] + actions: ["highlightFirstOption"] }], ARROW_UP: [{ cond: "hasHighlightedOption", - actions: ["highlightPreviousOption", "invokeOnHighlight"] + actions: ["highlightPreviousOption"] }, { - actions: ["highlightLastOption", "invokeOnHighlight"] + actions: ["highlightLastOption"] }], TYPEAHEAD: { - actions: ["highlightMatchingOption", "invokeOnHighlight"] + actions: ["highlightMatchingOption"] }, POINTER_MOVE: { - actions: ["highlightOption", "invokeOnHighlight"] + actions: ["highlightOption"] }, POINTER_LEAVE: { actions: ["clearHighlightedOption"] }, TAB: [{ target: "idle", - actions: ["selectHighlightedOption", "invokeOnClose", "invokeOnSelect", "clearHighlightedOption"], + actions: ["selectHighlightedOption", "invokeOnClose", "clearHighlightedOption"], cond: "selectOnTab" }, { target: "idle", diff --git a/packages/machines/select/src/index.ts b/packages/machines/select/src/index.ts index c066c6a227..742cfd129e 100644 --- a/packages/machines/select/src/index.ts +++ b/packages/machines/select/src/index.ts @@ -6,5 +6,5 @@ export type { OptionGroupLabelProps, OptionGroupProps, OptionProps, - PublicApi, + MachineApi as Api, } from "./select.types" diff --git a/packages/machines/select/src/select.connect.ts b/packages/machines/select/src/select.connect.ts index 5cfad79ab6..ad0ee98214 100644 --- a/packages/machines/select/src/select.connect.ts +++ b/packages/machines/select/src/select.connect.ts @@ -10,13 +10,13 @@ import type { OptionGroupLabelProps, OptionGroupProps, OptionProps, - PublicApi, + MachineApi, Send, State, } from "./select.types" import * as utils from "./select.utils" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const disabled = state.context.disabled const invalid = state.context.invalid const isInteractive = state.context.isInteractive diff --git a/packages/machines/select/src/select.dom.ts b/packages/machines/select/src/select.dom.ts index b0f08238e9..8a063aa249 100644 --- a/packages/machines/select/src/select.dom.ts +++ b/packages/machines/select/src/select.dom.ts @@ -35,7 +35,8 @@ export const dom = createScope({ const options = dom.getOptionElements(ctx) return prevById(options, currentId, ctx.loop) }, - getOptionDetails(option: HTMLElement) { + getOptionData(option: HTMLElement | null | undefined) { + if (!option) return null const { label, value } = option.dataset return { label, value } as Option }, diff --git a/packages/machines/select/src/select.machine.ts b/packages/machines/select/src/select.machine.ts index be6dc28bc7..61379283a7 100644 --- a/packages/machines/select/src/select.machine.ts +++ b/packages/machines/select/src/select.machine.ts @@ -7,7 +7,7 @@ import { getPlacement } from "@zag-js/popper" import { proxyTabFocus } from "@zag-js/tabbable" import { compact, json } from "@zag-js/utils" import { dom } from "./select.dom" -import type { MachineContext, MachineState, UserDefinedContext } from "./select.types" +import type { MachineContext, MachineState, Option, UserDefinedContext } from "./select.types" export function machine(userContext: UserDefinedContext) { const ctx = compact(userContext) @@ -21,7 +21,6 @@ export function machine(userContext: UserDefinedContext) { loop: false, closeOnSelect: true, ...ctx, - initialSelectedOption: null, prevSelectedOption: null, prevHighlightedOption: null, typeahead: getByTypeahead.defaultOptions, @@ -45,23 +44,21 @@ export function machine(userContext: UserDefinedContext) { initial: "idle", watch: { - selectedOption: ["syncSelectValue", "dispatchChangeEvent"], + selectedOption: ["syncSelectElement"], }, on: { HIGHLIGHT_OPTION: { - actions: ["setHighlightedOption", "invokeOnHighlight"], + actions: ["setHighlightedOption"], }, SELECT_OPTION: { - actions: ["setSelectedOption", "invokeOnSelect"], + actions: ["setSelectedOption"], }, CLEAR_SELECTED: { - actions: ["clearSelectedOption", "invokeOnSelect"], + actions: ["clearSelectedOption"], }, }, - entry: ["setInitialSelectedOption"], - activities: ["trackFormControlState"], states: { @@ -96,38 +93,38 @@ export function machine(userContext: UserDefinedContext) { }, ARROW_UP: { target: "open", - actions: ["highlightLastOption", "invokeOnHighlight"], + actions: ["highlightLastOption"], }, ARROW_DOWN: { target: "open", - actions: ["highlightFirstOption", "invokeOnHighlight"], + actions: ["highlightFirstOption"], }, ARROW_LEFT: [ { guard: "hasSelectedOption", - actions: ["selectPreviousOption", "invokeOnSelect"], + actions: ["selectPreviousOption"], }, { - actions: ["selectLastOption", "invokeOnSelect"], + actions: ["selectLastOption"], }, ], ARROW_RIGHT: [ { guard: "hasSelectedOption", - actions: ["selectNextOption", "invokeOnSelect"], + actions: ["selectNextOption"], }, { - actions: ["selectFirstOption", "invokeOnSelect"], + actions: ["selectFirstOption"], }, ], HOME: { - actions: ["selectFirstOption", "invokeOnSelect"], + actions: ["selectFirstOption"], }, END: { - actions: ["selectLastOption", "invokeOnSelect"], + actions: ["selectLastOption"], }, TYPEAHEAD: { - actions: ["selectMatchingOption", "invokeOnSelect"], + actions: ["selectMatchingOption"], }, OPEN: { target: "open", @@ -152,21 +149,21 @@ export function machine(userContext: UserDefinedContext) { OPTION_CLICK: [ { target: "focused", - actions: ["selectHighlightedOption", "invokeOnSelect", "invokeOnClose"], + actions: ["selectHighlightedOption", "invokeOnClose"], guard: "closeOnSelect", }, { - actions: ["selectHighlightedOption", "invokeOnSelect"], + actions: ["selectHighlightedOption"], }, ], TRIGGER_KEY: [ { target: "focused", - actions: ["selectHighlightedOption", "invokeOnSelect", "invokeOnClose"], + actions: ["selectHighlightedOption", "invokeOnClose"], guard: "closeOnSelect", }, { - actions: ["selectHighlightedOption", "invokeOnSelect"], + actions: ["selectHighlightedOption"], }, ], BLUR: { @@ -174,34 +171,34 @@ export function machine(userContext: UserDefinedContext) { actions: ["invokeOnClose"], }, HOME: { - actions: ["highlightFirstOption", "invokeOnHighlight"], + actions: ["highlightFirstOption"], }, END: { - actions: ["highlightLastOption", "invokeOnHighlight"], + actions: ["highlightLastOption"], }, ARROW_DOWN: [ { guard: "hasHighlightedOption", - actions: ["highlightNextOption", "invokeOnHighlight"], + actions: ["highlightNextOption"], }, { - actions: ["highlightFirstOption", "invokeOnHighlight"], + actions: ["highlightFirstOption"], }, ], ARROW_UP: [ { guard: "hasHighlightedOption", - actions: ["highlightPreviousOption", "invokeOnHighlight"], + actions: ["highlightPreviousOption"], }, { - actions: ["highlightLastOption", "invokeOnHighlight"], + actions: ["highlightLastOption"], }, ], TYPEAHEAD: { - actions: ["highlightMatchingOption", "invokeOnHighlight"], + actions: ["highlightMatchingOption"], }, POINTER_MOVE: { - actions: ["highlightOption", "invokeOnHighlight"], + actions: ["highlightOption"], }, POINTER_LEAVE: { actions: ["clearHighlightedOption"], @@ -209,7 +206,7 @@ export function machine(userContext: UserDefinedContext) { TAB: [ { target: "idle", - actions: ["selectHighlightedOption", "invokeOnClose", "invokeOnSelect", "clearHighlightedOption"], + actions: ["selectHighlightedOption", "invokeOnClose", "clearHighlightedOption"], guard: "selectOnTab", }, { @@ -238,14 +235,13 @@ export function machine(userContext: UserDefinedContext) { }, }) }, - trackFormControlState(ctx) { + trackFormControlState(ctx, _evt, { initialContext }) { return trackFormControl(dom.getHiddenSelectElement(ctx), { onFieldsetDisabled() { ctx.disabled = true }, onFormReset() { - ctx.prevSelectedOption = ctx.selectedOption - ctx.selectedOption = ctx.initialSelectedOption + set.selectedOption(ctx, initialContext.selectedOption) }, }) }, @@ -292,26 +288,23 @@ export function machine(userContext: UserDefinedContext) { }, }, actions: { - setInitialSelectedOption(ctx) { - ctx.initialSelectedOption = ctx.selectedOption - }, highlightPreviousOption(ctx) { if (!ctx.highlightedId) return const option = dom.getPreviousOption(ctx, ctx.highlightedId) - highlightOption(ctx, option) + set.highlightedOption(ctx, dom.getOptionData(option)) }, highlightNextOption(ctx) { if (!ctx.highlightedId) return const option = dom.getNextOption(ctx, ctx.highlightedId) - highlightOption(ctx, option) + set.highlightedOption(ctx, dom.getOptionData(option)) }, highlightFirstOption(ctx) { const option = dom.getFirstOption(ctx) - highlightOption(ctx, option) + set.highlightedOption(ctx, dom.getOptionData(option)) }, highlightLastOption(ctx) { const option = dom.getLastOption(ctx) - highlightOption(ctx, option) + set.highlightedOption(ctx, dom.getOptionData(option)) }, focusContent(ctx) { raf(() => { @@ -328,58 +321,52 @@ export function machine(userContext: UserDefinedContext) { const id = evt.id ?? ctx.highlightedId if (!id) return const option = dom.getById(ctx, id) - selectOption(ctx, option) + set.selectedOption(ctx, dom.getOptionData(option)) }, selectFirstOption(ctx) { const option = dom.getFirstOption(ctx) - selectOption(ctx, option) + set.selectedOption(ctx, dom.getOptionData(option)) }, selectLastOption(ctx) { const option = dom.getLastOption(ctx) - selectOption(ctx, option) + set.selectedOption(ctx, dom.getOptionData(option)) }, selectNextOption(ctx) { if (!ctx.selectedId) return const option = dom.getNextOption(ctx, ctx.selectedId) - selectOption(ctx, option) + set.selectedOption(ctx, dom.getOptionData(option)) }, selectPreviousOption(ctx) { if (!ctx.selectedId) return const option = dom.getPreviousOption(ctx, ctx.selectedId) - selectOption(ctx, option) + set.selectedOption(ctx, dom.getOptionData(option)) }, highlightSelectedOption(ctx) { - if (!ctx.selectedOption) return - ctx.prevHighlightedOption = ctx.highlightedOption - ctx.highlightedOption = ctx.selectedOption + set.highlightedOption(ctx, ctx.selectedOption) }, highlightOption(ctx, evt) { const option = evt.target ?? dom.getById(ctx, evt.id) - highlightOption(ctx, option) + set.highlightedOption(ctx, dom.getOptionData(option)) }, highlightMatchingOption(ctx, evt) { const option = dom.getMatchingOption(ctx, evt.key, ctx.highlightedId) - highlightOption(ctx, option) + set.highlightedOption(ctx, dom.getOptionData(option)) }, selectMatchingOption(ctx, evt) { const option = dom.getMatchingOption(ctx, evt.key, ctx.selectedId) - selectOption(ctx, option) + set.selectedOption(ctx, dom.getOptionData(option)) }, setHighlightedOption(ctx, evt) { - if (!evt.value) return - ctx.prevHighlightedOption = ctx.highlightedOption - ctx.highlightedOption = evt.value + set.highlightedOption(ctx, evt.value) }, clearHighlightedOption(ctx) { - ctx.highlightedOption = null + set.highlightedOption(ctx, null, true) }, setSelectedOption(ctx, evt) { - if (!evt.value) return - ctx.prevSelectedOption = ctx.selectedOption - ctx.selectedOption = evt.value + set.selectedOption(ctx, evt.value) }, clearSelectedOption(ctx) { - ctx.selectedOption = null + set.selectedOption(ctx, null, true) }, scrollContentToTop(ctx) { dom.getContentElement(ctx)?.scrollTo(0, 0) @@ -390,40 +377,48 @@ export function machine(userContext: UserDefinedContext) { invokeOnClose(ctx) { ctx.onClose?.() }, - invokeOnHighlight(ctx) { - if (!ctx.hasHighlightedChanged) return - ctx.onHighlight?.(json(ctx.highlightedOption)) - }, - invokeOnSelect(ctx) { - if (!ctx.hasSelectedChanged) return - ctx.onChange?.(json(ctx.selectedOption)) - }, - syncSelectValue(ctx) { + syncSelectElement(ctx) { const selectedOption = ctx.selectedOption const node = dom.getHiddenSelectElement(ctx) if (!node || !selectedOption) return setElementValue(node, selectedOption.value, { type: "HTMLSelectElement" }) }, - dispatchChangeEvent(ctx) { - const node = dom.getHiddenSelectElement(ctx) - if (!node) return - const win = dom.getWin(ctx) - const changeEvent = new win.Event("change", { bubbles: true }) - node.dispatchEvent(changeEvent) - }, }, }, ) } -function highlightOption(ctx: MachineContext, option?: HTMLElement | null) { - if (!option) return - ctx.prevHighlightedOption = ctx.highlightedOption - ctx.highlightedOption = dom.getOptionDetails(option) +function dispatchChangeEvent(ctx: MachineContext) { + const node = dom.getHiddenSelectElement(ctx) + if (!node) return + const win = dom.getWin(ctx) + const changeEvent = new win.Event("change", { bubbles: true }) + node.dispatchEvent(changeEvent) +} + +const invoke = { + change: (ctx: MachineContext) => { + ctx.onChange?.(json(ctx.selectedOption)) + dispatchChangeEvent(ctx) + }, + highlightChange: (ctx: MachineContext) => { + ctx.onHighlight?.(json(ctx.highlightedOption)) + }, } -function selectOption(ctx: MachineContext, option?: HTMLElement | null) { - if (!option) return - ctx.prevSelectedOption = ctx.selectedOption - ctx.selectedOption = dom.getOptionDetails(option) +const set = { + selectedOption: (ctx: MachineContext, value: Option | null | undefined, force = false) => { + // TODO: account for change + if (!value && !force) return + ctx.prevSelectedOption = ctx.selectedOption + ctx.selectedOption = value || null + invoke.change(ctx) + }, + highlightedOption: (ctx: MachineContext, value: Option | null | undefined, force = false) => { + // TODO: account for change + if (!value && !force) return + ctx.prevHighlightedOption = ctx.highlightedOption + ctx.highlightedOption = value || null + invoke.highlightChange(ctx) + }, } diff --git a/packages/machines/select/src/select.types.ts b/packages/machines/select/src/select.types.ts index 33b09aef49..d17a44a946 100644 --- a/packages/machines/select/src/select.types.ts +++ b/packages/machines/select/src/select.types.ts @@ -85,61 +85,6 @@ type PublicContext = DirectionProperty & loop?: boolean } -export type PublicApi = { - /** - * Whether the select is open - */ - isOpen: boolean - /** - * The currently highlighted option - */ - highlightedOption: Option | null - /** - * The currently selected option - */ - selectedOption: Option | null - /** - * Function to focus the select - */ - focus(): void - /** - * Function to open the select - */ - open(): void - /** - * Function to close the select - */ - close(): void - /** - * Function to set the selected option - */ - setSelectedOption(value: Option): void - /** - * Function to set the highlighted option - */ - setHighlightedOption(value: Option): void - /** - * Function to clear the selected option - */ - clearSelectedOption(): void - /** - * Returns the state details of an option - */ - getOptionState: (props: OptionProps) => { - isDisabled: boolean - isHighlighted: boolean - isChecked: boolean - } - labelProps: T["label"] - positionerProps: T["element"] - triggerProps: T["button"] - getOptionProps(props: OptionProps): T["element"] - getOptionGroupLabelProps(props: OptionGroupLabelProps): T["element"] - getOptionGroupProps(props: OptionGroupProps): T["element"] - hiddenSelectProps: T["select"] - contentProps: T["element"] -} - type PrivateContext = Context<{ /** * Internal state of the typeahead @@ -159,10 +104,6 @@ type PrivateContext = Context<{ * Used to determine if the selected option has changed. */ prevSelectedOption?: Option | null - /** - * The initial selected option. Used for form reset. - */ - initialSelectedOption: Option | null }> type ComputedContext = Readonly<{ @@ -218,3 +159,58 @@ export type OptionGroupProps = { export type OptionGroupLabelProps = { htmlFor: string } + +export type MachineApi = { + /** + * Whether the select is open + */ + isOpen: boolean + /** + * The currently highlighted option + */ + highlightedOption: Option | null + /** + * The currently selected option + */ + selectedOption: Option | null + /** + * Function to focus the select + */ + focus(): void + /** + * Function to open the select + */ + open(): void + /** + * Function to close the select + */ + close(): void + /** + * Function to set the selected option + */ + setSelectedOption(value: Option): void + /** + * Function to set the highlighted option + */ + setHighlightedOption(value: Option): void + /** + * Function to clear the selected option + */ + clearSelectedOption(): void + /** + * Returns the state details of an option + */ + getOptionState: (props: OptionProps) => { + isDisabled: boolean + isHighlighted: boolean + isChecked: boolean + } + labelProps: T["label"] + positionerProps: T["element"] + triggerProps: T["button"] + getOptionProps(props: OptionProps): T["element"] + getOptionGroupLabelProps(props: OptionGroupLabelProps): T["element"] + getOptionGroupProps(props: OptionGroupProps): T["element"] + hiddenSelectProps: T["select"] + contentProps: T["element"] +} From 03dc08745a6960196a28b63e409cf82b32599047 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 09:35:04 +0100 Subject: [PATCH 22/45] refactor: switch --- packages/machines/switch/src/index.ts | 2 +- .../machines/switch/src/switch.connect.ts | 13 ++--- packages/machines/switch/src/switch.dom.ts | 4 +- .../machines/switch/src/switch.machine.ts | 38 ++++++++----- packages/machines/switch/src/switch.types.ts | 56 +++++++++---------- 5 files changed, 61 insertions(+), 52 deletions(-) diff --git a/packages/machines/switch/src/index.ts b/packages/machines/switch/src/index.ts index 533f984cb5..e8f0e81dc8 100644 --- a/packages/machines/switch/src/index.ts +++ b/packages/machines/switch/src/index.ts @@ -1,4 +1,4 @@ export { anatomy } from "./switch.anatomy" export { connect } from "./switch.connect" export { machine } from "./switch.machine" -export type { UserDefinedContext as Context, PublicApi } from "./switch.types" +export type { UserDefinedContext as Context, MachineApi as Api } from "./switch.types" diff --git a/packages/machines/switch/src/switch.connect.ts b/packages/machines/switch/src/switch.connect.ts index 8703ba6b12..b23f14b36b 100644 --- a/packages/machines/switch/src/switch.connect.ts +++ b/packages/machines/switch/src/switch.connect.ts @@ -3,9 +3,9 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types" import { visuallyHiddenStyle } from "@zag-js/visually-hidden" import { parts } from "./switch.anatomy" import { dom } from "./switch.dom" -import type { PublicApi, Send, State } from "./switch.types" +import type { MachineApi, Send, State } from "./switch.types" -export function connect(state: State, send: Send, normalize: NormalizeProps): PublicApi { +export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isDisabled = state.context.disabled const isFocusable = state.context.focusable const isFocused = !isDisabled && state.context.focused @@ -39,7 +39,7 @@ export function connect(state: State, send: Send, normalize ...parts.root.attrs, ...dataAttrs, id: dom.getRootId(state.context), - htmlFor: dom.getInputId(state.context), + htmlFor: dom.getHiddenInputId(state.context), onPointerMove() { if (isDisabled) return send({ type: "CONTEXT.SET", context: { hovered: true } }) @@ -60,7 +60,7 @@ export function connect(state: State, send: Send, normalize send({ type: "CONTEXT.SET", context: { active: false } }) }, onClick(event) { - if (event.target === dom.getInputEl(state.context)) { + if (event.target === dom.getHiddenInputEl(state.context)) { event.stopPropagation() } }, @@ -86,9 +86,8 @@ export function connect(state: State, send: Send, normalize "aria-hidden": true, }), - inputProps: normalize.input({ - ...parts.input.attrs, - id: dom.getInputId(state.context), + hiddenInputProps: normalize.input({ + id: dom.getHiddenInputId(state.context), type: "checkbox", required: state.context.required, defaultChecked: isChecked, diff --git a/packages/machines/switch/src/switch.dom.ts b/packages/machines/switch/src/switch.dom.ts index 46380ac75c..8988983e7f 100644 --- a/packages/machines/switch/src/switch.dom.ts +++ b/packages/machines/switch/src/switch.dom.ts @@ -6,6 +6,6 @@ export const dom = createScope({ getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `switch:${ctx.id}:label`, getThumbId: (ctx: Ctx) => ctx.ids?.thumb ?? `switch:${ctx.id}:thumb`, getControlId: (ctx: Ctx) => ctx.ids?.control ?? `switch:${ctx.id}:control`, - getInputId: (ctx: Ctx) => ctx.ids?.input ?? `switch:${ctx.id}:input`, - getInputEl: (ctx: Ctx) => dom.getById(ctx, dom.getInputId(ctx)), + getHiddenInputId: (ctx: Ctx) => ctx.ids?.input ?? `switch:${ctx.id}:input`, + getHiddenInputEl: (ctx: Ctx) => dom.getById(ctx, dom.getHiddenInputId(ctx)), }) diff --git a/packages/machines/switch/src/switch.machine.ts b/packages/machines/switch/src/switch.machine.ts index ccbe2c293d..febec5053b 100644 --- a/packages/machines/switch/src/switch.machine.ts +++ b/packages/machines/switch/src/switch.machine.ts @@ -20,7 +20,7 @@ export function machine(userContext: UserDefinedContext) { watch: { disabled: "removeFocusIfNeeded", - checked: ["invokeOnChange", "syncInputElement"], + checked: "syncInputElement", }, activities: ["trackFormControlState"], @@ -44,46 +44,56 @@ export function machine(userContext: UserDefinedContext) { { activities: { trackFormControlState(ctx, _evt, { send, initialContext }) { - return trackFormControl(dom.getInputEl(ctx), { + return trackFormControl(dom.getHiddenInputEl(ctx), { onFieldsetDisabled() { ctx.disabled = true }, onFormReset() { - send({ type: "CHECKED.SET", checked: !!initialContext.checked }) + send({ type: "CHECKED.SET", checked: !!initialContext.checked, src: "form-reset" }) }, }) }, }, actions: { - invokeOnChange(ctx) { - ctx.onChange?.({ checked: ctx.checked }) - }, setContext(ctx, evt) { Object.assign(ctx, evt.context) }, syncInputElement(ctx) { - const inputEl = dom.getInputEl(ctx) + const inputEl = dom.getHiddenInputEl(ctx) if (!inputEl) return inputEl.checked = !!ctx.checked }, - dispatchChangeEvent(ctx, evt) { - const inputEl = dom.getInputEl(ctx) - const checked = evt.checked - dispatchInputCheckedEvent(inputEl, { checked, bubbles: true }) - }, removeFocusIfNeeded(ctx) { if (ctx.disabled && ctx.focused) { ctx.focused = false } }, setChecked(ctx, evt) { - ctx.checked = evt.checked + set.checked(ctx, evt.checked) }, toggleChecked(ctx, _evt) { - ctx.checked = !ctx.checked + set.checked(ctx, !ctx.checked) }, }, }, ) } + +const invoke = { + change: (ctx: MachineContext) => { + // invoke fn + ctx.onChange?.({ checked: ctx.checked }) + + // form event + const inputEl = dom.getHiddenInputEl(ctx) + dispatchInputCheckedEvent(inputEl, { checked: ctx.checked, bubbles: true }) + }, +} + +const set = { + checked: (ctx: MachineContext, checked: boolean) => { + ctx.checked = checked + invoke.change(ctx) + }, +} diff --git a/packages/machines/switch/src/switch.types.ts b/packages/machines/switch/src/switch.types.ts index 8cf3d1e853..9122133d06 100644 --- a/packages/machines/switch/src/switch.types.ts +++ b/packages/machines/switch/src/switch.types.ts @@ -64,34 +64,6 @@ type PublicContext = DirectionProperty & value?: string | number } -export type PublicApi = { - /** - * Whether the checkbox is checked - */ - isChecked: boolean - /** - * Whether the checkbox is disabled - */ - isDisabled: boolean | undefined - /** - * Whether the checkbox is focused - */ - isFocused: boolean | undefined - /** - * Function to set the checked state of the switch. - */ - setChecked(checked: boolean): void - /** - * Function to toggle the checked state of the checkbox - */ - toggleChecked(): void - rootProps: T["label"] - labelProps: T["element"] - thumbProps: T["element"] - controlProps: T["element"] - inputProps: T["input"] -} - export type UserDefinedContext = RequiredBy type ComputedContext = Readonly<{}> @@ -123,3 +95,31 @@ export type MachineState = { export type State = S.State export type Send = S.Send + +export type MachineApi = { + /** + * Whether the checkbox is checked + */ + isChecked: boolean + /** + * Whether the checkbox is disabled + */ + isDisabled: boolean | undefined + /** + * Whether the checkbox is focused + */ + isFocused: boolean | undefined + /** + * Function to set the checked state of the switch. + */ + setChecked(checked: boolean): void + /** + * Function to toggle the checked state of the checkbox + */ + toggleChecked(): void + rootProps: T["label"] + labelProps: T["element"] + thumbProps: T["element"] + controlProps: T["element"] + hiddenInputProps: T["input"] +} From 355333a6794eb57b7da2c18d4b02446f86c3fae6 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 14 Aug 2023 09:37:00 +0100 Subject: [PATCH 23/45] docs: update switch --- website/components/machines/switch.tsx | 2 +- website/data/snippets/react/switch/usage.mdx | 2 +- website/data/snippets/solid/switch/usage.mdx | 2 +- website/data/snippets/vue-jsx/switch/usage.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/components/machines/switch.tsx b/website/components/machines/switch.tsx index a88bb68090..be02532c52 100644 --- a/website/components/machines/switch.tsx +++ b/website/components/machines/switch.tsx @@ -34,7 +34,7 @@ export function Switch(props: SwitchProps) { "--switch-track-height": "1.5rem", }} > - + - + diff --git a/website/data/snippets/solid/switch/usage.mdx b/website/data/snippets/solid/switch/usage.mdx index 159440e8d7..a14b925a42 100644 --- a/website/data/snippets/solid/switch/usage.mdx +++ b/website/data/snippets/solid/switch/usage.mdx @@ -10,7 +10,7 @@ function Checkbox() { return (