diff --git a/cypress/integration/tabs.test.ts b/cypress/integration/tabs.test.ts new file mode 100644 index 0000000000..3f46a6f92f --- /dev/null +++ b/cypress/integration/tabs.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable jest/expect-expect */ +describe("tabs", () => { + beforeEach(() => { + cy.visit("/tabs") + cy.injectAxe() + + cy.findByTestId("nils-tab").as("nilsTab") + cy.findByTestId("agnes-tab").as("agnesTab") + cy.findByTestId("joke-tab").as("jokeTab") + + cy.findByTestId("nils-tab-panel").as("nilsPanel") + cy.findByTestId("agnes-tab-panel").as("agnesPanel") + cy.findByTestId("joke-tab-panel").as("jokePanel") + }) + + it("should have no accessibility violation", () => { + cy.checkA11y(".tabs") + }) + + describe("in automatic mode", () => { + it("should select the correct tab on click", () => { + cy.get("@nilsPanel").should("be.visible") + cy.get("@agnesTab").click() + cy.get("@agnesPanel").should("be.visible") + cy.get("@jokeTab").click() + cy.get("@jokePanel").should("be.visible") + }) + + it("on `ArrowRight`: should select & focus the next tab", () => { + cy.get("@nilsTab").focus().realType("{rightarrow}") + cy.get("@agnesTab").should("have.focus") + cy.get("@agnesPanel").should("be.visible") + cy.get("@agnesTab").realType("{rightarrow}") + cy.get("@jokeTab").should("have.focus") + cy.get("@jokePanel").should("be.visible") + cy.get("@jokeTab").realType("{rightarrow}") + // the keyboard navigation should circle back + cy.get("@nilsTab").should("have.focus") + cy.get("@nilsPanel").should("be.visible") + }) + + it("on `ArrowLeft`: should select & focus the previous tab", () => { + cy.get("@nilsTab").focus().realType("{leftarrow}") + cy.get("@jokeTab").should("have.focus") + cy.get("@jokePanel").should("be.visible") + cy.get("@jokeTab").realType("{leftarrow}") + cy.get("@agnesTab").should("have.focus") + cy.get("@agnesPanel").should("be.visible") + cy.get("@agnesTab").realType("{leftarrow}") + cy.get("@nilsTab").should("have.focus") + cy.get("@nilsPanel").should("be.visible") + }) + + it("on `Home` should select first tab", () => { + cy.get("@jokeTab").click().realType("{home}") + cy.get("@nilsTab").should("have.focus") + cy.get("@nilsPanel").should("be.visible") + }) + + it("on `End` should select last tab", () => { + cy.get("@nilsTab").focus().realType("{end}") + cy.get("@jokeTab").should("have.focus") + cy.get("@jokePanel").should("be.visible") + }) + }) + + describe("in manual mode", () => { + beforeEach(() => { + cy.findByTestId("manual").check() + }) + + it("should have no accessibility violation", () => { + cy.checkA11y(".tabs") + }) + + it("on `ArrowRight`: should select & focus the next tab", () => { + cy.get("@nilsTab").focus().realType("{rightarrow}") + cy.get("@agnesTab").should("have.focus") + cy.get("@agnesPanel").should("not.be.visible") + cy.get("@agnesTab").realType("{enter}") + cy.get("@agnesPanel").should("be.visible") + cy.get("@agnesTab").realType("{rightarrow}") + cy.get("@jokeTab").should("have.focus") + cy.get("@jokePanel").should("not.be.visible") + cy.get("@jokeTab").realType("{enter}") + cy.get("@jokePanel").should("be.visible") + }) + + it("on `ArrowLeft`: should select & focus the previous tab", () => { + cy.get("@nilsTab").focus().realType("{leftarrow}") + cy.get("@jokeTab").should("have.focus") + cy.get("@jokePanel").should("not.be.visible") + cy.get("@jokeTab").realType("{enter}") + cy.get("@jokePanel").should("be.visible") + cy.get("@jokeTab").realType("{leftarrow}") + cy.get("@agnesTab").should("have.focus") + cy.get("@agnesPanel").should("not.be.visible") + cy.get("@agnesTab").realType("{enter}") + cy.get("@agnesPanel").should("be.visible") + }) + + it("on `Home`: should go to first tab", () => { + cy.get("@agnesTab").click().realType("{home}") + cy.get("@nilsTab").should("have.focus") + cy.get("@nilsPanel").should("not.be.visible") + cy.get("@nilsTab").realType("{enter}") + cy.get("@nilsPanel").should("be.visible") + }) + + it("on `End`: should go to last tab", () => { + cy.get("@nilsTab").click().realType("{end}") + cy.get("@jokeTab").should("have.focus") + cy.get("@jokePanel").should("not.be.visible") + cy.get("@jokeTab").realType("{enter}") + cy.get("@jokePanel").should("be.visible") + }) + }) +}) diff --git a/examples/next-ts/hooks/use-controls.tsx b/examples/next-ts/hooks/use-controls.tsx index a39173888b..9089d05d4c 100644 --- a/examples/next-ts/hooks/use-controls.tsx +++ b/examples/next-ts/hooks/use-controls.tsx @@ -39,101 +39,101 @@ export function useControls(config: T) { return { context: state, - ui: (props: React.ComponentProps<"div">) => ( + ui: () => (
- {Object.keys(config).map((key) => { - const { type, label, options, placeholder, min, max } = (config[key] ?? {}) as any - switch (type) { - case "boolean": - return ( -
- { - setState((s) => ({ ...s, [key]: e.target.checked })) - }} - /> - -
- ) - case "string": - return ( -
- - { - if (e.key === "Enter") { - setState((s) => ({ ...s, [key]: (e.target as HTMLInputElement).value })) - } - }} - /> -
- ) - case "select": - return ( -
- - -
- ) - case "number": - return ( -
- - { - if (e.key === "Enter") { - setState((s) => ({ ...s, [key]: e.currentTarget?.valueAsNumber })) - } - }} - /> -
- ) - } - })} +

Property controls

+
+ {Object.keys(config).map((key) => { + const { type, label, options, placeholder, min, max } = (config[key] ?? {}) as any + switch (type) { + case "boolean": + return ( +
+ { + setState((s) => ({ ...s, [key]: e.target.checked })) + }} + /> + +
+ ) + case "string": + return ( +
+ + { + if (e.key === "Enter") { + setState((s) => ({ ...s, [key]: (e.target as HTMLInputElement).value })) + } + }} + /> +
+ ) + case "select": + return ( +
+ + +
+ ) + case "number": + return ( +
+ + { + if (e.key === "Enter") { + setState((s) => ({ ...s, [key]: e.currentTarget?.valueAsNumber })) + } + }} + /> +
+ ) + } + })} +
), } diff --git a/examples/next-ts/pages/tabs.tsx b/examples/next-ts/pages/tabs.tsx index 7c093a1068..20ca8471f3 100644 --- a/examples/next-ts/pages/tabs.tsx +++ b/examples/next-ts/pages/tabs.tsx @@ -2,14 +2,47 @@ import { tabs } from "@ui-machines/web" import { useMachine } from "@ui-machines/react" import { StateVisualizer } from "components/state-visualizer" import { useMount } from "hooks/use-mount" +import { useControls } from "hooks/use-controls" + +const tabsData = [ + { + id: "nils", + label: "Nils Frahm", + content: ` + Nils Frahm is a German musician, composer and record producer based in Berlin. He is known for combining + classical and electronic music and for an unconventional approach to the piano in which he mixes a grand + piano, upright piano, Roland Juno-60, Rhodes piano, drum machine, and Moog Taurus. + `, + }, + { + id: "agnes", + label: "Agnes Obel", + content: ` + Agnes Caroline Thaarup Obel is a Danish singer/songwriter. Her first album, Philharmonics, was released by + PIAS Recordings on 4 October 2010 in Europe. Philharmonics was certified gold in June 2011 by the Belgian + Entertainment Association (BEA) for sales of 10,000 Copies. + `, + }, + { + id: "joke", + label: "Joke", + content: ` + Fear of complicated buildings: A complex complex complex. + `, + }, +] export default function Page() { - const [state, send] = useMachine( - tabs.machine.withContext({ - activeId: "nils", - activationMode: "manual", - }), - ) + const controls = useControls({ + manual: { type: "boolean", defaultValue: false, label: "manual?" }, + loop: { type: "boolean", defaultValue: true, label: "loop?" }, + }) + const [state, send] = useMachine(tabs.machine.withContext({ value: "nils" }), { + context: { + activationMode: controls.context.manual ? "manual" : "automatic", + loop: controls.context.loop, + }, + }) const ref = useMount(send) @@ -17,31 +50,21 @@ export default function Page() { return (
+
- - - -
-
-

- Nils Frahm is a German musician, composer and record producer based in Berlin. He is known for combining - classical and electronic music and for an unconventional approach to the piano in which he mixes a grand - piano, upright piano, Roland Juno-60, Rhodes piano, drum machine, and Moog Taurus. -

-
-
-

- Agnes Caroline Thaarup Obel is a Danish singer/songwriter. Her first album, Philharmonics, was released by - PIAS Recordings on 4 October 2010 in Europe. Philharmonics was certified gold in June 2011 by the Belgian - Entertainment Association (BEA) for sales of 10,000 Copies. -

-
-
-

Fear of complicated buildings:

-

A complex complex complex.

+ {tabsData.map((data) => ( + + ))}
+ {tabsData.map((data) => ( +
+

{data.content}

+
+ ))}
diff --git a/examples/solid-ts/src/pages/tabs.tsx b/examples/solid-ts/src/pages/tabs.tsx index 0153715bea..0924cb1365 100644 --- a/examples/solid-ts/src/pages/tabs.tsx +++ b/examples/solid-ts/src/pages/tabs.tsx @@ -7,39 +7,39 @@ import { StateVisualizer } from "../components/state-visualizer" export default function Page() { const [state, send] = useMachine( tabs.machine.withContext({ - activeId: "nils", + value: "nils", activationMode: "manual", }), ) const ref = useSetup({ send, id: "123" }) - const machineState = createMemo(() => tabs.connect(state, send, normalizeProps)) + const connect = createMemo(() => tabs.connect(state, send, normalizeProps)) return (
-
-
- - - +
+
+ + +
-
+

Nils Frahm is a German musician, composer and record producer based in Berlin. He is known for combining classical and electronic music and for an unconventional approach to the piano in which he mixes a grand piano, upright piano, Roland Juno-60, Rhodes piano, drum machine, and Moog Taurus.

-
+

Agnes Caroline Thaarup Obel is a Danish singer/songwriter. Her first album, Philharmonics, was released by PIAS Recordings on 4 October 2010 in Europe. Philharmonics was certified gold in June 2011 by the Belgian Entertainment Association (BEA) for sales of 10,000 Copies.

-
+

Fear of complicated buildings:

A complex complex complex.

diff --git a/examples/vue-ts/src/pages/tabs.tsx b/examples/vue-ts/src/pages/tabs.tsx index b44ce1c690..5083184f4b 100644 --- a/examples/vue-ts/src/pages/tabs.tsx +++ b/examples/vue-ts/src/pages/tabs.tsx @@ -12,7 +12,7 @@ export default defineComponent({ setup() { const [state, send] = useMachine( tabs.machine.withContext({ - activeId: "nils", + value: "nils", activationMode: "manual", }), ) @@ -27,25 +27,25 @@ export default defineComponent({
- - - + + +
-
+

Nils Frahm is a German musician, composer and record producer based in Berlin. He is known for combining classical and electronic music and for an unconventional approach to the piano in which he mixes a grand piano, upright piano, Roland Juno-60, Rhodes piano, drum machine, and Moog Taurus.

-
+

Agnes Caroline Thaarup Obel is a Danish singer/songwriter. Her first album, Philharmonics, was released by PIAS Recordings on 4 October 2010 in Europe. Philharmonics was certified gold in June 2011 by the Belgian Entertainment Association (BEA) for sales of 10,000 Copies.

-
+

Fear of complicated buildings:

A complex complex complex.

diff --git a/packages/machines/src/tabs/tabs.connect.ts b/packages/machines/src/tabs/tabs.connect.ts index c8cc5eaede..d513ded33b 100644 --- a/packages/machines/src/tabs/tabs.connect.ts +++ b/packages/machines/src/tabs/tabs.connect.ts @@ -14,6 +14,11 @@ export function tabsConnect( const { context: ctx } = state return { + value: ctx.value, + focusedValue: ctx.focusedValue, + setValue(value: string) { + send({ type: "SET_VALUE", value }) + }, tablistProps: normalize({ id: dom.getTablistId(ctx), role: "tablist", @@ -39,7 +44,7 @@ export function tabsConnect( send("END") }, Enter() { - send({ type: "ENTER", uid: ctx.focusedId }) + send({ type: "ENTER", value: ctx.focusedValue }) }, } @@ -53,19 +58,19 @@ export function tabsConnect( }, }), - getTabProps({ uid }: { uid: string }) { - const selected = ctx.activeId === uid + getTabProps({ value }: { value: string }) { + const selected = ctx.value === value return normalize({ role: "tab", type: "button", - "data-uid": uid, + "data-value": value, "aria-selected": selected, - "aria-controls": dom.getPanelId(ctx, uid), + "aria-controls": dom.getPanelId(ctx, value), "data-ownedby": dom.getTablistId(ctx), - id: dom.getTabId(ctx, uid), + id: dom.getTabId(ctx, value), tabIndex: selected ? 0 : -1, onFocus() { - send({ type: "TAB_FOCUS", uid }) + send({ type: "TAB_FOCUS", value }) }, onBlur(event) { const target = event.relatedTarget as HTMLElement | null @@ -74,7 +79,7 @@ export function tabsConnect( } }, onClick(event) { - send({ type: "TAB_CLICK", uid }) + send({ type: "TAB_CLICK", value }) // ensure browser focus for safari if (isSafari()) { event.currentTarget.focus() @@ -83,12 +88,12 @@ export function tabsConnect( }) }, - getTabPanelProps({ uid }: { uid: string }) { - const selected = ctx.activeId === uid + getTabPanelProps({ value }: { value: string }) { + const selected = ctx.value === value return normalize({ - id: dom.getPanelId(ctx, uid), + id: dom.getPanelId(ctx, value), tabIndex: 0, - "aria-labelledby": dom.getTabId(ctx, uid), + "aria-labelledby": dom.getTabId(ctx, value), role: "tabpanel", "data-ownedby": dom.getTablistId(ctx), hidden: !selected, diff --git a/packages/machines/src/tabs/tabs.dom.ts b/packages/machines/src/tabs/tabs.dom.ts index d60c003794..eb356b82dd 100644 --- a/packages/machines/src/tabs/tabs.dom.ts +++ b/packages/machines/src/tabs/tabs.dom.ts @@ -16,8 +16,8 @@ export const dom = { getFirstEl: (ctx: Ctx) => first(dom.getElements(ctx)), getLastEl: (ctx: Ctx) => last(dom.getElements(ctx)), - getNextEl: (ctx: Ctx, id: string) => nextById(dom.getElements(ctx), dom.getTabId(ctx, id)), - getPrevEl: (ctx: Ctx, id: string) => prevById(dom.getElements(ctx), dom.getTabId(ctx, id)), + getNextEl: (ctx: Ctx, id: string) => nextById(dom.getElements(ctx), dom.getTabId(ctx, id), ctx.loop), + getPrevEl: (ctx: Ctx, id: string) => prevById(dom.getElements(ctx), dom.getTabId(ctx, id), ctx.loop), getRectById: (ctx: Ctx, id: string) => { const tab = itemById(dom.getElements(ctx), dom.getTabId(ctx, id)) return { diff --git a/packages/machines/src/tabs/tabs.machine.ts b/packages/machines/src/tabs/tabs.machine.ts index 5f59029adf..ae8eed5f19 100644 --- a/packages/machines/src/tabs/tabs.machine.ts +++ b/packages/machines/src/tabs/tabs.machine.ts @@ -6,12 +6,50 @@ import { dom } from "./tabs.dom" const { not } = guards export type TabsMachineContext = Context<{ - focusedId?: string - activeId: string + /** + * Whether the keyboard navigation will loop from last tab to first, and vice versa. + * @default true + */ + loop: boolean + /** + * The focused tab id + */ + focusedValue: string | null + /** + * The selected tab id + */ + value: string | null + /** + * The orientation of the tabs. Can be `horizontal` or `vertical` + * - `horizontal`: only left and right arrow key navigation will work. + * - `vertical`: only up and down arrow key navigation will work. + * + * @default "horizontal" + */ orientation?: "horizontal" | "vertical" + /** + * The activation mode of the tabs. Can be `manual` or `automatic` + * - `manual`: Tabs are activated when clicked or press `enter` key. + * - `automatic`: Tabs are activated when receiving focus + * @default "automatic" + */ activationMode?: "manual" | "automatic" + /** + * @internal The active tab indicator's dom rect + */ indicatorRect?: Partial + /** + * @internal Whether the active tab indicator's rect has been measured + */ measuredRect?: boolean + /** + * Callback to be called when the selected/active tab changes + */ + onChange?: (id: string | null) => void + /** + * Callback to be called when the focused tab changes + */ + onFocus?: (id: string | null) => void }> export type TabsMachineState = { @@ -25,11 +63,21 @@ export const tabsMachine = createMachine( dir: "ltr", orientation: "horizontal", activationMode: "automatic", - activeId: "", - focusedId: "", + value: null, + focusedValue: null, uid: "", indicatorRect: { left: 0, right: 0, width: 0, height: 0 }, measuredRect: false, + loop: true, + }, + watch: { + focusedValue: "invokeOnFocus", + value: "invokeOnChange", + }, + on: { + SET_VALUE: { + actions: ["setValue"], + }, }, states: { unknown: { @@ -41,12 +89,12 @@ export const tabsMachine = createMachine( }, }, idle: { - entry: "setActiveTabRect", + entry: "setIndicatorRect", on: { - TAB_FOCUS: { target: "focused", actions: "setFocusedId" }, + TAB_FOCUS: { target: "focused", actions: "setFocusedValue" }, TAB_CLICK: { target: "focused", - actions: ["setFocusedId", "setActiveId", "setActiveTabRect"], + actions: ["setFocusedValue", "setValue", "setIndicatorRect"], }, }, }, @@ -54,26 +102,40 @@ export const tabsMachine = createMachine( on: { TAB_CLICK: { target: "focused", - actions: ["setFocusedId", "setActiveId", "setActiveTabRect"], + actions: ["setFocusedValue", "setValue", "setIndicatorRect"], + }, + ARROW_LEFT: { + cond: "isHorizontal", + actions: "focusPrevTab", + }, + ARROW_RIGHT: { + cond: "isHorizontal", + actions: "focusNextTab", + }, + ARROW_UP: { + cond: "isVertical", + actions: "focusPrevTab", + }, + ARROW_DOWN: { + cond: "isVertical", + actions: "focusNextTab", }, - ARROW_LEFT: { actions: "focusPrevTab" }, - ARROW_RIGHT: { actions: "focusNextTab" }, HOME: { actions: "focusFirstTab" }, END: { actions: "focusLastTab" }, ENTER: { - cond: not("shouldSelectOnFocus"), - actions: ["setActiveId", "setActiveTabRect"], + cond: not("selectOnFocus"), + actions: ["setValue", "setIndicatorRect"], }, TAB_FOCUS: [ { - cond: "shouldSelectOnFocus", - actions: ["setFocusedId", "setActiveId", "setActiveTabRect"], + cond: "selectOnFocus", + actions: ["setFocusedValue", "setValue", "setIndicatorRect"], }, - { actions: "setFocusedId" }, + { actions: "setFocusedValue" }, ], TAB_BLUR: { target: "idle", - actions: "resetFocusedId", + actions: "resetFocusedValue", }, }, }, @@ -81,8 +143,9 @@ export const tabsMachine = createMachine( }, { guards: { - shouldSelectOnFocus: (ctx) => ctx.activationMode === "automatic", - isRtl: (ctx) => ctx.dir === "rtl", + isVertical: (ctx) => ctx.orientation === "vertical", + isHorizontal: (ctx) => ctx.orientation === "horizontal", + selectOnFocus: (ctx) => ctx.activationMode === "automatic", }, actions: { setOwnerDocument(ctx, evt) { @@ -91,14 +154,14 @@ export const tabsMachine = createMachine( setId(ctx, evt) { ctx.uid = evt.id }, - setFocusedId(ctx, evt) { - ctx.focusedId = evt.uid + setFocusedValue(ctx, evt) { + ctx.focusedValue = evt.value }, - resetFocusedId(ctx) { - ctx.focusedId = "" + resetFocusedValue(ctx) { + ctx.focusedValue = null }, - setActiveId(ctx, evt) { - ctx.activeId = evt.uid + setValue(ctx, evt) { + ctx.value = evt.value }, focusFirstTab(ctx) { nextTick(() => dom.getFirstEl(ctx)?.focus()) @@ -107,24 +170,31 @@ export const tabsMachine = createMachine( nextTick(() => dom.getLastEl(ctx)?.focus()) }, focusNextTab(ctx) { - if (!ctx.focusedId) return - const next = dom.getNextEl(ctx, ctx.focusedId) + if (!ctx.focusedValue) return + const next = dom.getNextEl(ctx, ctx.focusedValue) nextTick(() => next?.focus()) }, focusPrevTab(ctx) { - if (!ctx.focusedId) return - const prev = dom.getPrevEl(ctx, ctx.focusedId) + if (!ctx.focusedValue) return + const prev = dom.getPrevEl(ctx, ctx.focusedValue) nextTick(() => prev?.focus()) }, - setActiveTabRect(ctx) { + setIndicatorRect(ctx) { nextTick(() => { - ctx.indicatorRect = dom.getRectById(ctx, ctx.activeId) + if (!ctx.value) return + ctx.indicatorRect = dom.getRectById(ctx, ctx.value) if (ctx.measuredRect) return nextTick(() => { ctx.measuredRect = true }) }) }, + invokeOnChange(ctx) { + ctx.onChange?.(ctx.value) + }, + invokeOnFocus(ctx) { + ctx.onFocus?.(ctx.focusedValue) + }, }, }, )