diff --git a/.codesandbox/templates/reach-ui/package.json b/.codesandbox/templates/reach-ui/package.json index 5e0e85008..fe8005453 100644 --- a/.codesandbox/templates/reach-ui/package.json +++ b/.codesandbox/templates/reach-ui/package.json @@ -30,10 +30,10 @@ "@reach/utils": "latest", "@reach/visually-hidden": "latest", "@reach/window-size": "latest", - "@types/react": "^17.0.2", - "@types/react-dom": "^17.0.2", - "react": "^17.0.1", - "react-dom": "^17.0.1", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.3", + "react": "^17.0.2", + "react-dom": "^17.0.2", "react-scripts": "3.4.1" }, "devDependencies": { diff --git a/.gitignore b/.gitignore index 395195e16..cd9c6c763 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ pids /doc.json # bundled files -/packages/*/dist +/packages/*/**/dist /packages/*/errors # editor specific diff --git a/README.md b/README.md index 76475b17d..005eca5d4 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,11 @@ This is our current release process. It's not perfect, but it has almost the rig $ yarn build $ yarn test +# If you aren't already on the main branch, be sure to check it out +# and merge release changes from `develop` +$ git checkout main +$ git merge develop + # Generate the changelog and copy it somewhere for later. We'll # automate this part eventually, but for now you can get the changelog # with: diff --git a/jest.config.js b/jest.config.js index 5b9bb6ad2..ba217f3a7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,3 +25,13 @@ module.exports = { require.resolve("jest-watch-typeahead/testname"), ], }; + +if (process.env.USE_REACT_16 === "true") { + module.exports.cacheDirectory = ".cache/jest-cache-react-16"; + module.exports.moduleNameMapper = { + ...module.exports.moduleNameMapper, + "^react-is((\\/.*)?)$": "react-is-16$1", + "^react-dom((\\/.*)?)$": "react-dom-16$1", + "^react((\\/.*)?)$": "react-16$1", + }; +} diff --git a/lerna.json b/lerna.json index dc2d82d06..9d8e4b5e1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,9 +1,15 @@ { - "version": "0.13.1", + "version": "0.15.0", "registry": "https://registry.npmjs.org/", "publishConfig": { "access": "public" }, "npmClient": "yarn", - "useWorkspaces": true + "useWorkspaces": true, + "ignoreChanges": [ + "**/__fixtures__/**", + "**/__tests__/**", + "**/*.md", + "**/examples/**" + ] } diff --git a/package.json b/package.json index accfc85fc..bad29e59b 100644 --- a/package.json +++ b/package.json @@ -10,52 +10,55 @@ }, "scripts": { "start": "start-storybook -p 9001 -c .storybook", + "test:react-16": "USE_REACT_16=true jest", "test": "jest", "build": "yarn build:packages", "build:packages": "preconstruct build", "ver": "lerna version --no-push --exact", "changes": "dotenv lerna-changelog", "clean": "git clean -e '!/.env' -e '!/website-deploy-key' -e '!/website-deploy-key.pub' -fdX .", - "lint": "eslint .", + "lint:packages": "eslint packages", + "lint": "yarn lint:packages", "fix": "manypkg fix && preconstruct fix", - "dev": "yarn start", + "dev": "preconstruct dev && yarn start", + "pc": "preconstruct", "postinstall": "manypkg check && preconstruct dev", "release": "lerna publish from-git --yes --pre-dist-tag next" }, "dependencies": { - "@babel/core": "^7.12.10", - "@babel/plugin-proposal-class-properties": "^7.12.1", - "@babel/preset-env": "^7.12.11", - "@babel/preset-react": "^7.12.10", - "@babel/preset-typescript": "^7.12.7", + "@babel/core": "^7.13.14", + "@babel/plugin-proposal-class-properties": "^7.13.0", + "@babel/preset-env": "^7.13.12", + "@babel/preset-react": "^7.13.13", + "@babel/preset-typescript": "^7.13.0", "@manypkg/cli": "^0.17.0", - "@preconstruct/cli": "^2.0.1", + "@preconstruct/cli": "^2.0.6", "@reach/router": "^1.3.4", "@storybook/addon-actions": "^6.0.28", "@storybook/addon-docs": "^6.0.28", "@storybook/addon-links": "^6.0.28", "@storybook/addons": "^6.0.28", "@storybook/react": "^6.0.28", - "@testing-library/dom": "^7.29.0", - "@testing-library/jest-dom": "^5.11.6", - "@testing-library/react": "^11.2.2", - "@testing-library/user-event": "^12.6.0", - "@types/eslint": "^7.2.0", - "@types/highlight-words-core": "^1.2.0", + "@testing-library/dom": "^7.30.1", + "@testing-library/jest-dom": "^5.11.10", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^5.1.0", + "@testing-library/user-event": "^13.0.16", + "@types/eslint": "^7.2.7", "@types/invariant": "^2.2.33", "@types/jest": "^26.0.0", "@types/lodash": "^4.14.155", "@types/node": "^12.12.47", - "@types/react": "^17.0.2", - "@types/react-dom": "^17.0.1", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.3", "@types/react-router-dom": "^5.1.7", "@types/react-test-renderer": "^17.0.1", "@types/sinon": "^9.0.4", "@types/styled-components": "^5.1.2", "@types/tabbable": "^3.1.0", "@types/warning": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^3.9.1", - "@typescript-eslint/parser": "^3.9.1", + "@typescript-eslint/eslint-plugin": "^4.20.0", + "@typescript-eslint/parser": "^4.20.0", "autoprefixer": "^9.8.6", "babel-eslint": "^10.1.0", "babel-jest": "^26.6.3", @@ -65,40 +68,44 @@ "babel-plugin-macros": "^3.0.1", "cross-env": "^7.0.2", "dotenv-cli": "^3.2.0", - "eslint": "^7.7.0", - "eslint-config-react-app": "^5.2.1", - "eslint-plugin-flowtype": "^5.2.0", - "eslint-plugin-import": "^2.22.0", - "eslint-plugin-jsx-a11y": "^6.3.1", - "eslint-plugin-react": "^7.20.6", - "eslint-plugin-react-hooks": "^4.1.0", + "eslint": "^7.23.0", + "eslint-config-react-app": "^6.0.0", + "eslint-plugin-flowtype": "^5.4.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jest": "^24.3.2", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-react": "^7.23.1", + "eslint-plugin-react-hooks": "^4.2.0", + "eslint-plugin-testing-library": "^3.10.2", "husky": "^4.2.5", - "jest": "^26.4.1", - "jest-axe": "^3.5.0", - "jest-watch-typeahead": "0.6.0", + "jest": "^26.6.3", + "jest-axe": "^4.1.0", + "jest-watch-typeahead": "0.6.1", "lerna": "^3.22.1", "lerna-changelog": "^1.0.1", - "lodash": "^4.17.20", + "lodash": "^4.17.21", "match-sorter": "^6.0.1", "prettier": "^2.0.5", "pretty-quick": "^3.0.0", "prop-types": "^15.7.2", - "react": "^17.0.1", - "react-dom": "^17.0.1", + "react": "^17.0.2", + "react-16": "npm:react@^16.14.0", + "react-dom": "^17.0.2", + "react-dom-16": "npm:react-dom@^16.14.0", + "react-is": "^17.0.2", + "react-is-16": "npm:react-is@^16.13.1", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-spring": "^8.0.27", "react-test-renderer": "^16.13.1", "sinon": "^9.0.3", "styled-components": "^5.1.1", - "ts-jest": "^26.3.0", - "ts-loader": "^8.0.2", - "ts-node": "^8.10.2", - "typescript": "^4.0.2" + "ts-jest": "^26.5.4", + "typescript": "^4.2.3" }, "resolutions": { - "@types/react": "^17.0.2", - "@types/react-dom": "^17.0.1" + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.3" }, "workspaces": [ "packages/*" @@ -131,15 +138,8 @@ "__DEV__": "readonly" }, "rules": { - "no-unused-vars": [ - 1, - { - "args": "after-used", - "ignoreRestSiblings": true, - "argsIgnorePattern": "^(event|_)$" - } - ], "import/first": 0, + "import/no-anonymous-default-export": 0, "jsx-a11y/no-static-element-interactions": [ 1, { @@ -157,7 +157,7 @@ }, "eslintIgnore": [ "node_modules", - "packages/*/dist" + "packages/*/**/dist" ], "prettier": { "singleQuote": false diff --git a/packages/accordion/__tests__/accordion.test.tsx b/packages/accordion/__tests__/accordion.test.tsx index 6c4b3e1fc..b29473318 100644 --- a/packages/accordion/__tests__/accordion.test.tsx +++ b/packages/accordion/__tests__/accordion.test.tsx @@ -1,6 +1,5 @@ import * as React from "react"; -import { render, act, fireEvent } from "$test/utils"; -import { AxeResults } from "$test/types"; +import { render, fireEvent } from "$test/utils"; import { axe } from "jest-axe"; import { Accordion, @@ -11,110 +10,81 @@ import { describe("", () => { describe("a11y", () => { - it("should not have basic a11y issues", async () => { - let { container, getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); - let results: AxeResults = null as any; - await act(async () => { - results = await axe(container); - }); + it("Should not have ARIA violations", async () => { + let { container, buttons } = renderTestAccordion(); + let results = await axe(container); + expect(results).toHaveNoViolations(); - act(() => void fireEvent.click(getByText("Button One"))); - await act(async () => { - results = await axe(container); - }); + // Toggle to another panel and check again + fireEvent.click(buttons[1]); + results = await axe(container); expect(results).toHaveNoViolations(); }); - it("accepts a custom ID", () => { - let { getByTestId } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + describe("ARIA attributes", () => { + let buttons: HTMLElement[]; + let panels: HTMLElement[]; + beforeEach(() => { + let rendered = renderTestAccordion((props) => ( + + )); + buttons = rendered.buttons; + panels = rendered.panels; + }); - expect(getByTestId("wrapper")).toHaveAttribute("id", "test-id"); - }); + it("`role` is set to `region` for panel elements", () => { + expect(panels[0]).toHaveAttribute("role", "region"); + expect(panels[1]).toHaveAttribute("role", "region"); + }); - it("sets the correct state-related aria attributes on toggle", () => { - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + it("`aria-controls` for button elements points to the corresponding panel element id", () => { + for (let i = 0; i < panels.length; i++) { + let id = panels[i].getAttribute("id"); + expect(buttons[i]).toHaveAttribute("aria-controls", id); + } + }); - expect(getByText("Button One")).toHaveAttribute("aria-expanded", "true"); - expect(getByText("Panel One")).toHaveAttribute("data-state", "open"); + it("`aria-labelledby` for panel elements points to the corresponding button element id", () => { + for (let i = 0; i < panels.length; i++) { + let id = buttons[i].getAttribute("id"); + expect(panels[i]).toHaveAttribute("aria-labelledby", id); + } + }); - expect(getByText("Button Two")).toHaveAttribute("aria-expanded", "false"); - expect(getByText("Panel Two")).toHaveAttribute("data-state", "collapsed"); - expect(getByText("Panel Two")).toHaveAttribute("hidden"); + it("`aria-expanded` is true for the active button element", () => { + expect(buttons[0]).toHaveAttribute("aria-expanded", "true"); + }); + + it("`aria-expanded` is false for the inactive button element", () => { + expect(buttons[1]).toHaveAttribute("aria-expanded", "false"); + }); }); }); describe("rendering", () => { - it("should open the first panel by default", () => { - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); - - expect(getByText("Panel One")).toBeVisible(); - expect(getByText("Panel Two")).not.toBeVisible(); + it("passes DOM props to the wrapper", () => { + let { wrapper } = renderTestAccordion((props) => ( + + )); + expect(wrapper).toHaveAttribute("id", "test-id"); }); - it("should not open any panels by default when using collapsed", () => { - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + it("should show the first panel by default", () => { + let { panels } = renderTestAccordion(); + expect(panels[0]).toBeVisible(); + expect(panels[1]).not.toBeVisible(); + }); - expect(getByText("Panel One")).not.toBeVisible(); - expect(getByText("Panel Two")).not.toBeVisible(); + it("should not show any panels by default when using `collapsed` prop", () => { + let { panels } = renderTestAccordion((props) => ( + + )); + expect(panels[0]).not.toBeVisible(); + expect(panels[1]).not.toBeVisible(); }); - it("should open panel as specified by defaultIndex", () => { + it("should show panel specified by defaultIndex", () => { let { getByText } = render( @@ -132,114 +102,182 @@ describe("", () => { expect(getByText("Panel Two")).toBeVisible(); }); - it("assigns the correct @reach data attributes", () => { - let { getByTestId, getByText } = render( - - - Button One - Panel One - - - ); - expect(getByTestId("wrapper")).toHaveAttribute("data-reach-accordion"); - expect(getByTestId("item1")).toHaveAttribute("data-reach-accordion-item"); - expect(getByText("Button One")).toHaveAttribute( - "data-reach-accordion-button" - ); - expect(getByText("Panel One")).toHaveAttribute( - "data-reach-accordion-panel" - ); + describe("Internal DOM attributes", () => { + let wrapper: HTMLElement; + let items: HTMLElement[]; + let buttons: HTMLElement[]; + let panels: HTMLElement[]; + beforeEach(() => { + let rendered = renderTestAccordion((props) => ( + + )); + wrapper = rendered.wrapper; + items = rendered.items; + buttons = rendered.buttons; + panels = rendered.panels; + }); + + it("`data-reach-accordion` is present on the wrapper element", () => { + expect(wrapper).toHaveAttribute("data-reach-accordion"); + }); + + it("`data-reach-accordion-item` is present on the item elements", () => { + for (let item of items) { + expect(item).toHaveAttribute("data-reach-accordion-item"); + } + }); + + it("`data-reach-accordion-button` is present on the button elements", () => { + for (let button of buttons) { + expect(button).toHaveAttribute("data-reach-accordion-button"); + } + }); + + it("`data-reach-accordion-panel` is present on the panel elements", () => { + for (let panel of panels) { + expect(panel).toHaveAttribute("data-reach-accordion-panel"); + } + }); + + it("`data-state` is `open` for the active button element", () => { + expect(buttons[0]).toHaveAttribute("data-state", "open"); + }); + + it("`data-state` is `collapsed` for the inactive button element", () => { + expect(buttons[1]).toHaveAttribute("data-state", "collapsed"); + }); + + it("`data-state` is `open` for the active panel element", () => { + expect(panels[0]).toHaveAttribute("data-state", "open"); + }); + + it("`data-state` is `collapsed` for the inactive panel element", () => { + expect(panels[1]).toHaveAttribute("data-state", "collapsed"); + }); + + it("`hidden` is not present for the active panel element", () => { + expect(panels[0]).not.toHaveAttribute("hidden"); + }); + + it("`hidden` is present for the inactive panel element", () => { + expect(panels[1]).toHaveAttribute("hidden"); + }); }); }); describe("user events", () => { - it("should change panel on click", () => { - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + describe("when clicking an inactive button", () => { + it("should change the visible panel", () => { + let { panels, buttons } = renderTestAccordion(); - let panelOneContent = getByText("Panel One"); + expect(panels[1]).not.toBeVisible(); + expect(panels[0]).toBeVisible(); - expect(panelOneContent).toBeVisible(); - fireEvent.click(getByText("Button Two")); - expect(panelOneContent).not.toBeVisible(); - expect(getByText("Panel Two")).toBeVisible(); - }); + fireEvent.click(buttons[1]); + expect(panels[0]).not.toBeVisible(); + expect(panels[1]).toBeVisible(); + }); - it("should call onChange", () => { - let mockOnChange = jest.fn(); - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + it("should call `onChange`", () => { + let mockOnChange = jest.fn(); + let { buttons } = renderTestAccordion((props) => ( + + )); - fireEvent.click(getByText("Button Two")); - expect(mockOnChange).toHaveBeenCalledTimes(1); + fireEvent.click(buttons[1]); + expect(mockOnChange).toHaveBeenCalledTimes(1); + }); }); - it("should allow collapsing when collapsible", () => { - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + describe("when navigating between focused buttons", () => { + // let panels: HTMLElement[]; + let buttons: HTMLElement[]; + beforeEach(() => { + let rendered = renderTestAccordion(); + // panels = rendered.panels; + buttons = rendered.buttons; + }); + + it("should move focus to the next focusable button on `ArrowDown` press", () => { + buttons[0].focus(); + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + expect(buttons[1]).toHaveFocus(); + }); - let panelOneContent = getByText("Panel One"); - let panelOneButton = getByText("Button One"); + it("should move focus to the previous focusable button on `ArrowUp` press", () => { + buttons[1].focus(); + fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" }); + expect(buttons[0]).toHaveFocus(); + }); + + it("should move focus to the first focusable button on `Home` press", () => { + buttons[1].focus(); + fireEvent.keyDown(document.activeElement!, { key: "Home" }); + expect(buttons[0]).toHaveFocus(); + }); - expect(panelOneContent).not.toBeVisible(); - fireEvent.click(panelOneButton); - expect(panelOneContent).toBeVisible(); - fireEvent.click(panelOneButton); - expect(panelOneContent).not.toBeVisible(); + it("should move focus to the last focusable button on `End` press", () => { + buttons[0].focus(); + fireEvent.keyDown(document.activeElement!, { key: "End" }); + expect(buttons[buttons.length - 1]).toHaveFocus(); + }); }); - it("should allow multiple when multiple", () => { - let { getByText } = render( - - - Button One - Panel One - - - Button Two - Panel Two - - - ); + describe("with a fully collapsible accordion", () => { + it("should allow collapsing the open panel", () => { + let { panels, buttons } = renderTestAccordion((props) => ( + + )); + fireEvent.click(buttons[0]); + expect(panels[0]).not.toBeVisible(); + }); + }); - let panelOneContent = getByText("Panel One"); - let panelTwoContent = getByText("Panel Two"); + describe("with a multi-select accordion", () => { + it("should allow multiple visible panels", () => { + let { panels, buttons } = renderTestAccordion((props) => ( + + )); - expect(panelOneContent).toBeVisible(); - expect(panelTwoContent).not.toBeVisible(); - fireEvent.click(getByText("Button Two")); - expect(panelOneContent).toBeVisible(); - expect(panelTwoContent).toBeVisible(); + fireEvent.click(buttons[1]); + expect(panels[0]).toBeVisible(); + expect(panels[1]).toBeVisible(); + }); }); }); }); + +function renderTestAccordion(wrapper?: React.ComponentType) { + let Outer = wrapper || Accordion; + let { getByText, getByTestId, container } = render( + + + Button One + Panel One + + + Button Two + Panel Two + + + Button Three + Panel Three + + + ); + return { + container, + wrapper: getByTestId("wrapper"), + items: [getByTestId("item1"), getByTestId("item2"), getByTestId("item3")], + buttons: [ + getByText("Button One"), + getByText("Button Two"), + getByText("Button Three"), + ], + panels: [ + getByText("Panel One"), + getByText("Panel Two"), + getByText("Panel Three"), + ], + }; +} diff --git a/packages/accordion/package.json b/packages/accordion/package.json index 03682b8c2..22b437b64 100644 --- a/packages/accordion/package.json +++ b/packages/accordion/package.json @@ -1,24 +1,28 @@ { "name": "@reach/accordion", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React accordion component", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/accordion" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/descendants": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/descendants": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/accordion/src/index.tsx b/packages/accordion/src/index.tsx index 07fc75caf..f388dd46e 100644 --- a/packages/accordion/src/index.tsx +++ b/packages/accordion/src/index.tsx @@ -9,18 +9,14 @@ */ import * as React from "react"; -import { - createNamedContext, - forwardRefWithAs, - isBoolean, - isNumber, - makeId, - noop, - useCheckStyles, - useForkedRef, - warning, - wrapEvent, -} from "@reach/utils"; +import { createNamedContext } from "@reach/utils/context"; +import { isBoolean, isNumber } from "@reach/utils/type-check"; +import { makeId } from "@reach/utils/make-id"; +import { noop } from "@reach/utils/noop"; +import { useCheckStyles } from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; +import warning from "tiny-warning"; import { createDescendantContext, DescendantProvider, @@ -31,6 +27,7 @@ import { import { useId } from "@reach/auto-id"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { Descendant } from "@reach/descendants"; const AccordionDescendantContext = createDescendantContext( @@ -62,7 +59,7 @@ enum AccordionStates { * * @see Docs https://reach.tech/accordion#accordion-1 */ -const Accordion = forwardRefWithAs(function Accordion( +const Accordion = React.forwardRef(function Accordion( { as: Comp = "div", children, @@ -205,12 +202,12 @@ const Accordion = forwardRefWithAs(function Accordion( ); -}); +}) as Polymorphic.ForwardRefComponent<"div", AccordionProps>; /** * @see Docs https://reach.tech/accordion#accordion-props */ -type AccordionProps = { +interface AccordionProps { /** * `Accordion` can accept `AccordionItem` components as children. * @@ -276,7 +273,7 @@ type AccordionProps = { * by the index prop. */ multiple?: boolean; -}; +} if (__DEV__) { Accordion.displayName = "Accordion"; @@ -340,10 +337,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/accordion#accordionitem */ -const AccordionItem = forwardRefWithAs< - AccordionItemProps & React.ComponentPropsWithRef<"div">, - "div" ->(function AccordionItem( +const AccordionItem = React.forwardRef(function AccordionItem( { as: Comp = "div", children, disabled = false, ...props }, forwardedRef ) { @@ -395,12 +389,12 @@ const AccordionItem = forwardRefWithAs< ); -}); +}) as Polymorphic.ForwardRefComponent<"div", AccordionItemProps>; /** * @see Docs https://reach.tech/accordion#accordionitem-props */ -type AccordionItemProps = { +interface AccordionItemProps { /** * An `AccordionItem` expects to receive an `AccordionButton` and * `AccordionPanel` components as its children, though you can also nest other @@ -417,7 +411,7 @@ type AccordionItemProps = { * @see Docs https://reach.tech/accordion#accordionitem-disabled */ disabled?: boolean; -}; +} if (__DEV__) { AccordionItem.displayName = "AccordionItem"; @@ -437,112 +431,111 @@ if (__DEV__) { * * @see Docs https://reach.tech/accordion#accordionbutton */ -const AccordionButton = forwardRefWithAs( - function AccordionButton( - { - as: Comp = "button", - children, - onClick, - onKeyDown, - onMouseDown, - onPointerDown, - tabIndex, - ...props - }, - forwardedRef - ) { - let { onSelectPanel } = React.useContext(AccordionContext); +const AccordionButton = React.forwardRef(function AccordionButton( + { + as: Comp = "button", + children, + onClick, + onKeyDown, + onMouseDown, + onPointerDown, + tabIndex, + ...props + }, + forwardedRef +) { + let { onSelectPanel } = React.useContext(AccordionContext); - let { - disabled, - buttonId, - buttonRef: ownRef, - index, - panelId, - state, - } = React.useContext(AccordionItemContext); + let { + disabled, + buttonId, + buttonRef: ownRef, + index, + panelId, + state, + } = React.useContext(AccordionItemContext); - let ref = useForkedRef(forwardedRef, ownRef); + let ref = useComposedRefs(forwardedRef, ownRef); - function handleClick(event: React.MouseEvent) { - event.preventDefault(); - if (disabled) { - return; - } - ownRef.current.focus(); - onSelectPanel(index); + function handleClick(event: React.MouseEvent) { + event.preventDefault(); + if (disabled) { + return; } - - let handleKeyDown = useDescendantKeyDown(AccordionDescendantContext, { - currentIndex: index, - orientation: "vertical", - key: "element", - rotate: true, - callback(element: HTMLElement) { - element && element.focus(); - }, - filter: (button) => !button.disabled, - }); - - return ( - - //

- // Click Me - //

- // - // - - // The title of each accordion header is contained in an element with - // role `button`. We use an HTML button by default, so we can omit - // this attribute. - // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion - // role="button" - - // The accordion header `button` element has `aria-controls` set to the - // ID of the element containing the accordion panel content. - // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion - aria-controls={panelId} - // If the accordion panel associated with an accordion header is - // visible, the header `button` element has `aria-expanded` set to - // `true`. If the panel is not visible, `aria-expanded` is set to - // `false`. - // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion - aria-expanded={state === AccordionStates.Open} - tabIndex={disabled ? -1 : tabIndex} - {...props} - ref={ref} - data-reach-accordion-button="" - // If the accordion panel associated with an accordion header is - // visible, and if the accordion does not permit the panel to be - // collapsed, the header `button` element has `aria-disabled` set to - // `true`. We can use `disabled` since we opt for an HTML5 `button` - // element. - // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion - disabled={disabled || undefined} - id={buttonId} - onClick={wrapEvent(onClick, handleClick)} - onKeyDown={wrapEvent(onKeyDown, handleKeyDown)} - > - {children} -
- ); + ownRef.current.focus(); + onSelectPanel(index); } -); + + let handleKeyDown = useDescendantKeyDown(AccordionDescendantContext, { + currentIndex: index, + orientation: "vertical", + key: "element", + rotate: true, + callback(element: HTMLElement) { + element && element.focus(); + }, + filter: (button) => !button.disabled, + }); + + return ( + + //

+ // Click Me + //

+ // + // + + // The title of each accordion header is contained in an element with + // role `button`. We use an HTML button by default, so we can omit + // this attribute. + // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion + // role="button" + + // The accordion header `button` element has `aria-controls` set to the + // ID of the element containing the accordion panel content. + // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion + aria-controls={panelId} + // If the accordion panel associated with an accordion header is + // visible, the header `button` element has `aria-expanded` set to + // `true`. If the panel is not visible, `aria-expanded` is set to + // `false`. + // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion + aria-expanded={state === AccordionStates.Open} + tabIndex={disabled ? -1 : tabIndex} + {...props} + ref={ref} + data-reach-accordion-button="" + data-state={getDataState(state)} + // If the accordion panel associated with an accordion header is + // visible, and if the accordion does not permit the panel to be + // collapsed, the header `button` element has `aria-disabled` set to + // `true`. We can use `disabled` since we opt for an HTML5 `button` + // element. + // https://www.w3.org/TR/wai-aria-practices-1.2/#accordion + disabled={disabled || undefined} + id={buttonId} + onClick={composeEventHandlers(onClick, handleClick)} + onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)} + > + {children} +
+ ); +}) as Polymorphic.ForwardRefComponent<"button", AccordionButtonProps>; /** * @see Docs https://reach.tech/accordion#accordionbutton-props */ -type AccordionButtonProps = { +interface AccordionButtonProps { /** * Typically a text string that serves as a label for the accordion, though * nested DOM nodes can be passed as well so long as they are valid children @@ -552,7 +545,7 @@ type AccordionButtonProps = { * @see Docs https://reach.tech/accordion#accordionbutton-children */ children: React.ReactNode; -}; +} if (__DEV__) { AccordionButton.displayName = "AccordionButton"; @@ -572,58 +565,55 @@ if (__DEV__) { * * @see Docs https://reach.tech/accordion#accordionpanel */ -const AccordionPanel = forwardRefWithAs( - function AccordionPanel( - { as: Comp = "div", children, ...props }, - forwardedRef - ) { - const { disabled, panelId, buttonId, state } = React.useContext( - AccordionItemContext - ); +const AccordionPanel = React.forwardRef(function AccordionPanel( + { as: Comp = "div", children, ...props }, + forwardedRef +) { + const { disabled, panelId, buttonId, state } = React.useContext( + AccordionItemContext + ); - return ( - - ); - } -); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", AccordionPanelProps>; /** * @see Docs https://reach.tech/accordion#accordionpanel-props */ -type AccordionPanelProps = { +interface AccordionPanelProps { /** * Inner collapsible content for the accordion item. * * @see Docs https://reach.tech/accordion#accordionpanel-children */ children: React.ReactNode; -}; +} if (__DEV__) { AccordionPanel.displayName = "AccordionPanel"; @@ -677,22 +667,20 @@ function getDataState(state: AccordionStates) { //////////////////////////////////////////////////////////////////////////////// // Types -type AccordionContextValue = { +interface AccordionContextValue { id: string | undefined; openPanels: number[]; -}; +} -type AccordionItemContextValue = { +interface AccordionItemContextValue { index: number; isExpanded: boolean; -}; +} type AccordionDescendant = Descendant & { disabled: boolean; }; -type ResultBox = { v: T }; - type ButtonRef = React.MutableRefObject; type AccordionIndex = number | number[]; diff --git a/packages/alert-dialog/__tests__/alert-dialog.test.tsx b/packages/alert-dialog/__tests__/alert-dialog.test.tsx index fedc37d76..40ef4506c 100644 --- a/packages/alert-dialog/__tests__/alert-dialog.test.tsx +++ b/packages/alert-dialog/__tests__/alert-dialog.test.tsx @@ -26,7 +26,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { let { container, getByText, getByTestId } = render(); let results: AxeResults = null as any; await act(async () => { diff --git a/packages/alert-dialog/package.json b/packages/alert-dialog/package.json index 3fb0299bf..fb3ec2ffc 100644 --- a/packages/alert-dialog/package.json +++ b/packages/alert-dialog/package.json @@ -1,25 +1,28 @@ { "name": "@reach/alert-dialog", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React Alert Dialog.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/alert-dialog" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/dialog": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/dialog": "0.15.0", + "@reach/utils": "0.15.0", "invariant": "^2.2.4", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/alert-dialog/src/index.tsx b/packages/alert-dialog/src/index.tsx index 37620a1b8..210da9034 100644 --- a/packages/alert-dialog/src/index.tsx +++ b/packages/alert-dialog/src/index.tsx @@ -33,16 +33,14 @@ import * as React from "react"; import { DialogOverlay, DialogContent } from "@reach/dialog"; import { useId } from "@reach/auto-id"; -import { - createNamedContext, - forwardRefWithAs, - getOwnerDocument, - makeId, - useForkedRef, -} from "@reach/utils"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { createNamedContext } from "@reach/utils/context"; +import { makeId } from "@reach/utils/make-id"; +import { useComposedRefs } from "@reach/utils/compose-refs"; import invariant from "invariant"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { DialogProps, DialogContentProps } from "@reach/dialog"; let AlertDialogContext = createNamedContext( @@ -63,33 +61,34 @@ let AlertDialogContext = createNamedContext( * * @see Docs https://reach.tech/alert-dialog#alertdialogoverlay */ -const AlertDialogOverlay = forwardRefWithAs( - function AlertDialogOverlay({ leastDestructiveRef, ...props }, forwardedRef) { - let ownRef = React.useRef(null); - let ref = useForkedRef(forwardedRef, ownRef); - let id = useId(props.id); - let labelId = id ? makeId("alert-dialog", id) : undefined; - let descriptionId = id ? makeId("alert-dialog-description", id) : undefined; +const AlertDialogOverlay = React.forwardRef(function AlertDialogOverlay( + { leastDestructiveRef, ...props }, + forwardedRef +) { + let ownRef = React.useRef(null); + let ref = useComposedRefs(forwardedRef, ownRef); + let id = useId(props.id); + let labelId = id ? makeId("alert-dialog", id) : undefined; + let descriptionId = id ? makeId("alert-dialog-description", id) : undefined; - return ( - - - - ); - } -); + return ( + + + + ); +}) as Polymorphic.ForwardRefComponent<"div", AlertDialogProps>; if (__DEV__) { AlertDialogOverlay.displayName = "AlertDialogOverlay"; @@ -117,65 +116,66 @@ if (__DEV__) { * * @see Docs https://reach.tech/alert-dialog#alertdialogcontent */ -const AlertDialogContent = forwardRefWithAs( - function AlertDialogContent({ children, ...props }, forwardedRef) { - let { - descriptionId, - labelId, - leastDestructiveRef, - overlayRef, - } = React.useContext(AlertDialogContext); - React.useEffect(() => { - let ownerDocument = getOwnerDocument(overlayRef.current)!; - if (labelId) { - invariant( - ownerDocument.getElementById(labelId), - `@reach/alert-dialog: You must render a \`\` - inside an \`\`.` - ); - } +const AlertDialogContent = React.forwardRef(function AlertDialogContent( + { children, ...props }, + forwardedRef +) { + let { + descriptionId, + labelId, + leastDestructiveRef, + overlayRef, + } = React.useContext(AlertDialogContext); + React.useEffect(() => { + let ownerDocument = getOwnerDocument(overlayRef.current)!; + if (labelId) { invariant( - leastDestructiveRef, - `@reach/alert-dialog: You must provide a \`leastDestructiveRef\` to + ownerDocument.getElementById(labelId), + `@reach/alert-dialog: You must render a \`\` + inside an \`\`.` + ); + } + invariant( + leastDestructiveRef, + `@reach/alert-dialog: You must provide a \`leastDestructiveRef\` to \`\` or \`\`. Please see https://ui.reach.tech/alert-dialog/#alertdialogoverlay-leastdestructiveref` - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [labelId, leastDestructiveRef]); - return ( - - {children} - ); - } -); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [labelId, leastDestructiveRef]); + return ( + + {children} + + ); +}) as Polymorphic.ForwardRefComponent<"div", AlertDialogContentProps>; /** * @see Docs https://reach.tech/alert-dialog#alertdialogcontent-props */ -type AlertDialogContentProps = { +interface AlertDialogContentProps extends DialogContentProps { /** * Accepts any renderable content but should generally be restricted to * `AlertDialogLabel`, `AlertDialogDescription` and action buttons, other @@ -184,7 +184,7 @@ type AlertDialogContentProps = { * @see Docs https://reach.tech/alert-dialog#alertdialogcontent-children */ children: React.ReactNode; -} & DialogContentProps; +} if (__DEV__) { AlertDialogContent.displayName = "AlertDialogContent"; @@ -206,25 +206,26 @@ if (__DEV__) { * * @see Docs https://reach.tech/alert-dialog#alertdialoglabel */ -const AlertDialogLabel = forwardRefWithAs( - function ({ as: Comp = "div", ...props }, forwardedRef) { - const { labelId } = React.useContext(AlertDialogContext); - return ( - - ); - } -); +const AlertDialogLabel = React.forwardRef(function ( + { as: Comp = "div", ...props }, + forwardedRef +) { + const { labelId } = React.useContext(AlertDialogContext); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", AlertDialogLabelProps>; if (__DEV__) { AlertDialogLabel.displayName = "AlertDialogLabel"; } -type AlertDialogLabelProps = {}; +interface AlertDialogLabelProps {} //////////////////////////////////////////////////////////////////////////////// @@ -238,10 +239,7 @@ type AlertDialogLabelProps = {}; * @see Docs https://reach.tech/alert-dialog#alertdialogdescription * @param props */ -const AlertDialogDescription = forwardRefWithAs< - AlertDialogDescriptionProps, - "div" ->(function AlertDialogDescription( +const AlertDialogDescription = React.forwardRef(function AlertDialogDescription( { as: Comp = "div", ...props }, forwardedRef ) { @@ -254,13 +252,13 @@ const AlertDialogDescription = forwardRefWithAs< data-reach-alert-dialog-description /> ); -}); +}) as Polymorphic.ForwardRefComponent<"div", AlertDialogDescriptionProps>; if (__DEV__) { AlertDialogDescription.displayName = "AlertDialogDescription"; } -type AlertDialogDescriptionProps = {}; +interface AlertDialogDescriptionProps {} //////////////////////////////////////////////////////////////////////////////// @@ -272,23 +270,21 @@ type AlertDialogDescriptionProps = {}; * @see Docs https://reach.tech/alert-dialog#alertdialog * @param props */ -const AlertDialog = forwardRefWithAs( - function AlertDialog( - { id, isOpen, onDismiss, leastDestructiveRef, ...props }, - forwardedRef - ) { - return ( - - - - ); - } -); +const AlertDialog = React.forwardRef(function AlertDialog( + { id, isOpen, onDismiss, leastDestructiveRef, ...props }, + forwardedRef +) { + return ( + + + + ); +}) as Polymorphic.ForwardRefComponent<"div", AlertDialogProps>; /** * @see Docs https://reach.tech/alert-dialog#alertdialog-props */ -type AlertDialogProps = { +interface AlertDialogProps extends DialogProps { id?: string; /** * Controls whether the dialog is open or not. @@ -319,7 +315,7 @@ type AlertDialogProps = { * @see Docs: https://reach.tech/alert-dialog#alertdialog-children */ children: React.ReactNode; -} & DialogProps; +} if (__DEV__) { AlertDialog.displayName = "AlertDialog"; diff --git a/packages/alert/__tests__/alert.test.tsx b/packages/alert/__tests__/alert.test.tsx index 475c68273..d371d1ffb 100644 --- a/packages/alert/__tests__/alert.test.tsx +++ b/packages/alert/__tests__/alert.test.tsx @@ -2,15 +2,15 @@ import * as React from "react"; import { render, act, fireEvent } from "$test/utils"; import { AxeResults } from "$test/types"; import { axe } from "jest-axe"; -import Alert from "@reach/alert"; -import { usePrevious } from "@reach/utils"; -import VisuallyHidden from "@reach/visually-hidden"; +import { Alert } from "@reach/alert"; +import { VisuallyHidden } from "@reach/visually-hidden"; +import { usePrevious } from "@reach/utils/use-previous"; const MESSAGE_TIMEOUT = 5000; describe("", () => { describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { let { container, getByTestId } = render(); let results: AxeResults = null as any; await act(async () => { diff --git a/packages/alert/examples/basic-ts.example.tsx b/packages/alert/examples/basic-ts.example.tsx index f0eb33f30..1b6f0203e 100644 --- a/packages/alert/examples/basic-ts.example.tsx +++ b/packages/alert/examples/basic-ts.example.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import Alert from "@reach/alert"; -import { usePrevious } from "@reach/utils"; -import VisuallyHidden from "@reach/visually-hidden"; +import { Alert } from "@reach/alert"; +import { VisuallyHidden } from "@reach/visually-hidden"; +import { usePrevious } from "@reach/utils/use-previous"; const MESSAGE_TIMEOUT = 5000; diff --git a/packages/alert/examples/basic.example.js b/packages/alert/examples/basic.example.js index 73338a136..5693c2654 100644 --- a/packages/alert/examples/basic.example.js +++ b/packages/alert/examples/basic.example.js @@ -1,7 +1,7 @@ import * as React from "react"; -import Alert from "@reach/alert"; -import { usePrevious } from "@reach/utils"; -import VisuallyHidden from "@reach/visually-hidden"; +import { Alert } from "@reach/alert"; +import { usePrevious } from "@reach/utils/use-previous"; +import { VisuallyHidden } from "@reach/visually-hidden"; import LoremIpsum from "./LoremIpsum.js"; let name = "Basic"; diff --git a/packages/alert/package.json b/packages/alert/package.json index 06f48cdca..f96c9b642 100644 --- a/packages/alert/package.json +++ b/packages/alert/package.json @@ -1,23 +1,26 @@ { "name": "@reach/alert", - "version": "0.13.1", + "version": "0.15.0", "description": "Screen-reader-friendly alert messages.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/alert" }, "dependencies": { - "@reach/utils": "0.13.1", - "@reach/visually-hidden": "0.13.1", + "@reach/utils": "0.15.0", + "@reach/visually-hidden": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/alert/src/index.tsx b/packages/alert/src/index.tsx index 912f7d43d..331787998 100644 --- a/packages/alert/src/index.tsx +++ b/packages/alert/src/index.tsx @@ -25,14 +25,13 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import { VisuallyHidden } from "@reach/visually-hidden"; -import { - forwardRefWithAs, - getOwnerDocument, - usePrevious, - useForkedRef, -} from "@reach/utils"; +import { usePrevious } from "@reach/utils/use-previous"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { useComposedRefs } from "@reach/utils/compose-refs"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; + /* * Singleton state is fine because you don't server render * an alert (SRs don't read them on first load anyway) @@ -65,12 +64,12 @@ let renderTimer: number | null; * * @see Docs https://reach.tech/alert */ -const Alert = forwardRefWithAs(function Alert( +const Alert = React.forwardRef(function Alert( { as: Comp = "div", children, type: regionType = "polite", ...props }, forwardedRef ) { const ownRef = React.useRef(null); - const ref = useForkedRef(forwardedRef, ownRef); + const ref = useComposedRefs(forwardedRef, ownRef); const child = React.useMemo( () => ( @@ -83,12 +82,12 @@ const Alert = forwardRefWithAs(function Alert( useMirrorEffects(regionType, child, ownRef); return child; -}); +}) as Polymorphic.ForwardRefComponent<"div", AlertProps>; /** * @see Docs https://reach.tech/alert#alert-props */ -type AlertProps = { +interface AlertProps { /** * Controls whether the assistive technology should read immediately * ("assertive") or wait until the user is idle ("polite"). @@ -97,7 +96,7 @@ type AlertProps = { */ type?: "assertive" | "polite"; children: React.ReactNode; -}; +} if (__DEV__) { Alert.displayName = "Alert"; diff --git a/packages/auto-id/package.json b/packages/auto-id/package.json index d44a5f816..0dc9e9087 100644 --- a/packages/auto-id/package.json +++ b/packages/auto-id/package.json @@ -1,21 +1,24 @@ { "name": "@reach/auto-id", - "version": "0.13.1", + "version": "0.15.0", "description": "Autogenerate IDs to facilitate WAI-ARIA and server rendering.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/auto-id" }, "dependencies": { - "@reach/utils": "0.13.1", - "tslib": "^2.0.0" + "@reach/utils": "0.15.0", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/auto-id/src/index.tsx b/packages/auto-id/src/index.tsx index b0c4df8c5..6386015eb 100644 --- a/packages/auto-id/src/index.tsx +++ b/packages/auto-id/src/index.tsx @@ -55,7 +55,7 @@ */ import * as React from "react"; -import { useIsomorphicLayoutEffect } from "@reach/utils"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; let serverHandoffComplete = false; let id = 0; @@ -84,7 +84,7 @@ function useId(idFromProps?: string | null) { const [id, setId] = React.useState(initialId); - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { if (id === null) { /* * Patch the ID after render. We do this in `useLayoutEffect` to avoid any diff --git a/packages/checkbox/__tests__/checkbox.test.tsx b/packages/checkbox/__tests__/checkbox.test.tsx index d608404aa..5d10e0e18 100644 --- a/packages/checkbox/__tests__/checkbox.test.tsx +++ b/packages/checkbox/__tests__/checkbox.test.tsx @@ -9,12 +9,12 @@ import { import { render, fireEvent } from "$test/utils"; describe("", () => { - it("should not have basic a11y issues after render", async () => { + it("Should not have ARIA violations after render", async () => { let { container } = render(); await expect(container).toHaveNoAxeViolations(); }); - it("should not have basic a11y issues after initial click", async () => { + it("Should not have ARIA violations after initial click", async () => { let { container, getByTestId } = render(); fireEvent.click(getByTestId("checkbox")); await expect(container).toHaveNoAxeViolations(); @@ -24,18 +24,18 @@ describe("", () => { }); describe("", () => { - it("should not have basic a11y issues after render", async () => { + it("Should not have ARIA violations after render", async () => { let { container } = render(); await expect(container).toHaveNoAxeViolations(); }); - it("should not have basic a11y issues after initial click (1)", async () => { + it("Should not have ARIA violations after initial click (1)", async () => { let { container, getByTestId } = render(); fireEvent.click(getByTestId("checkbox-1")); await expect(container).toHaveNoAxeViolations(); }); - it("should not have basic a11y issues after initial click (2)", async () => { + it("Should not have ARIA violations after initial click (2)", async () => { let { container, getByTestId } = render(); fireEvent.click(getByTestId("checkbox-2")); await expect(container).toHaveNoAxeViolations(); diff --git a/packages/checkbox/package.json b/packages/checkbox/package.json index a92fa29a4..2e3467195 100644 --- a/packages/checkbox/package.json +++ b/packages/checkbox/package.json @@ -1,25 +1,29 @@ { "name": "@reach/checkbox", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible components to build custom, tri-state checkboxes in React.", "author": "React Training ", "license": "MIT", "source": "", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/checkbox" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/machine": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/machine": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/checkbox/src/custom.tsx b/packages/checkbox/src/custom.tsx index 87b1bf651..b925ba6d7 100644 --- a/packages/checkbox/src/custom.tsx +++ b/packages/checkbox/src/custom.tsx @@ -32,14 +32,11 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ import * as React from "react"; -import { - createNamedContext, - forwardRefWithAs, - isFunction, - useCheckStyles, - useForkedRef, - wrapEvent, -} from "@reach/utils"; +import { createNamedContext } from "@reach/utils/context"; +import { isFunction } from "@reach/utils/type-check"; +import { useCheckStyles } from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { internal_checkedPropToStateValue as checkedPropToStateValue, internal_useControlledSwitchWarning as useControlledSwitchWarning, @@ -47,6 +44,7 @@ import { } from "./mixed"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { MixedOrBool, UseMixedCheckboxProps } from "./mixed"; //////////////////////////////////////////////////////////////////////////////// @@ -68,77 +66,81 @@ function useCustomCheckboxContext() { * * @see Docs https://reach.tech/checkbox#customcheckboxcontainer */ -const CustomCheckboxContainer = forwardRefWithAs< - CustomCheckboxContainerProps & { _componentName?: string }, - "span" ->(function CustomCheckboxContainer( - { - as: Comp = "span", - checked: controlledChecked, - children, - defaultChecked, - disabled, - onClick, - onChange, - _componentName = "CustomCheckboxContainer", - ...props - }, - forwardedRef -) { - let inputRef: CustomCheckboxInputRef = React.useRef(null); - let [inputProps, stateData] = useMixedCheckbox(inputRef, { - defaultChecked, - checked: controlledChecked, - disabled, - onChange, - }); - let [focused, setFocused] = React.useState(false); - - function handleClick() { - // Wait a frame so the input event is triggered, then focus the input - window.requestAnimationFrame(() => { - inputRef.current && inputRef.current.focus(); - }); - } +const CustomCheckboxContainer = React.forwardRef( + function CustomCheckboxContainer( + { + as: Comp = "span", + checked: controlledChecked, + children, + defaultChecked, + disabled, + onClick, + onChange, + // @ts-expect-error + __componentName = "CustomCheckboxContainer", + ...props + }, + forwardedRef + ) { + let inputRef: CustomCheckboxInputRef = React.useRef(null); + let [inputProps, stateData] = useMixedCheckbox( + inputRef, + { + defaultChecked, + checked: controlledChecked, + disabled, + onChange, + }, + __componentName + ); + let [focused, setFocused] = React.useState(false); - let context: CustomCheckboxContextValue = { - defaultChecked, - disabled, - focused, - inputProps, - inputRef, - setFocused, - }; + function handleClick() { + // Wait a frame so the input event is triggered, then focus the input + window.requestAnimationFrame(() => { + inputRef.current && inputRef.current.focus(); + }); + } - useControlledSwitchWarning(controlledChecked, "checked", _componentName); - useCheckStyles("checkbox"); + let context: CustomCheckboxContextValue = { + defaultChecked, + disabled, + focused, + inputProps, + inputRef, + setFocused, + }; - return ( - - - {isFunction(children) - ? children({ - checked: inputProps["aria-checked"], - inputRef, - focused, - }) - : children} - - - ); -}); + useControlledSwitchWarning(controlledChecked, "checked", __componentName); + useCheckStyles("checkbox"); + + return ( + + + {isFunction(children) + ? children({ + checked: inputProps["aria-checked"], + inputRef, + focused, + }) + : children} + + + ); + } +) as Polymorphic.ForwardRefComponent<"span", CustomCheckboxContainerProps>; /** * @see Docs https://reach.tech/checkbox#custom-checkboxcontainer-props */ -type CustomCheckboxContainerProps = { +interface CustomCheckboxContainerProps { /** * Whether or not the checkbox is checked or in a `mixed` (indeterminate) * state. @@ -191,7 +193,7 @@ type CustomCheckboxContainerProps = { * */ onChange?(event: React.ChangeEvent): void; -}; +} if (__DEV__) { CustomCheckboxContainer.displayName = "CustomCheckboxContainer"; @@ -217,57 +219,68 @@ if (__DEV__) { * * @see Docs https://reach.tech/checkbox#customcheckboxinput */ -const CustomCheckboxInput = forwardRefWithAs( - function CustomCheckboxInput( - { as: Comp = "input", onBlur, onFocus, ...props }, - forwardedRef - ) { - let { - focused, - inputProps, - inputRef, - setFocused, - } = useCustomCheckboxContext(); +const CustomCheckboxInput = React.forwardRef(function CustomCheckboxInput( + { as: Comp = "input", onBlur, onFocus, ...props }, + forwardedRef +) { + let { + focused, + inputProps, + inputRef, + setFocused, + } = useCustomCheckboxContext(); - let ref = useForkedRef(forwardedRef, inputRef); - let mounted = React.useRef(true); + let ref = useComposedRefs(forwardedRef, inputRef); + let mounted = React.useRef(true); - function handleBlur() { - // window.requestAnimationFrame(() => send(CustomCheckboxEvents.Blur)); - window.requestAnimationFrame(() => { - if (mounted.current) { - setFocused(false); - } - }); - } + function handleBlur() { + // window.requestAnimationFrame(() => send(CustomCheckboxEvents.Blur)); + window.requestAnimationFrame(() => { + if (mounted.current) { + setFocused(false); + } + }); + } - function handleFocus() { - // window.requestAnimationFrame(() => send(CustomCheckboxEvents.Focus)); - window.requestAnimationFrame(() => { - if (mounted.current) { - setFocused(true); - } - }); - } + function handleFocus() { + // window.requestAnimationFrame(() => send(CustomCheckboxEvents.Focus)); + window.requestAnimationFrame(() => { + if (mounted.current) { + setFocused(true); + } + }); + } - React.useEffect(() => () => void (mounted.current = false), []); + React.useEffect(() => () => void (mounted.current = false), []); - return ( - - ); - } -); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"input", CustomCheckboxInputProps>; -type CustomCheckboxInputProps = Pick; +interface CustomCheckboxInputProps { + /** + * The `name` attribute passed to the checkbox form input. + * + * @see Docs https://reach.tech/checkbox#custom-checkbox-name + */ + name?: React.ComponentProps<"input">["name"]; + /** + * The `value` attribute passed to the checkbox form input. + * + * @see Docs https://reach.tech/checkbox#custom-checkbox-value + */ + value?: React.ComponentProps<"input">["value"]; +} if (__DEV__) { CustomCheckboxInput.displayName = "CustomCheckboxInput"; @@ -283,33 +296,32 @@ if (__DEV__) { * * @see Docs https://reach.tech/checkbox#customcheckbox-1 */ -const CustomCheckbox = forwardRefWithAs( - function CustomCheckbox( - { children, id, name, value, ...props }, - forwardedRef - ) { - return ( - - - {children} - - ); - } -); +const CustomCheckbox = React.forwardRef(function CustomCheckbox( + { children, id, name, value, ...props }, + forwardedRef +) { + return ( + // @ts-ignore + + + {children} + + ); +}) as Polymorphic.ForwardRefComponent<"input", CustomCheckboxProps>; /** * @see Docs https://reach.tech/checkbox#custom-checkbox-props */ -type CustomCheckboxProps = { +interface CustomCheckboxProps { /** * Whether or not the checkbox is checked or in a `mixed` (indeterminate) * state. @@ -360,7 +372,7 @@ type CustomCheckboxProps = { * @see Docs https://reach.tech/checkbox#custom-checkbox-value */ value?: React.ComponentProps<"input">["value"]; -}; +} if (__DEV__) { CustomCheckbox.displayName = "CustomCheckbox"; diff --git a/packages/checkbox/src/mixed.tsx b/packages/checkbox/src/mixed.tsx index 98a63a2cc..118fab199 100644 --- a/packages/checkbox/src/mixed.tsx +++ b/packages/checkbox/src/mixed.tsx @@ -32,16 +32,14 @@ */ import * as React from "react"; -import { - forwardRefWithAs, - useForkedRef, - useIsomorphicLayoutEffect, - warning, - wrapEvent, -} from "@reach/utils"; +import { useIsomorphicLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { assign, useCreateMachine, useMachine } from "@reach/machine"; +import warning from "tiny-warning"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { MachineEventWithRefs, StateMachine } from "@reach/machine"; // Used for development only, not recommended for production code! @@ -204,15 +202,12 @@ const createMachineDefinition = ( * * @see Docs https://reach.tech/checkbox#mixedcheckbox-1 */ -const MixedCheckbox = forwardRefWithAs< - MixedCheckboxProps & { _componentName?: string }, - "input" ->(function MixedCheckbox( +const MixedCheckbox = React.forwardRef(function MixedCheckbox( { as: Comp = "input", checked, defaultChecked, disabled, onChange, ...props }, forwardedRef ) { let ownRef: MixedCheckboxInputRef = React.useRef(null); - let ref = useForkedRef(forwardedRef, ownRef); + let ref = useComposedRefs(forwardedRef, ownRef); let [inputProps] = useMixedCheckbox( ownRef, { @@ -229,16 +224,16 @@ const MixedCheckbox = forwardRefWithAs< return ( ); -}); +}) as Polymorphic.ForwardRefComponent<"input", MixedCheckboxProps>; -type MixedCheckboxProps = { +interface MixedCheckboxProps { /** * Whether or not the checkbox is checked or in a `mixed` (indeterminate) * state. */ checked?: MixedOrBool; onChange?: React.ComponentProps<"input">["onChange"]; -}; +} if (__DEV__) { MixedCheckbox.displayName = "MixedCheckbox"; @@ -312,8 +307,8 @@ function useMixedCheckbox( "aria-checked": stateValueToAriaChecked(current.value), checked: stateValueToChecked(current.value), disabled: !!disabled, - onChange: wrapEvent(onChange, handleChange), - onClick: wrapEvent(onClick, handleClick), + onChange: composeEventHandlers(onChange, handleChange), + onClick: composeEventHandlers(onClick, handleClick), type: "checkbox", }; @@ -352,13 +347,10 @@ function useMixedCheckbox( } } - React.useEffect(() => { - if (__DEV__ && !ref.current) { - throw new Error( - `A ref was not assigned to an input element in ${functionOrComponentName}.` - ); - } - }, [ref, functionOrComponentName]); + useRefDevWarning( + ref, + `A ref was not assigned to an input element in ${functionOrComponentName}.` + ); React.useEffect(() => { if (isControlled) { @@ -474,23 +466,6 @@ type MixedCheckboxEvent = MixedCheckboxEventBase & } ); -/** - * State object for the checkbox state machine. - */ -type MixedCheckboxState = - | { - value: MixedCheckboxStates.Checked; - context: MixedCheckboxData; - } - | { - value: MixedCheckboxStates.Unchecked; - context: MixedCheckboxData; - } - | { - value: MixedCheckboxStates.Mixed; - context: MixedCheckboxData; - }; - /** * DOM nodes for all of the refs used in the mixed checkbox state machine. */ @@ -515,3 +490,17 @@ export { checkedPropToStateValue as internal_checkedPropToStateValue, useControlledSwitchWarning as internal_useControlledSwitchWarning, }; + +function useRefDevWarning(ref: React.RefObject, message: string) { + if (__DEV__) { + /* eslint-disable react-hooks/rules-of-hooks */ + let messageRef = React.useRef(message); + React.useEffect(() => { + messageRef.current = message; + }, [message]); + React.useEffect(() => { + warning(ref.current, messageRef.current); + }, [ref]); + /* eslint-enable react-hooks/rules-of-hooks */ + } +} diff --git a/packages/combobox/__tests__/combobox.test.tsx b/packages/combobox/__tests__/combobox.test.tsx index 77b6e25eb..3b3e3402f 100644 --- a/packages/combobox/__tests__/combobox.test.tsx +++ b/packages/combobox/__tests__/combobox.test.tsx @@ -100,7 +100,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { jest.useRealTimers(); let { container } = render(); let results: AxeResults = null as any; diff --git a/packages/combobox/__tests__/utils.test.ts b/packages/combobox/__tests__/utils.test.ts new file mode 100644 index 000000000..4371aa0c9 --- /dev/null +++ b/packages/combobox/__tests__/utils.test.ts @@ -0,0 +1,139 @@ +import { HighlightWords } from "../src/utils"; +import latinize from "latinize"; + +describe(" : utils", () => { + describe("HighlightWords", () => { + // Forked from https://github.com/bvaughn/highlight-words-core + // Positions: 01234567890123456789012345678901234567 + const TEXT = "This is a string with words to search."; + + it("should handle empty `textToHighlight`", () => { + let result = HighlightWords.findAll({ + searchWords: ["search"], + textToHighlight: "", + }); + expect(result.length).toBe(0); + }); + + it("should handle undefined `textToHighlight`", () => { + let result = HighlightWords.findAll({ + searchWords: ["search"], + }); + expect(result.length).toBe(0); + }); + + it("should highlight all occurrences of a word, regardless of capitalization", () => { + let rawChunks = HighlightWords.findChunks({ + searchWords: ["th"], + textToHighlight: TEXT, + }); + expect(rawChunks).toEqual([ + { start: 0, end: 2, highlight: false }, + { start: 19, end: 21, highlight: false }, + ]); + }); + + it("should highlight words that partially overlap", () => { + let combinedChunks = HighlightWords.combineChunks({ + chunks: HighlightWords.findChunks({ + searchWords: ["thi", "is"], + textToHighlight: TEXT, + }), + }); + expect(combinedChunks).toEqual([ + { start: 0, end: 4, highlight: false }, + { start: 5, end: 7, highlight: false }, + ]); + }); + + it("should combine into the minimum number of marked and unmarked chunks", () => { + let filledInChunks = HighlightWords.findAll({ + searchWords: ["thi", "is"], + textToHighlight: TEXT, + }); + expect(filledInChunks).toEqual([ + { start: 0, end: 4, highlight: true }, + { start: 4, end: 5, highlight: false }, + { start: 5, end: 7, highlight: true }, + { start: 7, end: 38, highlight: false }, + ]); + }); + + it("should handle unclosed parentheses when autoEscape prop is truthy", () => { + let rawChunks = HighlightWords.findChunks({ + autoEscape: true, + searchWords: ["text)"], + textToHighlight: "(This is text)", + }); + expect(rawChunks).toEqual([{ start: 9, end: 14, highlight: false }]); + }); + + it("should match terms without accents against text with accents", () => { + let rawChunks = HighlightWords.findChunks({ + sanitize: latinize, + searchWords: ["example"], + textToHighlight: "ỆᶍǍᶆṔƚÉ", + }); + expect(rawChunks).toEqual([{ start: 0, end: 7, highlight: false }]); + }); + + it("should support case sensitive matches", () => { + let rawChunks = HighlightWords.findChunks({ + caseSensitive: true, + searchWords: ["t"], + textToHighlight: TEXT, + }); + expect(rawChunks).toEqual([ + { start: 11, end: 12, highlight: false }, + { start: 19, end: 20, highlight: false }, + { start: 28, end: 29, highlight: false }, + ]); + + rawChunks = HighlightWords.findChunks({ + caseSensitive: true, + searchWords: ["T"], + textToHighlight: TEXT, + }); + expect(rawChunks).toEqual([{ start: 0, end: 1, highlight: false }]); + }); + + it("should handle zero-length matches correctly", () => { + let rawChunks = HighlightWords.findChunks({ + caseSensitive: true, + searchWords: [".*"], + textToHighlight: TEXT, + }); + expect(rawChunks).toEqual([{ start: 0, end: 38, highlight: false }]); + + rawChunks = HighlightWords.findChunks({ + caseSensitive: true, + searchWords: ["w?"], + textToHighlight: TEXT, + }); + expect(rawChunks).toEqual([ + { start: 17, end: 18, highlight: false }, + { start: 22, end: 23, highlight: false }, + ]); + }); + + it("should use custom findChunks", () => { + let filledInChunks = HighlightWords.findAll({ + findChunks: (_: any) => [{ highlight: false, start: 1, end: 3 }], + searchWords: ["xxx"], + textToHighlight: TEXT, + }); + expect(filledInChunks).toEqual([ + { start: 0, end: 1, highlight: false }, + { start: 1, end: 3, highlight: true }, + { start: 3, end: 38, highlight: false }, + ]); + + filledInChunks = HighlightWords.findAll({ + findChunks: () => [], + searchWords: ["This"], + textToHighlight: TEXT, + }); + expect(filledInChunks).toEqual([{ start: 0, end: 38, highlight: false }]); + }); + }); +}); diff --git a/packages/combobox/examples/token-input.example.js b/packages/combobox/examples/token-input.example.js index 5156069ac..b868e136f 100644 --- a/packages/combobox/examples/token-input.example.js +++ b/packages/combobox/examples/token-input.example.js @@ -6,7 +6,7 @@ import { ComboboxOption, ComboboxPopover, } from "@reach/combobox"; -import { wrapEvent } from "@reach/utils"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { useCityMatch } from "./utils"; import "@reach/combobox/styles.css"; @@ -120,7 +120,10 @@ function ExampleTokenLabel({ onRemove, onKeyDown, ...props }) { return ( - ); } @@ -144,7 +147,7 @@ function ExampleTokenbox({ onSelect, ...props }) { const handleSelect = () => {}; return ( @@ -164,7 +167,10 @@ function ExampleTokenInput({ onKeyDown, ...props }) { } }; return ( - + ); } diff --git a/packages/combobox/package.json b/packages/combobox/package.json index 92aae3b50..ca73253c0 100644 --- a/packages/combobox/package.json +++ b/packages/combobox/package.json @@ -1,27 +1,30 @@ { "name": "@reach/combobox", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React Combobox (Autocomplete).", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/combobox" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/descendants": "0.13.1", - "@reach/popover": "0.13.1", - "@reach/portal": "0.13.1", - "@reach/utils": "0.13.1", - "highlight-words-core": "1.2.2", + "@reach/auto-id": "0.15.0", + "@reach/descendants": "0.15.0", + "@reach/popover": "0.15.0", + "@reach/portal": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "latinize": "0.3.0", + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/combobox/src/index.tsx b/packages/combobox/src/index.tsx index 02bea2422..274b89154 100644 --- a/packages/combobox/src/index.tsx +++ b/packages/combobox/src/index.tsx @@ -20,20 +20,15 @@ import * as React from "react"; import PropTypes from "prop-types"; -import { - createNamedContext, - forwardRefWithAs, - getOwnerDocument, - isFunction, - makeId, - noop, - useCheckStyles, - useForkedRef, - useIsomorphicLayoutEffect, - useLazyRef, - useUpdateEffect, - wrapEvent, -} from "@reach/utils"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { createNamedContext } from "@reach/utils/context"; +import { isFunction } from "@reach/utils/type-check"; +import { makeId } from "@reach/utils/make-id"; +import { noop } from "@reach/utils/noop"; +import { useCheckStyles } from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { useUpdateEffect } from "@reach/utils/use-update-effect"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { createDescendantContext, DescendantProvider, @@ -41,10 +36,11 @@ import { useDescendants, useDescendantsInit, } from "@reach/descendants"; -import { findAll } from "highlight-words-core"; +import { HighlightWords } from "./utils"; import { useId } from "@reach/auto-id"; import { Popover, positionMatchWidth } from "@reach/popover"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { PopoverProps } from "@reach/popover"; import type { Descendant } from "@reach/descendants"; @@ -261,109 +257,107 @@ const OptionContext = createNamedContext( * * @see Docs https://reach.tech/combobox#combobox */ -export const Combobox = forwardRefWithAs( - function Combobox( - { - onSelect, - openOnFocus = false, - children, - as: Comp = "div", - "aria-label": ariaLabel, - "aria-labelledby": ariaLabelledby, - ...props - }, - forwardedRef - ) { - let [options, setOptions] = useDescendantsInit(); +export const Combobox = React.forwardRef(function Combobox( + { + onSelect, + openOnFocus = false, + children, + as: Comp = "div", + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + ...props + }, + forwardedRef +) { + let [options, setOptions] = useDescendantsInit(); - // Need this to focus it - const inputRef = React.useRef(); + // Need this to focus it + const inputRef = React.useRef(); - const popoverRef = React.useRef(); + const popoverRef = React.useRef(); - const buttonRef = React.useRef(); + const buttonRef = React.useRef(); - // When we don't want cycle back to - // the user's value while navigating (because it's always the user's value), - // but we need to know this in useKeyDown which is far away from the prop - // here, so we do something sneaky and write it to this ref on context so we - // can use it anywhere else 😛. Another new trick for me and I'm excited - // about this one too! - const autocompletePropRef = React.useRef(); + // When we don't want cycle back to + // the user's value while navigating (because it's always the user's value), + // but we need to know this in useKeyDown which is far away from the prop + // here, so we do something sneaky and write it to this ref on context so we + // can use it anywhere else 😛. Another new trick for me and I'm excited + // about this one too! + const autocompletePropRef = React.useRef(false); - const persistSelectionRef = React.useRef(); + const persistSelectionRef = React.useRef(false); - const defaultData: StateData = { - // The value the user has typed. We derive this also when the developer is - // controlling the value of ComboboxInput. - value: "", - // the value the user has navigated to with the keyboard - navigationValue: null, - }; + const defaultData: StateData = { + // The value the user has typed. We derive this also when the developer is + // controlling the value of ComboboxInput. + value: "", + // the value the user has navigated to with the keyboard + navigationValue: null, + }; - const [state, data, transition] = useReducerMachine( - stateChart, - reducer, - defaultData - ); - - useFocusManagement(data.lastEventType, inputRef); - - const id = useId(props.id); - const listboxId = id ? makeId("listbox", id) : "listbox"; - - const context: InternalComboboxContextValue = { - ariaLabel, - ariaLabelledby, - autocompletePropRef, - buttonRef, - comboboxId: id, - data, - inputRef, - isExpanded: popoverIsExpanded(state), - listboxId, - onSelect: onSelect || noop, - openOnFocus, - persistSelectionRef, - popoverRef, - state, - transition, - }; + const [state, data, transition] = useReducerMachine( + stateChart, + reducer, + defaultData + ); - useCheckStyles("combobox"); + useFocusManagement(data.lastEventType, inputRef); - return ( - - - - {isFunction(children) - ? children({ - id, - isExpanded: popoverIsExpanded(state), - navigationValue: data.navigationValue ?? null, - state, - }) - : children} - - - - ); - } -); + const id = useId(props.id); + const listboxId = id ? makeId("listbox", id) : "listbox"; + + const context: InternalComboboxContextValue = { + ariaLabel, + ariaLabelledby, + autocompletePropRef, + buttonRef, + comboboxId: id, + data, + inputRef, + isExpanded: popoverIsExpanded(state), + listboxId, + onSelect: onSelect || noop, + openOnFocus, + persistSelectionRef, + popoverRef, + state, + transition, + }; + + useCheckStyles("combobox"); + + return ( + + + + {isFunction(children) + ? children({ + id, + isExpanded: popoverIsExpanded(state), + navigationValue: data.navigationValue ?? null, + state, + }) + : children} + + + + ); +}) as Polymorphic.ForwardRefComponent<"div", ComboboxProps>; /** * @see Docs https://reach.tech/combobox#combobox-props */ -export type ComboboxProps = { +export interface ComboboxProps { /** * @see Docs https://reach.tech/combobox#combobox-children */ @@ -393,7 +387,7 @@ export type ComboboxProps = { * @see Docs https://reach.tech/combobox#accessibility */ "aria-labelledby"?: string; -}; +} if (__DEV__) { Combobox.displayName = "Combobox"; @@ -413,162 +407,163 @@ if (__DEV__) { * * @see Docs https://reach.tech/combobox#comboboxinput */ -export const ComboboxInput = forwardRefWithAs( - function ComboboxInput( - { - as: Comp = "input", - selectOnClick = false, - autocomplete = true, - onClick, - onChange, - onKeyDown, - onBlur, - onFocus, - value: controlledValue, - ...props - }, - forwardedRef - ) { - // https://github.com/reach/reach-ui/issues/464 - let { current: initialControlledValue } = React.useRef(controlledValue); - let controlledValueChangedRef = React.useRef(false); - useUpdateEffect(() => { - controlledValueChangedRef.current = true; - }, [controlledValue]); - - let { - data: { navigationValue, value, lastEventType }, - inputRef, - state, - transition, - listboxId, - autocompletePropRef, - openOnFocus, - isExpanded, - ariaLabel, - ariaLabelledby, - } = React.useContext(ComboboxContext); - - let ref = useForkedRef(inputRef, forwardedRef); - - // Because we close the List on blur, we need to track if the blur is - // caused by clicking inside the list, and if so, don't close the List. - let selectOnClickRef = React.useRef(false); - - let handleKeyDown = useKeyDown(); - - let handleBlur = useBlur(); - - let isControlled = controlledValue != null; - - // Layout effect should be SSR-safe here because we don't actually do - // anything with this ref that involves rendering until after we've - // let the client hydrate in nested components. - useIsomorphicLayoutEffect(() => { - autocompletePropRef.current = autocomplete; - }, [autocomplete, autocompletePropRef]); - - const handleValueChange = React.useCallback( - (value: ComboboxValue) => { - if (value.trim() === "") { - transition(CLEAR); - } else if ( - value === initialControlledValue && - !controlledValueChangedRef.current - ) { - transition(INITIAL_CHANGE, { value }); - } else { - transition(CHANGE, { value }); - } - }, - [initialControlledValue, transition] - ); - - React.useEffect(() => { - // If they are controlling the value we still need to do our transitions, - // so we have this derived state to emulate onChange of the input as we - // receive new `value`s ...[*] - if ( - isControlled && - controlledValue !== value && - // https://github.com/reach/reach-ui/issues/481 - (controlledValue!.trim() === "" ? (value || "").trim() !== "" : true) +export const ComboboxInput = React.forwardRef(function ComboboxInput( + { + as: Comp = "input", + selectOnClick = false, + autocomplete = true, + onClick, + onChange, + onKeyDown, + onBlur, + onFocus, + value: controlledValue, + ...props + }, + forwardedRef +) { + // https://github.com/reach/reach-ui/issues/464 + let { current: initialControlledValue } = React.useRef(controlledValue); + let controlledValueChangedRef = React.useRef(false); + useUpdateEffect(() => { + controlledValueChangedRef.current = true; + }, [controlledValue]); + + let { + data: { navigationValue, value, lastEventType }, + inputRef, + state, + transition, + listboxId, + autocompletePropRef, + openOnFocus, + isExpanded, + ariaLabel, + ariaLabelledby, + persistSelectionRef, + } = React.useContext(ComboboxContext); + + let ref = useComposedRefs(inputRef, forwardedRef); + + // Because we close the List on blur, we need to track if the blur is + // caused by clicking inside the list, and if so, don't close the List. + let selectOnClickRef = React.useRef(false); + + let handleKeyDown = useKeyDown(); + + let handleBlur = useBlur(); + + let isControlled = controlledValue != null; + + // Layout effect should be SSR-safe here because we don't actually do + // anything with this ref that involves rendering until after we've + // let the client hydrate in nested components. + useLayoutEffect(() => { + autocompletePropRef.current = autocomplete; + }, [autocomplete, autocompletePropRef]); + + const handleValueChange = React.useCallback( + (value: ComboboxValue) => { + if (value.trim() === "") { + transition(CLEAR); + } else if ( + value === initialControlledValue && + !controlledValueChangedRef.current ) { - handleValueChange(controlledValue!); - } - }, [controlledValue, handleValueChange, isControlled, value]); - - // [*]... and when controlled, we don't trigger handleValueChange as the - // user types, instead the developer controls it with the normal input - // onChange prop - function handleChange(event: React.ChangeEvent) { - const { value } = event.target; - if (!isControlled) { - handleValueChange(value); + transition(INITIAL_CHANGE, { value }); + } else { + transition(CHANGE, { value }); } + }, + [initialControlledValue, transition] + ); + + React.useEffect(() => { + // If they are controlling the value we still need to do our transitions, + // so we have this derived state to emulate onChange of the input as we + // receive new `value`s ...[*] + if ( + isControlled && + controlledValue !== value && + // https://github.com/reach/reach-ui/issues/481 + (controlledValue!.trim() === "" ? (value || "").trim() !== "" : true) + ) { + handleValueChange(controlledValue!); + } + }, [controlledValue, handleValueChange, isControlled, value]); + + // [*]... and when controlled, we don't trigger handleValueChange as the + // user types, instead the developer controls it with the normal input + // onChange prop + function handleChange(event: React.ChangeEvent) { + const { value } = event.target; + if (!isControlled) { + handleValueChange(value); } + } - function handleFocus() { - if (selectOnClick) { - selectOnClickRef.current = true; - } + function handleFocus() { + if (selectOnClick) { + selectOnClickRef.current = true; + } - // If we select an option with click, useFocusManagement will focus the - // input, in those cases we don't want to cause the menu to open back up, - // so we guard behind these states. - if (openOnFocus && lastEventType !== SELECT_WITH_CLICK) { - transition(FOCUS); - } + // If we select an option with click, useFocusManagement will focus the + // input, in those cases we don't want to cause the menu to open back up, + // so we guard behind these states. + if (openOnFocus && lastEventType !== SELECT_WITH_CLICK) { + transition(FOCUS, { + persistSelection: persistSelectionRef.current, + }); } + } - function handleClick() { - if (selectOnClickRef.current) { - selectOnClickRef.current = false; - inputRef.current.select(); - } + function handleClick() { + if (selectOnClickRef.current) { + selectOnClickRef.current = false; + inputRef.current?.select(); + } - if (openOnFocus) { - transition("OPEN_WITH_BUTTON"); - } + if (openOnFocus) { + transition("OPEN_WITH_BUTTON"); } + } - const inputValue = - autocomplete && (state === NAVIGATING || state === INTERACTING) - ? // When idle, we don't have a navigationValue on ArrowUp/Down - navigationValue || controlledValue || value - : controlledValue || value; + const inputValue = + autocomplete && (state === NAVIGATING || state === INTERACTING) + ? // When idle, we don't have a navigationValue on ArrowUp/Down + navigationValue || controlledValue || value + : controlledValue || value; - return ( - - ); - } -); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"input", ComboboxInputProps>; /** * @see Docs https://reach.tech/combobox#comboboxinput-props */ -export type ComboboxInputProps = { +export interface ComboboxInputProps { /** * If true, when the user clicks inside the text box the current value will * be selected. Use this if the user is likely to delete all the text anyway @@ -598,7 +593,7 @@ export type ComboboxInputProps = { * @see Docs https://reach.tech/combobox#comboboxinput-value */ value?: ComboboxValue; -}; +} if (__DEV__) { ComboboxInput.displayName = "ComboboxInput"; @@ -615,10 +610,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/combobox#comboboxpopover */ -export const ComboboxPopover = forwardRefWithAs< - ComboboxPopoverProps & Partial, - "div" ->(function ComboboxPopover( +export const ComboboxPopover = React.forwardRef(function ComboboxPopover( { as: Comp = "div", children, @@ -628,20 +620,20 @@ export const ComboboxPopover = forwardRefWithAs< position = positionMatchWidth, ...props }, - forwardedRef: React.Ref + forwardedRef ) { const { popoverRef, inputRef, isExpanded, state } = React.useContext( ComboboxContext ); - const ref = useForkedRef(popoverRef, forwardedRef); + const ref = useComposedRefs(popoverRef, forwardedRef); const handleKeyDown = useKeyDown(); const handleBlur = useBlur(); const sharedProps = { "data-reach-combobox-popover": "", "data-state": getDataState(state), - onKeyDown: wrapEvent(onKeyDown, handleKeyDown), - onBlur: wrapEvent(onBlur, handleBlur), + onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown), + onBlur: composeEventHandlers(onBlur, handleBlur), // Instead of conditionally rendering the popover we use the `hidden` prop // because we don't want to unmount on close (from escape or onSelect). // However, the developer can conditionally render the ComboboxPopover if @@ -664,7 +656,10 @@ export const ComboboxPopover = forwardRefWithAs< ) : ( ); -}); +}) as Polymorphic.ForwardRefComponent< + "div", + ComboboxPopoverProps & Partial +>; if (__DEV__) { ComboboxPopover.displayName = "ComboboxPopover"; @@ -673,7 +668,7 @@ if (__DEV__) { /** * @see Docs https://reach.tech/combobox#comboboxpopover-props */ -export type ComboboxPopoverProps = { +export interface ComboboxPopoverProps { /** * If you pass `` the popover will not * render inside of a portal, but in the same order as the React tree. This @@ -683,7 +678,7 @@ export type ComboboxPopoverProps = { * @see Docs https://reach.tech/combobox#comboboxpopover-portal */ portal?: boolean; -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -695,41 +690,37 @@ export type ComboboxPopoverProps = { * * @see Docs https://reach.tech/combobox#comboboxlist */ -export const ComboboxList = forwardRefWithAs( - function ComboboxList( - { - // when true, and the list opens again, the option with a matching value - // will be automatically highlighted. - persistSelection = false, - as: Comp = "ul", - ...props - }, - forwardedRef - ) { - const { persistSelectionRef, listboxId } = React.useContext( - ComboboxContext - ); - - if (persistSelection) { - persistSelectionRef.current = true; - } +export const ComboboxList = React.forwardRef(function ComboboxList( + { + // when true, and the list opens again, the option with a matching value + // will be automatically highlighted. + persistSelection = false, + as: Comp = "ul", + ...props + }, + forwardedRef +) { + const { persistSelectionRef, listboxId } = React.useContext(ComboboxContext); - return ( - - ); + if (persistSelection) { + persistSelectionRef.current = true; } -); + + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"ul", ComboboxListProps>; /** * @see Docs https://reach.tech/combobox#comboboxlist-props */ -export type ComboboxListProps = { +export interface ComboboxListProps { /** * Defaults to false. When true and the list is opened, if an option's value * matches the value in the input, it will automatically be highlighted and @@ -744,7 +735,7 @@ export type ComboboxListProps = { * @see Docs https://reach.tech/combobox#comboboxlist-persistselection */ persistSelection?: boolean; -}; +} if (__DEV__) { ComboboxList.displayName = "ComboboxList"; @@ -759,70 +750,68 @@ if (__DEV__) { * * @see Docs https://reach.tech/combobox#comboboxoption */ -export const ComboboxOption = forwardRefWithAs( - function ComboboxOption( - { as: Comp = "li", children, value, onClick, ...props }, - forwardedRef: React.Ref - ) { - const { - onSelect, - data: { navigationValue }, - transition, - } = React.useContext(ComboboxContext); - - let ownRef = React.useRef(null); - let ref = useForkedRef(forwardedRef, ownRef); - - let index = useDescendant( - { - element: ownRef.current!, - value, - }, - ComboboxDescendantContext - ); +export const ComboboxOption = React.forwardRef(function ComboboxOption( + { as: Comp = "li", children, value, onClick, ...props }, + forwardedRef +) { + const { + onSelect, + data: { navigationValue }, + transition, + } = React.useContext(ComboboxContext); - const isActive = navigationValue === value; + let ownRef = React.useRef(null); + let ref = useComposedRefs(forwardedRef, ownRef); - const handleClick = () => { - onSelect && onSelect(value); - transition(SELECT_WITH_CLICK, { value }); - }; + let index = useDescendant( + { + element: ownRef.current!, + value, + }, + ComboboxDescendantContext + ); - return ( - - - {children ? ( - isFunction(children) ? ( - children({ value, index }) - ) : ( - children - ) + const isActive = navigationValue === value; + + const handleClick = () => { + onSelect && onSelect(value); + transition(SELECT_WITH_CLICK, { value }); + }; + + return ( + + + {children ? ( + isFunction(children) ? ( + children({ value, index }) ) : ( - - )} - - - ); - } -); + children + ) + ) : ( + + )} + + + ); +}) as Polymorphic.ForwardRefComponent<"li", ComboboxOptionProps>; /** * @see Docs https://reach.tech/combobox#comboboxoption-props */ -export type ComboboxOptionProps = { +export interface ComboboxOptionProps { /** * Optional. If omitted, the `value` will be used as the children like as: * ``. But if you need @@ -846,7 +835,7 @@ export type ComboboxOptionProps = { * @see Docs https://reach.tech/combobox#comboboxoption-value */ value: string; -}; +} if (__DEV__) { ComboboxOption.displayName = "ComboboxOption"; @@ -878,7 +867,7 @@ export function ComboboxOptionText() { const results = React.useMemo( () => - findAll({ + HighlightWords.findAll({ searchWords: escapeRegexp(contextValue || "").split(/\s+/), textToHighlight: value, }), @@ -915,46 +904,44 @@ if (__DEV__) { /** * ComboboxButton */ -export const ComboboxButton = forwardRefWithAs( - function ComboboxButton( - { as: Comp = "button", onClick, onKeyDown, ...props }, - forwardedRef - ) { - const { - transition, - state, - buttonRef, - listboxId, - isExpanded, - } = React.useContext(ComboboxContext); - const ref = useForkedRef(buttonRef, forwardedRef); +export const ComboboxButton = React.forwardRef(function ComboboxButton( + { as: Comp = "button", onClick, onKeyDown, ...props }, + forwardedRef +) { + const { + transition, + state, + buttonRef, + listboxId, + isExpanded, + } = React.useContext(ComboboxContext); + const ref = useComposedRefs(buttonRef, forwardedRef); - const handleKeyDown = useKeyDown(); + const handleKeyDown = useKeyDown(); - const handleClick = () => { - if (state === IDLE) { - transition(OPEN_WITH_BUTTON); - } else { - transition(CLOSE_WITH_BUTTON); - } - }; + const handleClick = () => { + if (state === IDLE) { + transition(OPEN_WITH_BUTTON); + } else { + transition(CLOSE_WITH_BUTTON); + } + }; - return ( - - ); - } -); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"button", ComboboxButtonProps>; -export type ComboboxButtonProps = {}; +export interface ComboboxButtonProps {} if (__DEV__) { ComboboxButton.displayName = "ComboboxButton"; @@ -972,20 +959,20 @@ if (__DEV__) { */ function useFocusManagement( lastEventType: MachineEventType | undefined, - inputRef: React.MutableRefObject + inputRef: React.MutableRefObject ) { // useLayoutEffect so that the cursor goes to the end of the input instead // of awkwardly at the beginning, unclear to me why 🤷‍♂️ // // Should be safe to use here since we're just focusing an input. - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { if ( lastEventType === NAVIGATE || lastEventType === ESCAPE || lastEventType === SELECT_WITH_CLICK || lastEventType === OPEN_WITH_BUTTON ) { - inputRef.current.focus(); + inputRef.current?.focus(); } }, [inputRef, lastEventType]); } @@ -1146,40 +1133,25 @@ function useBlur() { inputRef, buttonRef, } = React.useContext(ComboboxContext); - const rafIds = useLazyRef(() => new Set()); - - React.useEffect(() => { - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - rafIds.current.forEach((id) => cancelAnimationFrame(id)); - }; - }, [rafIds]); - - return function handleBlur() { - const ownerDocument = getOwnerDocument(popoverRef.current); - if (!ownerDocument) { - return; - } - let rafId = requestAnimationFrame(() => { - // we on want to close only if focus propss outside the combobox - if ( - ownerDocument.activeElement !== inputRef.current && - ownerDocument.activeElement !== buttonRef.current && - popoverRef.current - ) { - if (popoverRef.current.contains(ownerDocument.activeElement)) { - // focus landed inside the combobox, keep it open - if (state !== INTERACTING) { - transition(INTERACT); - } - } else { - // focus landed outside the combobox, close it. - transition(BLUR); + return function handleBlur(event: React.FocusEvent) { + let popover = popoverRef.current; + let input = inputRef.current; + let button = buttonRef.current; + let activeElement = event.relatedTarget as Node; + + // we on want to close only if focus propss outside the combobox + if (activeElement !== input && activeElement !== button && popover) { + if (popover.contains(activeElement)) { + // focus landed inside the combobox, keep it open + if (state !== INTERACTING) { + transition(INTERACT); } + } else { + // focus landed outside the combobox, close it. + transition(BLUR); } - }); - rafIds.current.add(rafId); + } }; } @@ -1236,6 +1208,19 @@ function makeHash(str: string) { return hash; } +// function getActiveElement(node: Element | null | undefined) { +// let activeElement: Element | null = null; +// try { +// // If Element.getRootNode is supported, we'll retrieve either the root +// // Document or shadow root depending on where the component is rendered. +// // https://github.com/reach/reach-ui/issues/787 +// activeElement = (node?.getRootNode() as ShadowRoot | Document) +// .activeElement; +// } finally { +// return activeElement || (node?.ownerDocument || document).activeElement; +// } +// } + function getDataState(state: State) { return state.toLowerCase(); } @@ -1251,7 +1236,8 @@ export function escapeRegexp(str: string) { return String(str).replace(/([.*+?=^!:${}()|[\]/\\])/g, "\\$1"); } -//////////////////////////////////////////////////////////////////////////////// +////////////////////////// +////////////////////////////////////////////////////// /** * A hook that exposes data for a given `Combobox` component to its descendants. @@ -1298,12 +1284,12 @@ export function useComboboxOptionContext(): ComboboxOptionContextValue { //////////////////////////////////////////////////////////////////////////////// // Types -export type ComboboxContextValue = { +export interface ComboboxContextValue { id: string | undefined; isExpanded: boolean; navigationValue: ComboboxValue | null; state: State; -}; +} type ComboboxDescendant = Descendant & { value: ComboboxValue; @@ -1318,16 +1304,16 @@ interface InternalComboboxContextValue { ariaLabel?: string; ariaLabelledby?: string; autocompletePropRef: React.MutableRefObject; - buttonRef: React.MutableRefObject; + buttonRef: React.MutableRefObject; comboboxId: string | undefined; data: StateData; - inputRef: React.MutableRefObject; + inputRef: React.MutableRefObject; isExpanded: boolean; listboxId: string; onSelect(value?: ComboboxValue): any; openOnFocus: boolean; - persistSelectionRef: React.MutableRefObject; - popoverRef: React.MutableRefObject; + persistSelectionRef: React.MutableRefObject; + popoverRef: React.MutableRefObject; state: State; transition: Transition; } @@ -1363,11 +1349,11 @@ interface StateChart { }; } -type StateData = { +interface StateData { lastEventType?: MachineEventType; navigationValue?: ComboboxValue | null; value?: ComboboxValue | null; -}; +} type MachineEvent = | { type: "BLUR" } @@ -1380,7 +1366,7 @@ type MachineEvent = | { type: "INTERACT" } | { type: "NAVIGATE"; - persistSelection?: React.MutableRefObject; + persistSelection?: boolean; value: ComboboxValue; } | { type: "OPEN_WITH_BUTTON" } diff --git a/packages/combobox/src/utils.ts b/packages/combobox/src/utils.ts new file mode 100644 index 000000000..b47bbc748 --- /dev/null +++ b/packages/combobox/src/utils.ts @@ -0,0 +1,181 @@ +// Forked from https://github.com/bvaughn/highlight-words-core + +/** + * Creates an array of chunk objects representing both higlightable and non + * highlightable pieces of text that match each search word. + * + * @return Array of "chunk" objects + */ +function findAll({ + autoEscape, + caseSensitive = false, + findChunks = defaultFindChunks, + sanitize, + searchWords, + textToHighlight, +}: { + autoEscape?: boolean; + caseSensitive?: boolean; + findChunks?: typeof defaultFindChunks; + sanitize?: typeof defaultSanitize; + searchWords: string[]; + textToHighlight?: string | null; +}): Chunk[] { + return fillInChunks({ + chunksToHighlight: combineChunks({ + chunks: findChunks({ + autoEscape, + caseSensitive, + sanitize, + searchWords, + textToHighlight, + }), + }), + totalLength: textToHighlight ? textToHighlight.length : 0, + }); +} + +/** + * Takes an array of "chunk" objects and combines chunks that overlap into + * single chunks. + * + * @return Array of "chunk" objects + */ +function combineChunks({ chunks }: { chunks: Chunk[] }): Chunk[] { + return chunks + .sort((first, second) => first.start - second.start) + .reduce((processedChunks, nextChunk) => { + // First chunk just goes straight in the array... + if (processedChunks.length === 0) { + return [nextChunk]; + } else { + // ... subsequent chunks get checked to see if they overlap... + const prevChunk = processedChunks.pop()!; + if (nextChunk.start <= prevChunk.end) { + // It may be the case that prevChunk completely surrounds nextChunk, so take the + // largest of the end indeces. + const endIndex = Math.max(prevChunk.end, nextChunk.end); + processedChunks.push({ + highlight: false, + start: prevChunk.start, + end: endIndex, + }); + } else { + processedChunks.push(prevChunk, nextChunk); + } + return processedChunks; + } + }, []); +} + +/** + * Examine text for any matches. If we find matches, add them to the returned + * array as a "chunk" object. + * + * @return Array of "chunk" objects + */ +function defaultFindChunks({ + autoEscape, + caseSensitive, + sanitize = defaultSanitize, + searchWords, + textToHighlight, +}: { + autoEscape?: boolean; + caseSensitive?: boolean; + sanitize?: typeof defaultSanitize; + searchWords: string[]; + textToHighlight?: string | null; +}): Chunk[] { + textToHighlight = sanitize(textToHighlight || ""); + + return searchWords + .filter((searchWord) => searchWord) // Remove empty words + .reduce((chunks, searchWord) => { + searchWord = sanitize(searchWord); + + if (autoEscape) { + searchWord = escapeRegExpFn(searchWord); + } + + const regex = new RegExp(searchWord, caseSensitive ? "g" : "gi"); + + let match; + while ((match = regex.exec(textToHighlight || ""))) { + let start = match.index; + let end = regex.lastIndex; + // We do not return zero-length matches + if (end > start) { + chunks.push({ highlight: false, start, end }); + } + + // Prevent browsers like Firefox from getting stuck in an infinite loop + // See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/ + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + + return chunks; + }, []); +} + +/** + * Given a set of chunks to highlight, create an additional set of chunks + * to represent the bits of text between the highlighted text. + * + * @return Array of "chunk" objects + */ +function fillInChunks({ + chunksToHighlight, + totalLength, +}: { + chunksToHighlight: Chunk[]; + totalLength: number; +}): Chunk[] { + const allChunks: Chunk[] = []; + + if (chunksToHighlight.length === 0) { + append(0, totalLength, false); + } else { + let lastIndex = 0; + chunksToHighlight.forEach((chunk) => { + append(lastIndex, chunk.start, false); + append(chunk.start, chunk.end, true); + lastIndex = chunk.end; + }); + append(lastIndex, totalLength, false); + } + return allChunks; + + function append(start: number, end: number, highlight: boolean) { + if (end - start > 0) { + allChunks.push({ + start, + end, + highlight, + }); + } + } +} + +function defaultSanitize(string: string): string { + return string; +} + +function escapeRegExpFn(string: string): string { + return string.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); +} + +export const HighlightWords = { + combineChunks, + fillInChunks, + findAll, + findChunks: defaultFindChunks, +}; + +export interface Chunk { + highlight: boolean; + start: number; + end: number; +} diff --git a/packages/component-component/package.json b/packages/component-component/package.json index 5fb8b513b..1a2d5e0b3 100644 --- a/packages/component-component/package.json +++ b/packages/component-component/package.json @@ -1,9 +1,12 @@ { "name": "@reach/component-component", - "version": "0.13.1", + "version": "0.15.0", "description": "Declarative React Component Definitions", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", @@ -13,8 +16,8 @@ "prop-types": "^15.7.2" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.4.0 || 17.x", diff --git a/packages/descendants/package.json b/packages/descendants/package.json index 1ab69b672..70ce70e92 100644 --- a/packages/descendants/package.json +++ b/packages/descendants/package.json @@ -1,21 +1,24 @@ { "name": "@reach/descendants", - "version": "0.13.1", + "version": "0.15.0", "description": "A descendant index solution for better accessibility support in compound components", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/descendants" }, "dependencies": { - "@reach/utils": "0.13.1", - "tslib": "^2.0.0" + "@reach/utils": "0.15.0", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/descendants/src/index.tsx b/packages/descendants/src/index.tsx index 09f73f633..2eb20b753 100644 --- a/packages/descendants/src/index.tsx +++ b/packages/descendants/src/index.tsx @@ -1,11 +1,9 @@ import * as React from "react"; -import { - createNamedContext, - noop, - useForceUpdate, - useIsomorphicLayoutEffect, - usePrevious, -} from "@reach/utils"; +import { useForceUpdate } from "@reach/utils/use-force-update"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { usePrevious } from "@reach/utils/use-previous"; +import { createNamedContext } from "@reach/utils/context"; +import { noop } from "@reach/utils/noop"; function createDescendantContext( name: string, @@ -76,7 +74,7 @@ function useDescendant( }); // Prevent any flashing - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { if (!descendant.element) forceUpdate(); registerDescendant({ ...descendant, @@ -274,7 +272,6 @@ function useDescendantKeyDown< rotate = true, rtl = false, } = options; - let index = currentIndex ?? -1; return function handleKeyDown(event: React.KeyboardEvent) { if ( @@ -292,6 +289,8 @@ function useDescendantKeyDown< return; } + let index = currentIndex ?? -1; + // If we use a filter function, we need to re-index our descendants array // so that filtered descendent elements aren't selected. let selectableDescendants = filter diff --git a/packages/dialog/__tests__/dialog.test.tsx b/packages/dialog/__tests__/dialog.test.tsx index 07bd5895c..8739b8b7b 100644 --- a/packages/dialog/__tests__/dialog.test.tsx +++ b/packages/dialog/__tests__/dialog.test.tsx @@ -33,7 +33,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { clock.restore(); const { container } = render(); let results: AxeResults = null as any; diff --git a/packages/dialog/package.json b/packages/dialog/package.json index 174a8255f..380740f17 100644 --- a/packages/dialog/package.json +++ b/packages/dialog/package.json @@ -1,25 +1,28 @@ { "name": "@reach/dialog", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React Modal Dialog.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/dialog" }, "dependencies": { - "@reach/portal": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/portal": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", "react-focus-lock": "^2.5.0", "react-remove-scroll": "^2.4.1", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/dialog/src/index.tsx b/packages/dialog/src/index.tsx index f6651008d..3ab8306d0 100644 --- a/packages/dialog/src/index.tsx +++ b/packages/dialog/src/index.tsx @@ -12,19 +12,18 @@ import * as React from "react"; import { Portal } from "@reach/portal"; -import { - forwardRefWithAs, - getOwnerDocument, - isString, - noop, - useCheckStyles, - useForkedRef, - wrapEvent, -} from "@reach/utils"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { isString } from "@reach/utils/type-check"; +import { noop } from "@reach/utils/noop"; +import { useCheckStyles } from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import FocusLock from "react-focus-lock"; import { RemoveScroll } from "react-remove-scroll"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; + const overlayPropTypes = { allowPinchZoom: PropTypes.bool, dangerouslyBypassFocusLock: PropTypes.bool, @@ -46,36 +45,34 @@ const overlayPropTypes = { * * @see Docs https://reach.tech/dialog#dialogoverlay */ -const DialogOverlay = forwardRefWithAs( - function DialogOverlay( - { as: Comp = "div", isOpen = true, ...props }, - forwardedRef - ) { - useCheckStyles("dialog"); - - // We want to ignore the immediate focus of a tooltip so it doesn't pop - // up again when the menu closes, only pops up when focus returns again - // to the tooltip (like native OS tooltips). - React.useEffect(() => { - if (isOpen) { +const DialogOverlay = React.forwardRef(function DialogOverlay( + { as: Comp = "div", isOpen = true, ...props }, + forwardedRef +) { + useCheckStyles("dialog"); + + // We want to ignore the immediate focus of a tooltip so it doesn't pop + // up again when the menu closes, only pops up when focus returns again + // to the tooltip (like native OS tooltips). + React.useEffect(() => { + if (isOpen) { + // @ts-ignore + window.__REACH_DISABLE_TOOLTIPS = true; + } else { + window.requestAnimationFrame(() => { + // Wait a frame so that this doesn't fire before tooltip does // @ts-ignore - window.__REACH_DISABLE_TOOLTIPS = true; - } else { - window.requestAnimationFrame(() => { - // Wait a frame so that this doesn't fire before tooltip does - // @ts-ignore - window.__REACH_DISABLE_TOOLTIPS = false; - }); - } - }, [isOpen]); + window.__REACH_DISABLE_TOOLTIPS = false; + }); + } + }, [isOpen]); - return isOpen ? ( - - - - ) : null; - } -); + return isOpen ? ( + + + + ) : null; +}) as Polymorphic.ForwardRefComponent<"div", DialogOverlayProps>; if (__DEV__) { DialogOverlay.displayName = "DialogOverlay"; @@ -85,7 +82,7 @@ if (__DEV__) { }; } -type DialogOverlayProps = DialogProps & { +interface DialogOverlayProps extends DialogProps { /** * By default the dialog locks the focus inside it. Normally this is what you * want. This prop is provided so that this feature can be disabled. This, @@ -123,94 +120,92 @@ type DialogOverlayProps = DialogProps & { * @see https://github.com/theKashey/react-remove-scroll */ dangerouslyBypassScrollLock?: boolean; -}; +} //////////////////////////////////////////////////////////////////////////////// /** * DialogInner */ -const DialogInner = forwardRefWithAs( - function DialogInner( - { - allowPinchZoom, - as: Comp = "div", - dangerouslyBypassFocusLock = false, - dangerouslyBypassScrollLock = false, - initialFocusRef, - onClick, - onDismiss = noop, - onKeyDown, - onMouseDown, - unstable_lockFocusAcrossFrames = true, - ...props - }, - forwardedRef - ) { - const mouseDownTarget = React.useRef(null); - const overlayNode = React.useRef(null); - const ref = useForkedRef(overlayNode, forwardedRef); - - const activateFocusLock = React.useCallback(() => { - if (initialFocusRef && initialFocusRef.current) { - initialFocusRef.current.focus(); - } - }, [initialFocusRef]); +const DialogInner = React.forwardRef(function DialogInner( + { + allowPinchZoom, + as: Comp = "div", + dangerouslyBypassFocusLock = false, + dangerouslyBypassScrollLock = false, + initialFocusRef, + onClick, + onDismiss = noop, + onKeyDown, + onMouseDown, + unstable_lockFocusAcrossFrames = true, + ...props + }, + forwardedRef +) { + const mouseDownTarget = React.useRef(null); + const overlayNode = React.useRef(null); + const ref = useComposedRefs(overlayNode, forwardedRef); - function handleClick(event: React.MouseEvent) { - if (mouseDownTarget.current === event.target) { - event.stopPropagation(); - onDismiss(event); - } + const activateFocusLock = React.useCallback(() => { + if (initialFocusRef && initialFocusRef.current) { + initialFocusRef.current.focus(); } + }, [initialFocusRef]); - function handleKeyDown(event: React.KeyboardEvent) { - if (event.key === "Escape") { - event.stopPropagation(); - onDismiss(event); - } + function handleClick(event: React.MouseEvent) { + if (mouseDownTarget.current === event.target) { + event.stopPropagation(); + onDismiss(event); } + } - function handleMouseDown(event: React.MouseEvent) { - mouseDownTarget.current = event.target; + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + event.stopPropagation(); + onDismiss(event); } + } - React.useEffect(() => { - return overlayNode.current - ? createAriaHider(overlayNode.current) - : void null; - }, []); - - return ( - - - - - - ); + function handleMouseDown(event: React.MouseEvent) { + mouseDownTarget.current = event.target; } -); + + React.useEffect(() => { + return overlayNode.current + ? createAriaHider(overlayNode.current) + : void null; + }, []); + + return ( + + + + + + ); +}) as Polymorphic.ForwardRefComponent<"div", DialogOverlayProps>; if (__DEV__) { DialogOverlay.displayName = "DialogOverlay"; @@ -236,38 +231,36 @@ if (__DEV__) { * * @see Docs https://reach.tech/dialog#dialogcontent */ -const DialogContent = forwardRefWithAs( - function DialogContent( - { as: Comp = "div", onClick, onKeyDown, ...props }, - forwardedRef - ) { - return ( - { - event.stopPropagation(); - })} - /> - ); - } -); +const DialogContent = React.forwardRef(function DialogContent( + { as: Comp = "div", onClick, onKeyDown, ...props }, + forwardedRef +) { + return ( + { + event.stopPropagation(); + })} + /> + ); +}) as Polymorphic.ForwardRefComponent<"div", DialogContentProps>; /** * @see Docs https://reach.tech/dialog#dialogcontent-props */ -type DialogContentProps = { +interface DialogContentProps { /** * Accepts any renderable content. * * @see Docs https://reach.tech/dialog#dialogcontent-children */ children?: React.ReactNode; -}; +} if (__DEV__) { DialogContent.displayName = "DialogContent"; @@ -287,7 +280,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/dialog#dialog */ -const Dialog = forwardRefWithAs(function Dialog( +const Dialog = React.forwardRef(function Dialog( { allowPinchZoom = false, initialFocusRef, @@ -307,12 +300,12 @@ const Dialog = forwardRefWithAs(function Dialog( ); -}); +}) as Polymorphic.ForwardRefComponent<"div", DialogProps>; /** * @see Docs https://reach.tech/dialog#dialog-props */ -type DialogProps = { +interface DialogProps { /** * Handle zoom/pinch gestures on iOS devices when scroll locking is enabled. * Defaults to `false`. @@ -372,7 +365,7 @@ type DialogProps = { * https://github.com/reach/reach-ui/issues/536 */ unstable_lockFocusAcrossFrames?: boolean; -}; +} if (__DEV__) { Dialog.displayName = "Dialog"; diff --git a/packages/disclosure/__tests__/disclosure.test.tsx b/packages/disclosure/__tests__/disclosure.test.tsx index 136aa5b0c..4ef5839c3 100644 --- a/packages/disclosure/__tests__/disclosure.test.tsx +++ b/packages/disclosure/__tests__/disclosure.test.tsx @@ -83,7 +83,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { let { getByRole, container } = render( Click Button @@ -108,16 +108,6 @@ describe("", () => { expect(getByText("Panel body")).toHaveAttribute("id", "panel--my-id"); }); - it("removes the panel from the navigation flow", () => { - let { getByText } = render( - - Click Button - Panel body - - ); - expect(getByText("Panel body")).toHaveAttribute("tabindex", "-1"); - }); - it("sets the correct aria attributes when collapsed", () => { let { getByText } = render( diff --git a/packages/disclosure/package.json b/packages/disclosure/package.json index f5b359db3..f592f0165 100644 --- a/packages/disclosure/package.json +++ b/packages/disclosure/package.json @@ -1,23 +1,27 @@ { "name": "@reach/disclosure", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React disclosure component", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/disclosure" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/disclosure/src/index.tsx b/packages/disclosure/src/index.tsx index 84cf1c222..96c2810ae 100644 --- a/packages/disclosure/src/index.tsx +++ b/packages/disclosure/src/index.tsx @@ -16,18 +16,17 @@ */ import * as React from "react"; -import { - createNamedContext, - forwardRefWithAs, - makeId, - useForkedRef, - useStableCallback, - warning, - wrapEvent, -} from "@reach/utils"; +import { useStableCallback } from "@reach/utils/use-stable-callback"; +import { createNamedContext } from "@reach/utils/context"; +import { makeId } from "@reach/utils/make-id"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { useId } from "@reach/auto-id"; +import warning from "tiny-warning"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; + const DisclosureContext = createNamedContext( "DisclosureContext", {} as DisclosureContextValue @@ -119,7 +118,7 @@ const Disclosure: React.FC = ({ ); }; -type DisclosureProps = { +interface DisclosureProps { /** * `Disclosure` expects to receive accept `DisclosureButton` and * `DisclosurePanel` components as children. It can also accept wrapper @@ -162,7 +161,7 @@ type DisclosureProps = { * @see Docs https://reach.tech/disclosure#disclosure-open */ open?: boolean; -}; +} if (__DEV__) { Disclosure.displayName = "Disclosure"; @@ -183,58 +182,57 @@ if (__DEV__) { * * @see Docs https://reach.tech/disclosure#disclosurebutton */ -const DisclosureButton = forwardRefWithAs( - function DisclosureButton( - { - // The element that shows and hides the content has role `button`. - // https://www.w3.org/TR/wai-aria-practices-1.2/#disclosure - as: Comp = "button", - children, - onClick, - onMouseDown, - onPointerDown, - ...props - }, - forwardedRef - ) { - const { onSelect, open, panelId } = React.useContext(DisclosureContext); - const ownRef = React.useRef(null); - - const ref = useForkedRef(forwardedRef, ownRef); - - function handleClick(event: React.MouseEvent) { - event.preventDefault(); - ownRef.current && ownRef.current.focus(); - onSelect(); - } - - return ( - - {children} - - ); +const DisclosureButton = React.forwardRef(function DisclosureButton( + { + // The element that shows and hides the content has role `button`. + // https://www.w3.org/TR/wai-aria-practices-1.2/#disclosure + as: Comp = "button", + children, + onClick, + onMouseDown, + onPointerDown, + ...props + }, + forwardedRef +) { + const { onSelect, open, panelId } = React.useContext(DisclosureContext); + const ownRef = React.useRef(null); + + const ref = useComposedRefs(forwardedRef, ownRef); + + function handleClick(event: React.MouseEvent) { + event.preventDefault(); + ownRef.current && ownRef.current.focus(); + onSelect(); } -); + + return ( + + {children} + + ); +}) as Polymorphic.ForwardRefComponent<"button", DisclosureButtonProps>; + /** * @see Docs https://reach.tech/disclosure#disclosurebutton-props */ -type DisclosureButtonProps = { +interface DisclosureButtonProps { /** * Typically a text string that serves as a label for the disclosure button, * though nested DOM nodes can be passed as well so long as they are valid @@ -244,7 +242,7 @@ type DisclosureButtonProps = { * @see Docs https://reach.tech/disclosure#disclosurebutton-children */ children: React.ReactNode; -}; +} if (__DEV__) { DisclosureButton.displayName = "DisclosureButton"; @@ -264,28 +262,25 @@ if (__DEV__) { * * @see Docs https://reach.tech/disclosure#disclosurepanel */ -const DisclosurePanel = forwardRefWithAs( - function DisclosurePanel( - { as: Comp = "div", children, ...props }, - forwardedRef - ) { - const { panelId, open } = React.useContext(DisclosureContext); - - return ( - - ); - } -); +const DisclosurePanel = React.forwardRef(function DisclosurePanel( + { as: Comp = "div", children, ...props }, + forwardedRef +) { + const { panelId, open } = React.useContext(DisclosureContext); + + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", DisclosurePanelProps>; if (__DEV__) { DisclosurePanel.displayName = "DisclosurePanel"; @@ -295,14 +290,14 @@ if (__DEV__) { /** * @see Docs https://reach.tech/disclosure#disclosurepanel-props */ -type DisclosurePanelProps = { +interface DisclosurePanelProps { /** * Inner collapsible content for the disclosure item. * * @see Docs https://reach.tech/disclosure#disclosurepanel-children */ children: React.ReactNode; -}; +} //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/listbox/__tests__/listbox.test.tsx b/packages/listbox/__tests__/listbox.test.tsx index 081f2416a..386e69c11 100644 --- a/packages/listbox/__tests__/listbox.test.tsx +++ b/packages/listbox/__tests__/listbox.test.tsx @@ -75,7 +75,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { let { container } = render(); let results: AxeResults = null as any; await act(async () => { diff --git a/packages/listbox/examples/basic-strict-mode.example.tsx b/packages/listbox/examples/basic-strict-mode.example.tsx index bedc97a72..0216bd32e 100644 --- a/packages/listbox/examples/basic-strict-mode.example.tsx +++ b/packages/listbox/examples/basic-strict-mode.example.tsx @@ -7,8 +7,6 @@ import "@reach/listbox/styles.css"; let name = "Basic (Strict Mode)"; -type Option = { value: string; label: string }; - function Example() { return ( diff --git a/packages/listbox/examples/common.tsx b/packages/listbox/examples/common.tsx index de7ad37dd..48b3584a4 100644 --- a/packages/listbox/examples/common.tsx +++ b/packages/listbox/examples/common.tsx @@ -7,7 +7,9 @@ export const Tag: React.FC> = ( let ref = React.useRef(null); let setInnerTextRef = React.useCallback((node: HTMLSpanElement) => { ref.current = node; - setInnerText(node.innerText); + if (node) { + setInnerText(node.innerText); + } }, []); return ( ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/listbox" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/descendants": "0.13.1", - "@reach/machine": "0.13.1", - "@reach/popover": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/descendants": "0.15.0", + "@reach/machine": "0.15.0", + "@reach/popover": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/listbox/src/index.tsx b/packages/listbox/src/index.tsx index c286f4b7e..ed8bbdfee 100644 --- a/packages/listbox/src/index.tsx +++ b/packages/listbox/src/index.tsx @@ -36,22 +36,18 @@ import { useDescendants, useDescendantsInit, } from "@reach/descendants"; +import { isRightClick } from "@reach/utils/is-right-click"; +import { useStableCallback } from "@reach/utils/use-stable-callback"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { createNamedContext } from "@reach/utils/context"; +import { isBoolean, isFunction, isString } from "@reach/utils/type-check"; +import { makeId } from "@reach/utils/make-id"; import { - createNamedContext, - forwardRefWithAs, - isBoolean, - isFunction, - isRightClick, - isString, - makeId, - memoWithAs, useCheckStyles, useControlledSwitchWarning, - useForkedRef, - useIsomorphicLayoutEffect as useLayoutEffect, - useStableCallback, - wrapEvent, -} from "@reach/utils"; +} from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { useCreateMachine, useMachine } from "@reach/machine"; import { createMachineDefinition, @@ -59,8 +55,9 @@ import { ListboxStates, } from "./machine"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { Descendant } from "@reach/descendants"; -import type { DistributiveOmit } from "@reach/utils"; +import type { DistributiveOmit } from "@reach/utils/types"; import type { StateMachine } from "@reach/machine"; import type { ListboxNodeRefs, @@ -95,10 +92,7 @@ const ListboxGroupContext = createNamedContext( * * @see Docs https://reach.tech/listbox#listboxinput */ -const ListboxInput = forwardRefWithAs< - ListboxInputProps & { _componentName?: string }, - "div" ->(function ListboxInput( +const ListboxInput = React.forwardRef(function ListboxInput( { as: Comp = "div", "aria-labelledby": ariaLabelledBy, @@ -113,7 +107,7 @@ const ListboxInput = forwardRefWithAs< value: valueProp, // We only use this prop for console warnings - _componentName = "ListboxInput", + __componentName = "ListboxInput", ...props }, forwardedRef @@ -121,8 +115,6 @@ const ListboxInput = forwardRefWithAs< let isControlled = React.useRef(valueProp != null); let [options, setOptions] = useDescendantsInit(); - let stableOnChange = useStableCallback(onChange); - // DOM refs let buttonRef = React.useRef(null); let hiddenInputRef = React.useRef(null); @@ -156,11 +148,17 @@ const ListboxInput = forwardRefWithAs< DEBUG ); + let stableOnChange = useStableCallback((newValue: string) => { + if (newValue !== state.context.value) { + onChange?.(newValue); + } + }); + // IDs for aria attributes let _id = useId(props.id); let id = props.id || makeId("listbox-input", _id); - let ref = useForkedRef(inputRef, forwardedRef); + let ref = useComposedRefs(inputRef, forwardedRef); // If the button has children, we just render them as the label. // Otherwise we'll find the option with a value that matches the listbox value @@ -234,7 +232,7 @@ const ListboxInput = forwardRefWithAs< } } - useControlledSwitchWarning(valueProp, "value", _componentName); + useControlledSwitchWarning(valueProp, "value", __componentName); // Even if the app controls state, we still need to update it internally to // run the state machine transitions @@ -335,7 +333,10 @@ const ListboxInput = forwardRefWithAs< ); -}); +}) as Polymorphic.ForwardRefComponent< + "div", + ListboxInputProps & { __componentName?: string } +>; if (__DEV__) { ListboxInput.displayName = "ListboxInput"; @@ -416,12 +417,12 @@ type ListboxInputProps = Pick< * * @see Docs https://reach.tech/listbox#listbox-1 */ -const Listbox = forwardRefWithAs(function Listbox( +const Listbox = React.forwardRef(function Listbox( { arrow = "▼", button, children, portal = true, ...props }, forwardedRef ) { return ( - + {({ value, valueLabel }) => ( (function Listbox( )} ); -}); +}) as Polymorphic.ForwardRefComponent<"div", ListboxProps>; if (__DEV__) { Listbox.displayName = "Listbox"; @@ -496,123 +497,119 @@ type ListboxProps = Omit & * * @see Docs https://reach.tech/listbox#listbox-button */ -const ListboxButtonImpl = forwardRefWithAs( - function ListboxButton( - { - "aria-label": ariaLabel, - arrow = false, - as: Comp = "span", - children, - onKeyDown, - onMouseDown, - onMouseUp, - ...props - }, - forwardedRef - ) { - let { - ariaLabelledBy, - buttonRef, - disabled, - isExpanded, - listboxId, - stateData, - send, - listboxValueLabel, - } = React.useContext(ListboxContext); - let listboxValue = stateData.value; +const ListboxButtonImpl = React.forwardRef(function ListboxButton( + { + "aria-label": ariaLabel, + arrow = false, + as: Comp = "span", + children, + onKeyDown, + onMouseDown, + onMouseUp, + ...props + }, + forwardedRef +) { + let { + ariaLabelledBy, + buttonRef, + disabled, + isExpanded, + listboxId, + stateData, + send, + listboxValueLabel, + } = React.useContext(ListboxContext); + let listboxValue = stateData.value; - let ref = useForkedRef(buttonRef, forwardedRef); + let ref = useComposedRefs(buttonRef, forwardedRef); - let handleKeyDown = useKeyDown(); + let handleKeyDown = useKeyDown(); - function handleMouseDown(event: React.MouseEvent) { - if (!isRightClick(event.nativeEvent)) { - event.preventDefault(); - event.stopPropagation(); - send({ - type: ListboxEvents.ButtonMouseDown, - disabled, - }); - } + function handleMouseDown(event: React.MouseEvent) { + if (!isRightClick(event.nativeEvent)) { + event.preventDefault(); + event.stopPropagation(); + send({ + type: ListboxEvents.ButtonMouseDown, + disabled, + }); } + } - function handleMouseUp(event: React.MouseEvent) { - if (!isRightClick(event.nativeEvent)) { - event.preventDefault(); - event.stopPropagation(); - send({ type: ListboxEvents.ButtonMouseUp }); - } + function handleMouseUp(event: React.MouseEvent) { + if (!isRightClick(event.nativeEvent)) { + event.preventDefault(); + event.stopPropagation(); + send({ type: ListboxEvents.ButtonMouseUp }); } + } - let id = makeId("button", listboxId); - - // If the button has children, we just render them as the label - // If a user needs the label on the server to prevent hydration mismatch - // errors, they need to control the state of the component and pass a label - // directly to the button. - let label: React.ReactNode = React.useMemo(() => { - if (!children) { - return listboxValueLabel; - } else if (isFunction(children)) { - return children({ - isExpanded, - label: listboxValueLabel!, - value: listboxValue, - // TODO: Remove in 1.0 - expanded: isExpanded, - }); - } - return children; - }, [children, listboxValueLabel, isExpanded, listboxValue]); + let id = makeId("button", listboxId); + + // If the button has children, we just render them as the label + // If a user needs the label on the server to prevent hydration mismatch + // errors, they need to control the state of the component and pass a label + // directly to the button. + let label: React.ReactNode = React.useMemo(() => { + if (!children) { + return listboxValueLabel; + } else if (isFunction(children)) { + return children({ + isExpanded, + label: listboxValueLabel!, + value: listboxValue, + // TODO: Remove in 1.0 + expanded: isExpanded, + }); + } + return children; + }, [children, listboxValueLabel, isExpanded, listboxValue]); - return ( - - {label} - {arrow && ( - {isBoolean(arrow) ? null : arrow} - )} - - ); - } -); + return ( + + {label} + {arrow && {isBoolean(arrow) ? null : arrow}} + + ); +}) as Polymorphic.ForwardRefComponent<"span", ListboxButtonProps>; if (__DEV__) { ListboxButtonImpl.displayName = "ListboxButton"; @@ -622,12 +619,14 @@ if (__DEV__) { }; } -const ListboxButton = memoWithAs(ListboxButtonImpl); +const ListboxButton = React.memo( + ListboxButtonImpl +) as Polymorphic.MemoComponent<"span", ListboxButtonProps>; /** * @see Docs https://reach.tech/listbox#listboxbutton-props */ -type ListboxButtonProps = { +interface ListboxButtonProps { /** * Renders a text string or React node to represent an arrow inside the * button. @@ -683,7 +682,7 @@ type ListboxButtonProps = { // TODO: Remove in 1.0 expanded: boolean; }) => React.ReactNode); -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -694,33 +693,31 @@ type ListboxButtonProps = { * * @see Docs https://reach.tech/listbox#listboxarrow */ -const ListboxArrowImpl = forwardRefWithAs( - function ListboxArrow( - { as: Comp = "span", children, ...props }, - forwardedRef - ) { - let { isExpanded } = React.useContext(ListboxContext); - return ( - - ); - } -); +const ListboxArrowImpl = React.forwardRef(function ListboxArrow( + { as: Comp = "span", children, ...props }, + forwardedRef +) { + let { isExpanded } = React.useContext(ListboxContext); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"span", ListboxArrowProps>; if (__DEV__) { ListboxArrowImpl.displayName = "ListboxArrow"; @@ -729,12 +726,15 @@ if (__DEV__) { }; } -const ListboxArrow = memoWithAs(ListboxArrowImpl); +const ListboxArrow = React.memo(ListboxArrowImpl) as Polymorphic.MemoComponent< + "span", + ListboxArrowProps +>; /** * @see Docs https://reach.tech/listbox#listboxarrow-props */ -type ListboxArrowProps = { +interface ListboxArrowProps { /** * Children to render as the listbox button's arrow. This can be a render * function that accepts the listbox's expanded state as an argument. @@ -746,7 +746,7 @@ type ListboxArrowProps = { // TODO: Remove in 1.0 expanded: boolean; }) => React.ReactNode); -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -757,59 +757,57 @@ type ListboxArrowProps = { * * @see Docs https://reach.tech/listbox#listboxpopover */ -const ListboxPopoverImpl = forwardRefWithAs( - function ListboxPopover( - { - as: Comp = "div", - position = positionMatchWidth, - onBlur, - onKeyDown, - portal = true, - unstable_observableRefs, - ...props - }, - forwardedRef - ) { - let { buttonRef, popoverRef, send, isExpanded } = React.useContext( - ListboxContext - ); - let ref = useForkedRef(popoverRef, forwardedRef); - - let handleKeyDown = useKeyDown(); - - let commonProps = { - hidden: !isExpanded, - tabIndex: -1, - ...props, - ref, - "data-reach-listbox-popover": "", - onBlur: wrapEvent(onBlur, handleBlur), - onKeyDown: wrapEvent(onKeyDown, handleKeyDown), - }; +const ListboxPopoverImpl = React.forwardRef(function ListboxPopover( + { + as: Comp = "div", + position = positionMatchWidth, + onBlur, + onKeyDown, + portal = true, + unstable_observableRefs, + ...props + }, + forwardedRef +) { + let { buttonRef, popoverRef, send, isExpanded } = React.useContext( + ListboxContext + ); + let ref = useComposedRefs(popoverRef, forwardedRef); + + let handleKeyDown = useKeyDown(); + + let commonProps = { + hidden: !isExpanded, + tabIndex: -1, + ...props, + ref, + "data-reach-listbox-popover": "", + onBlur: composeEventHandlers(onBlur, handleBlur), + onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown), + }; - function handleBlur(event: React.FocusEvent) { - let { nativeEvent } = event; - requestAnimationFrame(() => { - send({ - type: ListboxEvents.Blur, - relatedTarget: nativeEvent.relatedTarget || nativeEvent.target, - }); + function handleBlur(event: React.FocusEvent) { + let { nativeEvent } = event; + requestAnimationFrame(() => { + send({ + type: ListboxEvents.Blur, + relatedTarget: nativeEvent.relatedTarget || nativeEvent.target, }); - } - - return portal ? ( - - ) : ( - - ); + }); } -); + + return portal ? ( + + ) : ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", ListboxPopoverProps>; if (__DEV__) { ListboxPopoverImpl.displayName = "ListboxPopover"; @@ -820,12 +818,14 @@ if (__DEV__) { }; } -const ListboxPopover = memoWithAs(ListboxPopoverImpl); +const ListboxPopover = React.memo( + ListboxPopoverImpl +) as Polymorphic.MemoComponent<"div", ListboxPopoverProps>; /** * @see Docs https://reach.tech/listbox#listboxpopover-props */ -type ListboxPopoverProps = { +interface ListboxPopoverProps { /** * `ListboxPopover` expects to receive `ListboxList` as its children. * @@ -846,7 +846,7 @@ type ListboxPopoverProps = { */ position?: PopoverProps["position"]; unstable_observableRefs?: PopoverProps["unstable_observableRefs"]; -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -857,52 +857,51 @@ type ListboxPopoverProps = { * * @see Docs https://reach.tech/listbox#listboxlist */ -const ListboxList = forwardRefWithAs( - function ListboxList({ as: Comp = "ul", ...props }, forwardedRef) { - let { - ariaLabel, - ariaLabelledBy, - isExpanded, - listboxId, - listRef, - stateData: { value, navigationValue }, - } = React.useContext(ListboxContext); - let ref = useForkedRef(forwardedRef, listRef); +const ListboxList = React.forwardRef(function ListboxList( + { as: Comp = "ul", ...props }, + forwardedRef +) { + let { + ariaLabel, + ariaLabelledBy, + isExpanded, + listboxId, + listRef, + stateData: { value, navigationValue }, + } = React.useContext(ListboxContext); + let ref = useComposedRefs(forwardedRef, listRef); - return ( - - ); - } -); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"ul", ListboxListProps>; if (__DEV__) { ListboxList.displayName = "ListboxList"; @@ -912,7 +911,7 @@ if (__DEV__) { /** * @see Docs https://reach.tech/listbox#listboxlist-props */ -type ListboxListProps = {}; +interface ListboxListProps {} //////////////////////////////////////////////////////////////////////////////// @@ -923,166 +922,183 @@ type ListboxListProps = {}; * * @see Docs https://reach.tech/listbox#listboxoption */ -const ListboxOption = forwardRefWithAs( - function ListboxOption( +const ListboxOption = React.forwardRef(function ListboxOption( + { + as: Comp = "li", + children, + disabled, + onClick, + onMouseDown, + onMouseEnter, + onMouseLeave, + onMouseMove, + onMouseUp, + onTouchStart, + value, + label: labelProp, + ...props + }, + forwardedRef +) { + if (__DEV__ && !value) { + throw Error(`A ListboxOption must have a value prop.`); + } + + let { + highlightedOptionRef, + isExpanded, + onValueChange, + selectedOptionRef, + send, + state, + stateData: { value: listboxValue, navigationValue }, + } = React.useContext(ListboxContext); + + let [labelState, setLabel] = React.useState(labelProp); + let label = labelProp || labelState || ""; + + let ownRef = React.useRef(null); + useDescendant( { - as: Comp = "li", - children, - disabled, - onMouseDown, - onMouseEnter, - onMouseLeave, - onMouseMove, - onMouseUp, - onTouchStart, + element: ownRef.current!, value, - label: labelProp, - ...props + label, + disabled: !!disabled, }, - forwardedRef - ) { - if (__DEV__ && !value) { - throw Error(`A ListboxOption must have a value prop.`); - } + ListboxDescendantContext + ); - let { - highlightedOptionRef, - isExpanded, - onValueChange, - selectedOptionRef, - send, - state, - stateData: { value: listboxValue, navigationValue }, - } = React.useContext(ListboxContext); + // After the ref is mounted to the DOM node, we check to see if we have an + // explicit label prop before looking for the node's textContent for + // typeahead functionality. + let getLabelFromDomNode = React.useCallback( + (node: HTMLElement) => { + if (!labelProp && node) { + setLabel((prevState) => { + if (node.textContent && prevState !== node.textContent) { + return node.textContent; + } + return prevState || ""; + }); + } + }, + [labelProp] + ); - let [labelState, setLabel] = React.useState(labelProp); - let label = labelProp || labelState || ""; + let isHighlighted = navigationValue ? navigationValue === value : false; + let isSelected = listboxValue === value; - let ownRef = React.useRef(null); - useDescendant( - { - element: ownRef.current!, - value, - label, - disabled: !!disabled, - }, - ListboxDescendantContext - ); + let ref = useComposedRefs( + getLabelFromDomNode, + forwardedRef, + ownRef, + isSelected ? selectedOptionRef : null, + isHighlighted ? highlightedOptionRef : null + ); - // After the ref is mounted to the DOM node, we check to see if we have an - // explicit label prop before looking for the node's textContent for - // typeahead functionality. - let getLabelFromDomNode = React.useCallback( - (node: HTMLElement) => { - if (!labelProp && node) { - setLabel((prevState) => { - if (node.textContent && prevState !== node.textContent) { - return node.textContent; - } - return prevState || ""; - }); - } - }, - [labelProp] - ); + function handleMouseEnter() { + send({ + type: ListboxEvents.OptionMouseEnter, + value, + disabled: !!disabled, + }); + } - let isHighlighted = navigationValue ? navigationValue === value : false; - let isSelected = listboxValue === value; + function handleTouchStart() { + send({ + type: ListboxEvents.OptionTouchStart, + value, + disabled: !!disabled, + }); + } - let ref = useForkedRef( - getLabelFromDomNode, - forwardedRef, - ownRef, - isSelected ? selectedOptionRef : null, - isHighlighted ? highlightedOptionRef : null - ); + function handleMouseLeave() { + send({ type: ListboxEvents.ClearNavSelection }); + } + + function handleMouseDown(event: React.MouseEvent) { + // Prevent blur event from firing and bubbling to the popover + if (!isRightClick(event.nativeEvent)) { + event.preventDefault(); + send({ type: ListboxEvents.OptionMouseDown }); + } + } - function handleMouseEnter() { + function handleMouseUp(event: React.MouseEvent) { + if (!isRightClick(event.nativeEvent)) { send({ - type: ListboxEvents.OptionMouseEnter, + type: ListboxEvents.OptionMouseUp, value, + callback: onValueChange, disabled: !!disabled, }); } + } - function handleTouchStart() { + function handleClick(event: React.MouseEvent) { + // Generally an option will be selected on mouseup, but in case this isn't + // handled correctly by the device (whether because it's a touch/pen or + // virtual click event) we want to handle selection on a full click event + // just in case. This should address issues with screenreader selection, + // but this needs more robust testing. + if (!isRightClick(event.nativeEvent)) { send({ - type: ListboxEvents.OptionTouchStart, + type: ListboxEvents.OptionClick, value, + callback: onValueChange, disabled: !!disabled, }); } + } - function handleMouseLeave() { - send({ type: ListboxEvents.ClearNavSelection }); - } - - function handleMouseDown(event: React.MouseEvent) { - // Prevent blur event from firing and bubbling to the popover - if (!isRightClick(event.nativeEvent)) { - event.preventDefault(); - send({ type: ListboxEvents.OptionMouseDown }); - } - } - - function handleMouseUp(event: React.MouseEvent) { - if (!isRightClick(event.nativeEvent)) { - send({ - type: ListboxEvents.OptionMouseUp, - value, - callback: onValueChange, - disabled: !!disabled, - }); - } - } - - function handleMouseMove() { - // We don't really *need* these guards since we put all of our transition - // logic in the state machine, but in this case it seems wise not to - // needlessly run our transitions every time the user's mouse moves. Seems - // like a lot. 🙃 - if (state === ListboxStates.Open || navigationValue !== value) { - send({ - type: ListboxEvents.OptionMouseMove, - value, - disabled: !!disabled, - }); - } + function handleMouseMove() { + // We don't really *need* these guards since we put all of our transition + // logic in the state machine, but in this case it seems wise not to + // needlessly run our transitions every time the user's mouse moves. Seems + // like a lot. 🙃 + if (state === ListboxStates.Open || navigationValue !== value) { + send({ + type: ListboxEvents.OptionMouseMove, + value, + disabled: !!disabled, + }); } - - return ( - - {children} - - ); } -); + + return ( + + {children} + + ); +}) as Polymorphic.ForwardRefComponent<"li", ListboxOptionProps>; if (__DEV__) { ListboxOption.displayName = "ListboxOption"; @@ -1096,7 +1112,7 @@ if (__DEV__) { /** * @see Docs https://reach.tech/listbox#listboxoption-props */ -type ListboxOptionProps = { +interface ListboxOptionProps { /** * The option's value. This will be passed into a hidden input field for use * in forms. @@ -1120,7 +1136,7 @@ type ListboxOptionProps = { * @see Docs https://reach.tech/listbox#listboxoption-disabled */ disabled?: boolean; -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -1131,33 +1147,31 @@ type ListboxOptionProps = { * * @see Docs https://reach.tech/listbox#listboxgroup */ -const ListboxGroup = forwardRefWithAs( - function ListboxGroup( - { as: Comp = "div", label, children, ...props }, - forwardedRef - ) { - let { listboxId } = React.useContext(ListboxContext); - let labelId = makeId("label", useId(props.id), listboxId); - return ( - - - {label && {label}} - {children} - - - ); - } -); +const ListboxGroup = React.forwardRef(function ListboxGroup( + { as: Comp = "div", label, children, ...props }, + forwardedRef +) { + let { listboxId } = React.useContext(ListboxContext); + let labelId = makeId("label", useId(props.id), listboxId); + return ( + + + {label && {label}} + {children} + + + ); +}) as Polymorphic.ForwardRefComponent<"div", ListboxGroupProps>; if (__DEV__) { ListboxGroup.displayName = "ListboxGroup"; @@ -1169,7 +1183,7 @@ if (__DEV__) { /** * @see Docs https://reach.tech/listbox#listboxgroup-props */ -type ListboxGroupProps = { +interface ListboxGroupProps { /** * The text label to use for the listbox group. This can be omitted if a * group contains a `ListboxGroupLabel` component. The label should always @@ -1178,7 +1192,7 @@ type ListboxGroupProps = { * @see Docs https://reach.tech/listbox#listboxgroup-label */ label?: React.ReactNode; -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -1187,22 +1201,23 @@ type ListboxGroupProps = { * * @see Docs https://reach.tech/listbox#listboxgrouplabel */ -const ListboxGroupLabel = forwardRefWithAs( - function ListboxGroupLabel({ as: Comp = "span", ...props }, forwardedRef) { - let { labelId } = React.useContext(ListboxGroupContext); - return ( - - ); - } -); +const ListboxGroupLabel = React.forwardRef(function ListboxGroupLabel( + { as: Comp = "span", ...props }, + forwardedRef +) { + let { labelId } = React.useContext(ListboxGroupContext); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"span", ListboxGroupLabelProps>; if (__DEV__) { ListboxGroupLabel.displayName = "ListboxGroupLabel"; @@ -1212,7 +1227,7 @@ if (__DEV__) { /** * @see Docs https://reach.tech/listbox#listboxgroup-props */ -type ListboxGroupLabelProps = {}; +interface ListboxGroupLabelProps {} //////////////////////////////////////////////////////////////////////////////// @@ -1290,7 +1305,7 @@ function useKeyDown() { let index = options.findIndex(({ value }) => value === navigationValue); - let handleKeyDown = wrapEvent( + let handleKeyDown = composeEventHandlers( function (event: React.KeyboardEvent) { let { key } = event; let isSearching = isString(key) && key.length === 1; @@ -1379,14 +1394,14 @@ type ListboxDescendant = Descendant & { disabled: boolean; }; -type ListboxContextValue = { +interface ListboxContextValue { id: string | undefined; isExpanded: boolean; highlightedOptionRef: React.RefObject; selectedOptionRef: React.RefObject; value: ListboxValue | null; valueLabel: string | null; -}; +} interface InternalListboxContextValue { ariaLabel?: string; diff --git a/packages/listbox/src/machine.ts b/packages/listbox/src/machine.ts index 8c5d39996..fd736423c 100644 --- a/packages/listbox/src/machine.ts +++ b/packages/listbox/src/machine.ts @@ -1,5 +1,5 @@ import { assign } from "@reach/machine"; -import { getOwnerDocument } from "@reach/utils"; +import { getOwnerDocument } from "@reach/utils/owner-document"; import type { ListboxDescendant, ListboxValue } from "./index"; import type { MachineEventWithRefs, StateMachine } from "@reach/machine"; @@ -41,9 +41,18 @@ export enum ListboxEvents { KeyDownSearch = "KEY_DOWN_SEARCH", KeyDownTab = "KEY_DOWN_TAB", KeyDownShiftTab = "KEY_DOWN_SHIFT_TAB", + OptionTouchStart = "OPTION_TOUCH_START", OptionMouseMove = "OPTION_MOUSE_MOVE", OptionMouseEnter = "OPTION_MOUSE_ENTER", + OptionMouseDown = "OPTION_MOUSE_DOWN", + OptionMouseUp = "OPTION_MOUSE_UP", + OptionClick = "OPTION_CLICK", + + // WIP: Simplify and consolidate events + // TODO: Use a separate machine to deal with states to determine press events + OptionPress = "OPTION_PRESS", + OutsideMouseDown = "OUTSIDE_MOUSE_DOWN", OutsideMouseUp = "OUTSIDE_MOUSE_UP", @@ -51,8 +60,6 @@ export enum ListboxEvents { // ValueChange > Value change may have come from somewhere else ValueChange = "VALUE_CHANGE", - OptionMouseDown = "OPTION_MOUSE_DOWN", - OptionMouseUp = "OPTION_MOUSE_UP", PopoverPointerDown = "POPOVER_POINTER_DOWN", PopoverPointerUp = "POPOVER_POINTER_UP", UpdateAfterTypeahead = "UPDATE_AFTER_TYPEAHEAD", @@ -178,10 +185,13 @@ function optionIsNavigable(data: ListboxStateData, event: ListboxEvent) { return true; } -function optionIsSelectable(data: ListboxStateData, event: any) { - if (event && event.disabled) { +function optionIsSelectable(data: ListboxStateData, event: ListboxEvent) { + if ("disabled" in event && event.disabled) { return false; } + if ("value" in event) { + return event.value != null; + } return data.navigationValue != null; } @@ -410,6 +420,16 @@ export const createMachineDefinition = ({ actions: [navigate, clearTypeahead], cond: optionIsNavigable, }, + [ListboxEvents.OptionClick]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, + [ListboxEvents.OptionPress]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, [ListboxEvents.OptionMouseEnter]: { target: ListboxStates.Navigating, actions: [navigate, clearTypeahead], @@ -502,6 +522,16 @@ export const createMachineDefinition = ({ actions: [navigate, clearTypeahead], cond: optionIsNavigable, }, + [ListboxEvents.OptionClick]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, + [ListboxEvents.OptionPress]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, [ListboxEvents.KeyDownNavigate]: { target: ListboxStates.Navigating, actions: [navigate, clearTypeahead, focusList], @@ -611,6 +641,16 @@ export const createMachineDefinition = ({ actions: [navigate, clearTypeahead], cond: optionIsNavigable, }, + [ListboxEvents.OptionClick]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, + [ListboxEvents.OptionPress]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, [ListboxEvents.OptionMouseEnter]: { target: ListboxStates.Dragging, actions: [navigate, clearTypeahead], @@ -728,6 +768,16 @@ export const createMachineDefinition = ({ actions: [navigate, clearTypeahead], cond: optionIsNavigable, }, + [ListboxEvents.OptionClick]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, + [ListboxEvents.OptionPress]: { + target: ListboxStates.Idle, + actions: [assignValue, clearTypeahead, focusButton, selectOption], + cond: optionIsSelectable, + }, [ListboxEvents.OptionMouseEnter]: { target: ListboxStates.Navigating, actions: [navigate, clearTypeahead], @@ -890,6 +940,18 @@ export type ListboxEvent = ListboxEventBase & callback?: ((newValue: ListboxValue) => void) | null | undefined; disabled: boolean; } + | { + type: ListboxEvents.OptionClick; + value: ListboxValue | null | undefined; + callback?: ((newValue: ListboxValue) => void) | null | undefined; + disabled: boolean; + } + | { + type: ListboxEvents.OptionPress; + value: ListboxValue | null | undefined; + callback?: ((newValue: ListboxValue) => void) | null | undefined; + disabled: boolean; + } | { type: ListboxEvents.KeyDownTab; } diff --git a/packages/listbox/styles.css b/packages/listbox/styles.css index a8713cb39..c982567fa 100644 --- a/packages/listbox/styles.css +++ b/packages/listbox/styles.css @@ -42,16 +42,16 @@ user-select: none; } -[data-reach-listbox-option][aria-selected="true"] { +[data-reach-listbox-option][data-current-nav] { background: hsl(211, 81%, 46%); color: hsl(0, 0%, 100%); } -[data-reach-listbox-option][data-current] { +[data-reach-listbox-option][data-current-selected] { font-weight: bolder; } -[data-reach-listbox-option][data-current][data-confirming] { +[data-reach-listbox-option][data-current-selected][data-confirming] { animation: flash 100ms; animation-iteration-count: 1; } diff --git a/packages/machine/package.json b/packages/machine/package.json index 7702ea893..6dff1405c 100644 --- a/packages/machine/package.json +++ b/packages/machine/package.json @@ -1,22 +1,25 @@ { "name": "@reach/machine", - "version": "0.13.1", + "version": "0.15.0", "description": "State machine utilities for the Reach UI library.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/machine" }, "dependencies": { - "@reach/utils": "0.13.1", + "@reach/utils": "0.15.0", "@xstate/fsm": "1.4.0", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/machine/src/index.tsx b/packages/machine/src/index.tsx index 5c35aab82..74de919b7 100644 --- a/packages/machine/src/index.tsx +++ b/packages/machine/src/index.tsx @@ -5,9 +5,10 @@ import { interpret, InterpreterStatus, } from "@xstate/fsm"; -import { isString, useConstant } from "@reach/utils"; +import { isString } from "@reach/utils/type-check"; +import { useConstant } from "@reach/utils/use-constant"; -import type { DistributiveOmit } from "@reach/utils"; +import type { DistributiveOmit } from "@reach/utils/types"; import type { EventObject as MachineEvent, StateMachine, diff --git a/packages/menu-button/__tests__/menu-button.test.tsx b/packages/menu-button/__tests__/menu-button.test.tsx index 5d326a80d..668bfde45 100644 --- a/packages/menu-button/__tests__/menu-button.test.tsx +++ b/packages/menu-button/__tests__/menu-button.test.tsx @@ -1,172 +1,342 @@ import * as React from "react"; -import { render, act, fireEvent, fireClickAndMouseEvents } from "$test/utils"; -import { AxeResults } from "$test/types"; +import { + render, + screen, + fireEvent, + simulateMouseClick, + simulateSpaceKeyClick, + simulateEnterKeyClick, +} from "$test/utils"; import { axe } from "jest-axe"; -import { Menu, MenuList, MenuButton, MenuItem } from "@reach/menu-button"; +import { + Menu, + MenuList, + MenuButton, + MenuItem, + MenuLink, +} from "@reach/menu-button"; -describe("", () => { - describe("rendering", () => { - it("should mount the component", () => { - let { queryByRole } = render( - - Actions - - Download - Create a Copy - - - ); - expect(queryByRole("button")).toBeTruthy(); +let noop = () => {}; + +describe(" with ", () => { + describe("a11y", () => { + it("Should not have ARIA violations", async () => { + let { container, list, button } = renderTestMenu(); + + let results = await axe(container); + expect(results).toHaveNoViolations(); + + // Toggle the menu and check again + simulateMouseClick(button); + results = await axe(container); + + // We have to check the container and list separately since the list is + // portaled outside of the container. + let listResults = await axe(list); + + expect(results).toHaveNoViolations(); + expect(listResults).toHaveNoViolations(); }); - it("should mount with render props", () => { - let { queryByRole } = render( - - {(props) => ( - - - {props.isExpanded ? "Close" : "Open"} Actions - - - Download - Create a Copy - - - )} - - ); - expect(queryByRole("button")).toBeTruthy(); + describe("ARIA attributes", () => { + it("`role` is set to `menu` for list element", () => { + let { button, list } = renderTestMenu(); + + // Toggle the menu so that it is rendered + simulateMouseClick(button); + expect(list).toHaveAttribute("role", "menu"); + }); + + it("`aria-controls` for button element points to the list element id", () => { + let rendered = renderTestMenu(); + + // Toggle the menu so that it is rendered + simulateMouseClick(rendered.button); + + let id = rendered.list.getAttribute("id"); + expect(rendered.button).toHaveAttribute("aria-controls", id); + }); + + it("`aria-haspopup` for button element is present", () => { + let rendered = renderTestMenu(); + expect(rendered.button).toHaveAttribute("aria-haspopup"); + }); + + describe("when the list is not toggled", () => { + it("`aria-expanded` for the button element is not present", () => { + let rendered = renderTestMenu(); + expect(rendered.button).not.toHaveAttribute("aria-expanded"); + }); + }); + + describe("when the list is toggled", () => { + let rendered: ReturnType; + beforeEach(() => { + rendered = renderTestMenu(); + simulateMouseClick(rendered.button); + }); + + it("`role` is set to `menuitem` for item elements", () => { + expect(rendered.items[0]).toHaveAttribute("role", "menuitem"); + expect(rendered.items[1]).toHaveAttribute("role", "menuitem"); + }); + + it("`aria-expanded` for the button element is true", async () => { + expect(rendered.button).toHaveAttribute("aria-expanded", "true"); + }); + + it("`aria-labelledby` for list element points to the button element id", () => { + let id = rendered.button.getAttribute("id"); + expect(rendered.list).toHaveAttribute("aria-labelledby", id); + }); + + it("`aria-activedescendant` for list element is not present", () => { + expect(rendered.list).not.toHaveAttribute("aria-activedescendant"); + }); + + describe("when mouse enters an item", () => { + it("`aria-activedescendant` for list element points to the active item", () => { + let item = rendered.items[0]; + let id = item.getAttribute("id"); + fireEvent.mouseEnter(item); + expect(rendered.list).toHaveAttribute("aria-activedescendant", id); + }); + }); + }); }); }); - describe("a11y", () => { - it("should not have basic a11y issues", async () => { - let { container } = render( + describe("rendering", () => { + it("passes DOM props to the button", () => { + let { getByRole } = render( - - Actions - + Actions - Download - Create a Copy - Mark as Draft - Delete + Download ); - let results: AxeResults = null as any; - await act(async () => { - results = await axe(container); - }); - expect(results).toHaveNoViolations(); + let button = getByRole("button"); + expect(button).toHaveAttribute("id", "test-id"); + }); + + it("should not show the menu list by default", () => { + let { list } = renderTestMenu(); + expect(list).not.toBeVisible(); }); }); describe("user events", () => { - it("should toggle on button click", () => { - let { getByRole, baseElement } = render( - - - Actions - - - Download - Create a Copy - Mark as Draft - Delete - - - ); - - let getPopover = () => - baseElement.querySelector("[data-reach-menu-popover]"); - - expect(getPopover()).not.toBeVisible(); + it("should show the list when the button is clicked", () => { + let rendered = renderTestMenu(); + simulateMouseClick(rendered.button); - fireClickAndMouseEvents(getByRole("button")); - expect(getPopover()).toBeVisible(); - fireClickAndMouseEvents(getByRole("button")); - expect(getPopover()).not.toBeVisible(); + expect(rendered.list).toBeVisible(); + simulateMouseClick(rendered.button); + expect(rendered.list).not.toBeVisible(); }); - it("should not re-focus the button when user selects an item with click", () => { - let { getByRole, getByText } = render( - - Actions - - Download - - - ); + it("should call `onSelect` when user selects an item", () => { + let rendered = renderTestMenu(); + simulateSpaceKeyClick(rendered.button, { fireClick: true }); + simulateSpaceKeyClick(rendered.items[0]); + expect(rendered.selectCallbacks[0]).toHaveBeenCalledTimes(1); + }); - fireClickAndMouseEvents(getByRole("button")); - fireClickAndMouseEvents(getByText("Download")); - expect(getByRole("button")).not.toHaveFocus(); + it("should not focus the button when user selects an item with click", () => { + let rendered = renderTestMenu(); + simulateMouseClick(rendered.button); + simulateMouseClick(rendered.items[0]); + expect(rendered.button).not.toHaveFocus(); }); it("should manage focus when user selects an item with `Space` key", () => { - let { getByRole, getByText } = render( - - Actions - - Download - - - ); + let rendered = renderTestMenu(); - fireEvent.keyDown(getByRole("button"), { key: " " }); - fireEvent.keyDown(getByText("Download"), { key: " " }); - expect(getByRole("button")).toHaveFocus(); + simulateSpaceKeyClick(rendered.button, { fireClick: true }); + simulateSpaceKeyClick(rendered.items[0]); + expect(rendered.button).toHaveFocus(); }); it("should manage focus when user selects an item with `Enter` key", () => { - let { getByRole, getByText } = render( - - Actions - - Download - - - ); - - fireEvent.keyDown(getByRole("button"), { key: "Enter" }); - fireEvent.keyDown(getByText("Download"), { key: "Enter" }); + let rendered = renderTestMenu(); - expect(getByRole("button")).toHaveFocus(); + simulateSpaceKeyClick(rendered.button, { fireClick: true }); + simulateEnterKeyClick(rendered.items[0]); + expect(rendered.button).toHaveFocus(); }); it("should manage focus when user dismisses with the `Escape` key", () => { - let { getByRole, getByText } = render( - - Actions - - Download - - - ); - - fireClickAndMouseEvents(getByRole("button")); - fireEvent.keyDown(getByText("Download"), { key: "Escape" }); - - expect(getByRole("button")).toHaveFocus(); + let rendered = renderTestMenu(); + simulateMouseClick(rendered.button); + fireEvent.keyDown(rendered.list, { key: "Escape" }); + expect(rendered.button).toHaveFocus(); }); it("should not manage focus when user clicks outside element", () => { - let { getByRole, getByTestId } = render( + let { getByRole } = render( <> - Actions + Actions - Download + Download - + ); + let button = getByRole("button"); + let input = getByRole("textbox"); - fireClickAndMouseEvents(getByRole("button")); - fireEvent.click(getByTestId("input")); - expect(getByRole("button")).not.toHaveFocus(); + simulateMouseClick(button); + fireEvent.click(input); + expect(button).not.toHaveFocus(); }); }); }); + +describe(" with ", () => { + describe("a11y", () => { + it("Should not have ARIA violations", async () => { + let { container, list, button } = renderTestMenuWithLinks(); + + let results = await axe(container); + expect(results).toHaveNoViolations(); + + // Toggle the menu and check again + simulateMouseClick(button); + results = await axe(container); + + // We have to check the container and list separately since the list is + // portaled outside of the container. + let listResults = await axe(list); + + expect(results).toHaveNoViolations(); + expect(listResults).toHaveNoViolations(); + }); + }); +}); + +describe(" with and ", () => { + describe("a11y", () => { + it("Should not have ARIA violations", async () => { + let { container, list, button } = renderTestMenuWithLinksAndItems(); + + let results = await axe(container); + expect(results).toHaveNoViolations(); + + // Toggle the menu and check again + simulateMouseClick(button); + results = await axe(container); + + // We have to check the container and list separately since the list is + // portaled outside of the container. + let listResults = await axe(list); + + expect(results).toHaveNoViolations(); + expect(listResults).toHaveNoViolations(); + }); + }); +}); + +function renderTestMenu() { + let cb1 = jest.fn(); + let cb2 = jest.fn(); + let { getByRole, container } = render( + + + Actions + + + Download + Create a Copy + + + ); + return { + container, + get root() { + return document.body; + }, + get button() { + return getByRole("button"); + }, + get list() { + return screen.getByTestId("list"); + }, + get items() { + return [screen.getByText("Download"), screen.getByText("Create a Copy")]; + }, + selectCallbacks: [cb1, cb2], + }; +} + +function renderTestMenuWithLinks() { + let cb1 = jest.fn(); + let cb2 = jest.fn(); + let { getByRole, container } = render( + + + Navigation + + + + Home + + + About + + + + ); + return { + container, + get root() { + return document.body; + }, + get button() { + return getByRole("button"); + }, + get list() { + return screen.getByTestId("list"); + }, + get items() { + return [screen.getByText("Home"), screen.getByText("About")]; + }, + selectCallbacks: [cb1, cb2], + }; +} + +function renderTestMenuWithLinksAndItems() { + let cb1 = jest.fn(); + let cb2 = jest.fn(); + let { getByRole, container } = render( + + + Actions and Links + + + Download + + About + + + + ); + return { + container, + get root() { + return document.body; + }, + get button() { + return getByRole("button"); + }, + get list() { + return screen.getByTestId("list"); + }, + get items() { + return [screen.getByText("Home"), screen.getByText("About")]; + }, + selectCallbacks: [cb1, cb2], + }; +} diff --git a/packages/menu-button/examples/basic.example.js b/packages/menu-button/examples/basic.example.js index d7b8e731f..0d11cf9b9 100644 --- a/packages/menu-button/examples/basic.example.js +++ b/packages/menu-button/examples/basic.example.js @@ -1,5 +1,11 @@ import * as React from "react"; -import { Menu, MenuList, MenuButton, MenuItem } from "@reach/menu-button"; +import { + Menu, + MenuList, + MenuLink, + MenuButton, + MenuItem, +} from "@reach/menu-button"; import { action } from "@storybook/addon-actions"; import "@reach/menu-button/styles.css"; @@ -7,17 +13,34 @@ let name = "Basic"; function Example() { return ( - - - Actions - - - Download - Create a Copy - Mark as Draft - Delete - - +
+ + + Actions{" "} + + + + Download + Create a Copy + Mark as Draft + Delete + + + + + Links{" "} + + + + Google + Duck Duck Go + + +
); } diff --git a/packages/menu-button/examples/basic.example.tsx b/packages/menu-button/examples/basic.example.tsx index 1241df13a..df143740c 100644 --- a/packages/menu-button/examples/basic.example.tsx +++ b/packages/menu-button/examples/basic.example.tsx @@ -1,23 +1,46 @@ import * as React from "react"; import { action } from "@storybook/addon-actions"; -import { Menu, MenuList, MenuButton, MenuItem } from "@reach/menu-button"; +import { + Menu, + MenuList, + MenuButton, + MenuItem, + MenuLink, +} from "@reach/menu-button"; import "@reach/menu-button/styles.css"; let name = "Basic (TS)"; function Example() { return ( - - - Actions - - - Download - Create a Copy - Mark as Draft - Delete - - +
+ + + Actions{" "} + + + + Download + Create a Copy + Mark as Draft + Delete + + + + + Links{" "} + + + + Google + Duck Duck Go + + +
); } diff --git a/packages/menu-button/examples/with-links.example.js b/packages/menu-button/examples/with-links.example.js deleted file mode 100644 index 628180505..000000000 --- a/packages/menu-button/examples/with-links.example.js +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react"; -import { action } from "@storybook/addon-actions"; -import { - Menu, - MenuList, - MenuButton, - MenuLink, - MenuItem, -} from "@reach/menu-button"; -import { - Router, - Link, - createMemorySource, - createHistory, - LocationProvider, -} from "@reach/router"; -import "@reach/menu-button/styles.css"; - -let name = "With Links"; - -function Example() { - return ( - - - - - - - ); -} - -Example.story = { name }; -export const Comp = Example; -export default { title: "MenuButton" }; - -//////////////////////////////////////////////////////////////////////////////// - -// this is because we're in an iframe and not a -// pushState server inside of storybook -let memoryHistory = createHistory(createMemorySource("/")); - -function Home() { - return ( -
-

Home

- - - Actions - - - Mark as Draft - - View Settings - - Delete - - -
- ); -} - -function Settings() { - return ( -
-

Settings

-

- Go Home -

-
- ); -} diff --git a/packages/menu-button/package.json b/packages/menu-button/package.json index 94273fc3f..bafeb5bde 100644 --- a/packages/menu-button/package.json +++ b/packages/menu-button/package.json @@ -1,25 +1,29 @@ { "name": "@reach/menu-button", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React button dropdown menu.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/menu-button" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/descendants": "0.13.1", - "@reach/popover": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/descendants": "0.15.0", + "@reach/popover": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/menu-button/src/index.tsx b/packages/menu-button/src/index.tsx index 0b2fc4917..de0f68869 100644 --- a/packages/menu-button/src/index.tsx +++ b/packages/menu-button/src/index.tsx @@ -13,8 +13,9 @@ import * as React from "react"; import PropTypes from "prop-types"; +import warning from "tiny-warning"; import { useId } from "@reach/auto-id"; -import { Popover, Position } from "@reach/popover"; +import { Popover } from "@reach/popover"; import { createDescendantContext, DescendantProvider, @@ -23,20 +24,19 @@ import { useDescendantsInit, useDescendantKeyDown, } from "@reach/descendants"; -import { - createNamedContext, - forwardRefWithAs, - getOwnerDocument, - isFunction, - isString, - makeId, - noop, - useCheckStyles, - useForkedRef, - usePrevious, - wrapEvent, -} from "@reach/utils"; - +import { isRightClick } from "@reach/utils/is-right-click"; +import { usePrevious } from "@reach/utils/use-previous"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { createNamedContext } from "@reach/utils/context"; +import { isFunction, isString } from "@reach/utils/type-check"; +import { makeId } from "@reach/utils/make-id"; +import { noop } from "@reach/utils/noop"; +import { useCheckStyles } from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; + +import type { Position } from "@reach/popover"; +import type * as Polymorphic from "@reach/utils/polymorphic"; import type { Descendant } from "@reach/descendants"; //////////////////////////////////////////////////////////////////////////////// @@ -55,9 +55,13 @@ const SET_BUTTON_ID = "SET_BUTTON_ID"; const MenuDescendantContext = createDescendantContext( "MenuDescendantContext" ); -const MenuContext = createNamedContext( - "MenuContext", - {} as InternalMenuContextValue +const StableMenuContext = createNamedContext( + "StableMenuContext", + {} as StableMenuContextValue +); +const UnstableMenuContext = createNamedContext( + "UnstableMenuContext", + {} as UnstableMenuContextValue ); const initialState: MenuButtonState = { @@ -118,15 +122,22 @@ const Menu: React.FC = ({ id, children }) => { // on most platforms, and our menu button popover works similarly. let readyToSelect = React.useRef(false); - let context: InternalMenuContextValue = { - buttonRef, - dispatch, + // Trying a new approach for splitting up contexts by stable/unstable + // references. We'll see how it goes! + let stableContext = React.useMemo(() => { + return { + buttonRef, + dispatch, + menuRef, + popoverRef, + buttonClickedRef, + readyToSelect, + selectCallbacks, + }; + }, []); + + let unstableContext: UnstableMenuContextValue = { menuId, - menuRef, - popoverRef, - buttonClickedRef, - readyToSelect, - selectCallbacks, state, }; @@ -156,15 +167,17 @@ const Menu: React.FC = ({ id, children }) => { items={descendants} set={setDescendants} > - - {isFunction(children) - ? children({ - isExpanded: state.isExpanded, - // TODO: Remove in 1.0 - isOpen: state.isExpanded, - }) - : children} - + + + {isFunction(children) + ? children({ + isExpanded: state.isExpanded, + // TODO: Remove in 1.0 + isOpen: state.isExpanded, + }) + : children} + + ); }; @@ -206,112 +219,106 @@ if (__DEV__) { * * @see Docs https://reach.tech/menu-button#menubutton */ -const MenuButton = forwardRefWithAs( - function MenuButton( - { as: Comp = "button", onKeyDown, onMouseDown, id, ...props }, - forwardedRef - ) { - let { - buttonRef, - buttonClickedRef, - menuId, - state: { buttonId, isExpanded }, - dispatch, - } = React.useContext(MenuContext); - let ref = useForkedRef(buttonRef, forwardedRef); - let items = useDescendants(MenuDescendantContext); - let firstNonDisabledIndex = React.useMemo( - () => items.findIndex((item) => !item.disabled), - [items] - ); - React.useEffect(() => { - let newButtonId = - id != null - ? id - : menuId - ? makeId("menu-button", menuId) - : "menu-button"; - if (buttonId !== newButtonId) { +const MenuButton = React.forwardRef(function MenuButton( + { as: Comp = "button", onKeyDown, onMouseDown, id, ...props }, + forwardedRef +) { + let { buttonRef, buttonClickedRef, dispatch } = React.useContext( + StableMenuContext + ); + let { + menuId, + state: { buttonId, isExpanded }, + } = React.useContext(UnstableMenuContext); + let ref = useComposedRefs(buttonRef, forwardedRef); + let items = useDescendants(MenuDescendantContext); + let firstNonDisabledIndex = React.useMemo( + () => items.findIndex((item) => !item.disabled), + [items] + ); + React.useEffect(() => { + let newButtonId = + id != null ? id : menuId ? makeId("menu-button", menuId) : "menu-button"; + if (buttonId !== newButtonId) { + dispatch({ + type: SET_BUTTON_ID, + payload: newButtonId, + }); + } + }, [buttonId, dispatch, id, menuId]); + + function handleKeyDown(event: React.KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + case "ArrowUp": + event.preventDefault(); // prevent scroll dispatch({ - type: SET_BUTTON_ID, - payload: newButtonId, + type: OPEN_MENU_AT_INDEX, + payload: { index: firstNonDisabledIndex }, }); - } - }, [buttonId, dispatch, id, menuId]); - - function handleKeyDown(event: React.KeyboardEvent) { - switch (event.key) { - case "ArrowDown": - case "ArrowUp": - event.preventDefault(); // prevent scroll - dispatch({ - type: OPEN_MENU_AT_INDEX, - payload: { index: firstNonDisabledIndex }, - }); - break; - case "Enter": - case " ": - dispatch({ - type: OPEN_MENU_AT_INDEX, - payload: { index: firstNonDisabledIndex }, - }); - break; - default: - break; - } + break; + case "Enter": + case " ": + dispatch({ + type: OPEN_MENU_AT_INDEX, + payload: { index: firstNonDisabledIndex }, + }); + break; + default: + break; } + } - function handleMouseDown(event: React.MouseEvent) { - if (!isExpanded) { - buttonClickedRef.current = true; - } - if (isRightClick(event.nativeEvent)) { - return; - } else if (isExpanded) { - dispatch({ type: CLOSE_MENU, payload: { buttonRef } }); - } else { - dispatch({ type: OPEN_MENU_CLEARED }); - } + function handleMouseDown(event: React.MouseEvent) { + if (!isExpanded) { + buttonClickedRef.current = true; + } + if (isRightClick(event.nativeEvent)) { + return; + } else if (isExpanded) { + dispatch({ type: CLOSE_MENU }); + } else { + dispatch({ type: OPEN_MENU_CLEARED }); } - - return ( - - ); } -); + + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"button", MenuButtonProps>; /** * @see Docs https://reach.tech/menu-button#menubutton-props */ -type MenuButtonProps = { +interface MenuButtonProps { /** * Accepts any renderable content. * * @see Docs https://reach.tech/menu-button#menubutton-children */ children: React.ReactNode; -}; +} if (__DEV__) { MenuButton.displayName = "MenuButton"; @@ -327,190 +334,192 @@ if (__DEV__) { * * MenuItem and MenuLink share most of the same functionality captured here. */ -const MenuItemImpl = forwardRefWithAs( - function MenuItemImpl( - { - as: Comp, - index: indexProp, - isLink = false, - onClick, - onDragStart, - onMouseDown, - onMouseEnter, - onMouseLeave, - onMouseMove, - onMouseUp, - onSelect, - disabled, - valueText: valueTextProp, - ...props +const MenuItemImpl = React.forwardRef(function MenuItemImpl( + { + as: Comp = "div", + index: indexProp, + isLink = false, + onClick, + onDragStart, + onMouseDown, + onMouseEnter, + onMouseLeave, + onMouseMove, + onMouseUp, + onSelect, + disabled, + valueText: valueTextProp, + ...props + }, + forwardedRef +) { + let { + buttonRef, + dispatch, + readyToSelect, + selectCallbacks, + } = React.useContext(StableMenuContext); + let { + state: { selectionIndex, isExpanded }, + } = React.useContext(UnstableMenuContext); + let ownRef = React.useRef(null); + // After the ref is mounted to the DOM node, we check to see if we have an + // explicit valueText prop before looking for the node's textContent for + // typeahead functionality. + let [valueText, setValueText] = React.useState(valueTextProp || ""); + + let setValueTextFromDOM = React.useCallback( + (node: HTMLElement) => { + if (!valueTextProp && node?.textContent) { + setValueText(node.textContent); + } }, - forwardedRef - ) { - let { - buttonRef, - dispatch, - readyToSelect, - selectCallbacks, - state: { selectionIndex, isExpanded }, - } = React.useContext(MenuContext); - let ownRef = React.useRef(null); - // After the ref is mounted to the DOM node, we check to see if we have an - // explicit valueText prop before looking for the node's textContent for - // typeahead functionality. - let [valueText, setValueText] = React.useState(valueTextProp || ""); - let setValueTextFromDom = React.useCallback( - (node) => { - if (node) { - ownRef.current = node; - if ( - !valueTextProp || - (node.textContent && valueText !== node.textContent) - ) { - setValueText(node.textContent); - } - } - }, - [valueText, valueTextProp] - ); + [valueTextProp] + ); - let ref = useForkedRef(forwardedRef, setValueTextFromDom); + let ref = useComposedRefs(forwardedRef, ownRef, setValueTextFromDOM); - let mouseEventStarted = React.useRef(false); + let mouseEventStarted = React.useRef(false); - let index = useDescendant( - { - element: ownRef.current!, - key: valueText, - disabled, - isLink, - }, - MenuDescendantContext, - indexProp - ); - let isSelected = index === selectionIndex && !disabled; + let index = useDescendant( + { + element: ownRef.current!, + key: valueText, + disabled, + isLink, + }, + MenuDescendantContext, + indexProp + ); + let isSelected = index === selectionIndex && !disabled; - // Update the callback ref array on every render - selectCallbacks.current[index] = onSelect; + // Update the callback ref array on every render + selectCallbacks.current[index] = onSelect; - function select() { - focus(buttonRef.current); - onSelect && onSelect(); - dispatch({ type: CLICK_MENU_ITEM }); - } + function select() { + focus(buttonRef.current); + onSelect && onSelect(); + dispatch({ type: CLICK_MENU_ITEM }); + } - function handleClick(event: React.MouseEvent) { - if (isLink && !isRightClick(event.nativeEvent)) { - if (disabled) { - event.preventDefault(); - } else { - select(); - } + function handleClick(event: React.MouseEvent) { + if (isLink && !isRightClick(event.nativeEvent)) { + if (disabled) { + event.preventDefault(); + } else { + select(); } } + } - function handleDragStart(event: React.MouseEvent) { - // Because we don't preventDefault on mousedown for links (we need the - // native click event), clicking and holding on a link triggers a - // dragstart which we don't want. - if (isLink) { - event.preventDefault(); - } + function handleDragStart(event: React.MouseEvent) { + // Because we don't preventDefault on mousedown for links (we need the + // native click event), clicking and holding on a link triggers a + // dragstart which we don't want. + if (isLink) { + event.preventDefault(); } + } - function handleMouseDown(event: React.MouseEvent) { - if (isRightClick(event.nativeEvent)) return; + function handleMouseDown(event: React.MouseEvent) { + if (isRightClick(event.nativeEvent)) { + return; + } - if (isLink) { - // Signal that the mouse is down so we can react call the right function - // if the user is clicking on a link. - mouseEventStarted.current = true; - } else { - event.preventDefault(); - } + if (isLink) { + // Signal that the mouse is down so we can call the right function if the + // user is clicking on a link. + mouseEventStarted.current = true; + } else { + event.preventDefault(); } + } - function handleMouseEnter(event: React.MouseEvent) { - if (!isSelected && index != null && !disabled) { - dispatch({ type: SELECT_ITEM_AT_INDEX, payload: { index } }); - } + function handleMouseEnter(event: React.MouseEvent) { + if (!isSelected && index != null && !disabled) { + dispatch({ type: SELECT_ITEM_AT_INDEX, payload: { index } }); } + } + + function handleMouseLeave(event: React.MouseEvent) { + // Clear out selection when mouse over a non-menu item child. + dispatch({ type: CLEAR_SELECTION_INDEX }); + } - function handleMouseLeave(event: React.MouseEvent) { - // Clear out selection when mouse over a non-menu item child. - dispatch({ type: CLEAR_SELECTION_INDEX }); + function handleMouseMove() { + readyToSelect.current = true; + if (!isSelected && index != null && !disabled) { + dispatch({ type: SELECT_ITEM_AT_INDEX, payload: { index } }); } + } - function handleMouseMove() { + function handleMouseUp(event: React.MouseEvent) { + if (!readyToSelect.current) { readyToSelect.current = true; - if (!isSelected && index != null && !disabled) { - dispatch({ type: SELECT_ITEM_AT_INDEX, payload: { index } }); - } + return; } - - function handleMouseUp(event: React.MouseEvent) { - if (!readyToSelect.current) { - readyToSelect.current = true; - return; + if (isRightClick(event.nativeEvent)) return; + + if (isLink) { + // If a mousedown event was initiated on a menu link followed by a + // mouseup event on the same link, we do nothing; a click event will + // come next and handle selection. Otherwise, we trigger a click event. + if (mouseEventStarted.current) { + mouseEventStarted.current = false; + } else if (ownRef.current) { + ownRef.current.click(); } - if (isRightClick(event.nativeEvent)) return; - - if (isLink) { - // If a mousedown event was initiated on a menu link followed by a - // mouseup event on the same link, we do nothing; a click event will - // come next and handle selection. Otherwise, we trigger a click event. - if (mouseEventStarted.current) { - mouseEventStarted.current = false; - } else if (ownRef.current) { - ownRef.current.click(); - } - } else { - if (!disabled) { - select(); - } + } else { + if (!disabled) { + select(); } } + } - // When the menu closes, reset readyToSelect for the next interaction. - React.useEffect(() => { - if (!isExpanded) { - readyToSelect.current = false; - } - }, [isExpanded, readyToSelect]); + // When the menu closes, reset readyToSelect for the next interaction. + React.useEffect(() => { + if (!isExpanded) { + readyToSelect.current = false; + } + }, [isExpanded, readyToSelect]); - // Any time a mouseup event occurs anywhere in the document, we reset the - // mouseEventStarted ref so we can check it again when needed. - React.useEffect(() => { - let ownerDocument = getOwnerDocument(ownRef.current)!; - let listener = () => (mouseEventStarted.current = false); - ownerDocument.addEventListener("mouseup", listener); - return () => ownerDocument.removeEventListener("mouseup", listener); - }, []); - - return ( - - ); - } -); + // Any time a mouseup event occurs anywhere in the document, we reset the + // mouseEventStarted ref so we can check it again when needed. + React.useEffect(() => { + let ownerDocument = getOwnerDocument(ownRef.current)!; + ownerDocument.addEventListener("mouseup", listener); + return () => { + ownerDocument.removeEventListener("mouseup", listener); + }; -type MenuItemImplProps = { + function listener() { + mouseEventStarted.current = false; + } + }, []); + + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", MenuItemImplProps>; + +interface MenuItemImplProps { /** * You can put any type of content inside of a ``. * @@ -532,7 +541,7 @@ type MenuItemImplProps = { * @see Docs https://reach.tech/menu-button#menuitem-disabled */ disabled?: boolean; -}; +} //////////////////////////////////////////////////////////////////////////////// @@ -543,12 +552,12 @@ type MenuItemImplProps = { * * @see Docs https://reach.tech/menu-button#menuitem */ -const MenuItem = forwardRefWithAs(function MenuItem( +const MenuItem = React.forwardRef(function MenuItem( { as = "div", ...props }, forwardedRef ) { return ; -}); +}) as Polymorphic.ForwardRefComponent<"div", MenuItemProps>; /** * @see Docs https://reach.tech/menu-button#menuitem-props @@ -574,20 +583,19 @@ if (__DEV__) { * * @see Docs https://reach.tech/menu-button#menuitems */ -const MenuItems = forwardRefWithAs(function MenuItems( +const MenuItems = React.forwardRef(function MenuItems( { as: Comp = "div", children, id, onKeyDown, ...props }, forwardedRef ) { + const { dispatch, buttonRef, menuRef, selectCallbacks } = React.useContext( + StableMenuContext + ); const { menuId, - dispatch, - buttonRef, - menuRef, - selectCallbacks, state: { isExpanded, buttonId, selectionIndex, typeaheadQuery }, - } = React.useContext(MenuContext); + } = React.useContext(UnstableMenuContext); const menuItems = useDescendants(MenuDescendantContext); - const ref = useForkedRef(menuRef, forwardedRef); + const ref = useComposedRefs(menuRef, forwardedRef); React.useEffect(() => { // Respond to user char key input with typeahead @@ -647,7 +655,7 @@ const MenuItems = forwardRefWithAs(function MenuItems( selectionIndex, ]); - let handleKeyDown = wrapEvent( + let handleKeyDown = composeEventHandlers( function handleKeyDown(event: React.KeyboardEvent) { let { key } = event; @@ -681,7 +689,7 @@ const MenuItems = forwardRefWithAs(function MenuItems( break; case "Escape": focus(buttonRef.current); - dispatch({ type: CLOSE_MENU, payload: { buttonRef } }); + dispatch({ type: CLOSE_MENU }); break; case "Tab": // prevent leaving @@ -737,24 +745,24 @@ const MenuItems = forwardRefWithAs(function MenuItems( ref={ref} data-reach-menu-items="" id={menuId} - onKeyDown={wrapEvent(onKeyDown, handleKeyDown)} + onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)} > {children} ); -}); +}) as Polymorphic.ForwardRefComponent<"div", MenuItemsProps>; /** * @see Docs https://reach.tech/menu-button#menuitems-props */ -type MenuItemsProps = { +interface MenuItemsProps { /** * Can contain only `MenuItem` or a `MenuLink`. * * @see Docs https://reach.tech/menu-button#menuitems-children */ children: React.ReactNode; -}; +} if (__DEV__) { MenuItems.displayName = "MenuItems"; @@ -776,28 +784,32 @@ if (__DEV__) { * * @see Docs https://reach.tech/menu-button#menulink */ -const MenuLink = forwardRefWithAs( - function MenuLink({ as = "a", component, onSelect, ...props }, forwardedRef) { - if (component) { - console.warn( - "[@reach/menu-button]: Please use the `as` prop instead of `component`." - ); - } +const MenuLink = React.forwardRef(function MenuLink( + { + as = "a", + // @ts-ignore + component, + onSelect, + ...props + }, + forwardedRef +) { + useDevWarning( + !component, + "[@reach/menu-button]: Please use the `as` prop instead of `component`" + ); - return ( -
- -
- ); - } -); + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"a", MenuLinkProps>; /** * @see Docs https://reach.tech/menu-button#menulink-props @@ -810,7 +822,6 @@ if (__DEV__) { MenuLink.displayName = "MenuLink"; MenuLink.propTypes = { as: PropTypes.any, - component: PropTypes.any, }; } @@ -824,7 +835,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/menu-button#menulist */ -const MenuList = forwardRefWithAs(function MenuList( +const MenuList = React.forwardRef(function MenuList( { portal = true, ...props }, forwardedRef ) { @@ -833,12 +844,12 @@ const MenuList = forwardRefWithAs(function MenuList( ); -}); +}) as Polymorphic.ForwardRefComponent<"div", MenuListProps>; /** * @see Docs https://reach.tech/menu-button#menulist-props */ -type MenuListProps = { +interface MenuListProps { /** * Whether or not the popover should be rendered inside a portal. Defaults to * `true`. @@ -852,7 +863,7 @@ type MenuListProps = { * @see Docs https://reach.tech/menu-button#menulist-children */ children: React.ReactNode; -}; +} if (__DEV__) { MenuList.displayName = "MenuList"; @@ -864,90 +875,90 @@ if (__DEV__) { //////////////////////////////////////////////////////////////////////////////// /** - * MenuPopover - * - * A low-level wrapper for the popover that appears when a menu button is open. - * You can compose it with `MenuItems` for more control over the nested - * components and their rendered DOM nodes, or if you need to nest arbitrary - * components between the outer wrapper and your list. - * - * @see Docs https://reach.tech/menu-button#menupopover - */ -const MenuPopover = forwardRefWithAs( - function MenuPopover( - { as: Comp = "div", children, portal = true, position, ...props }, - forwardedRef - ) { - const { - buttonRef, - buttonClickedRef, - dispatch, - menuRef, - popoverRef, - state: { isExpanded }, - } = React.useContext(MenuContext); + * + + * + * A low-level wrapper for the popover that appears when a menu button is open. + * You can compose it with `MenuItems` for more control over the nested + * components and their rendered DOM nodes, or if you need to nest arbitrary + * components between the outer wrapper and your list. + * + * @see Docs https://reach.tech/menu-button#menupopover + */ +const MenuPopover = React.forwardRef(function MenuPopover( + { as: Comp = "div", children, onBlur, portal = true, position, ...props }, + forwardedRef +) { + const { + buttonRef, + buttonClickedRef, + dispatch, + menuRef, + popoverRef, + } = React.useContext(StableMenuContext); + const { + state: { isExpanded }, + } = React.useContext(UnstableMenuContext); - const ref = useForkedRef(popoverRef, forwardedRef); + const ref = useComposedRefs(popoverRef, forwardedRef); - React.useEffect(() => { - if (!isExpanded) { - return; - } + React.useEffect(() => { + if (!isExpanded) { + return; + } - let ownerDocument = getOwnerDocument(popoverRef.current)!; - function listener(event: MouseEvent | TouchEvent) { - if (buttonClickedRef.current) { - buttonClickedRef.current = false; - } else if ( - !popoverContainsEventTarget(popoverRef.current, event.target) - ) { - // We on want to close only if focus rests outside the menu - dispatch({ type: CLOSE_MENU, payload: { buttonRef } }); - } + let ownerDocument = getOwnerDocument(popoverRef.current)!; + function listener(event: MouseEvent | TouchEvent) { + if (buttonClickedRef.current) { + buttonClickedRef.current = false; + } else if ( + !popoverContainsEventTarget(popoverRef.current, event.target) + ) { + // We on want to close only if focus rests outside the menu + dispatch({ type: CLOSE_MENU }); } - ownerDocument.addEventListener("mousedown", listener); - // see https://github.com/reach/reach-ui/pull/700#discussion_r530369265 - // ownerDocument.addEventListener("touchstart", listener); - return () => { - ownerDocument.removeEventListener("mousedown", listener); - // ownerDocument.removeEventListener("touchstart", listener); - }; - }, [ - buttonClickedRef, - buttonRef, - dispatch, - menuRef, - popoverRef, - isExpanded, - ]); - - let commonProps = { - ref, - // TODO: remove in 1.0 - "data-reach-menu": "", - "data-reach-menu-popover": "", - hidden: !isExpanded, - children, - ...props, + } + ownerDocument.addEventListener("mousedown", listener); + // see https://github.com/reach/reach-ui/pull/700#discussion_r530369265 + // ownerDocument.addEventListener("touchstart", listener); + return () => { + ownerDocument.removeEventListener("mousedown", listener); + // ownerDocument.removeEventListener("touchstart", listener); }; + }, [buttonClickedRef, buttonRef, dispatch, menuRef, popoverRef, isExpanded]); + + let commonProps = { + ref, + // TODO: remove in 1.0 + "data-reach-menu": "", + "data-reach-menu-popover": "", + hidden: !isExpanded, + children, + onBlur: composeEventHandlers(onBlur, (event) => { + if (event.currentTarget.contains(event.relatedTarget as Node)) { + return; + } + dispatch({ type: CLOSE_MENU }); + }), + ...props, + }; - return portal ? ( - - ) : ( - - ); - } -); + return portal ? ( + + ) : ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", MenuPopoverProps>; /** * @see Docs https://reach.tech/menu-button#menupopover-props */ -type MenuPopoverProps = { +interface MenuPopoverProps { /** * Must contain a `MenuItems` * @@ -971,7 +982,7 @@ type MenuPopoverProps = { * @see Docs https://reach.tech/menu-button#menupopover-position */ position?: Position; -}; +} if (__DEV__) { MenuPopover.displayName = "MenuPopover"; @@ -990,7 +1001,7 @@ if (__DEV__) { function useMenuButtonContext(): MenuContextValue { let { state: { isExpanded }, - } = React.useContext(MenuContext); + } = React.useContext(UnstableMenuContext); return React.useMemo(() => ({ isExpanded }), [isExpanded]); } @@ -1018,7 +1029,7 @@ function findItemFromTypeahead( } function useMenuItemId(index: number | null) { - let { menuId } = React.useContext(MenuContext); + let { menuId } = React.useContext(UnstableMenuContext); return index != null && index > -1 ? makeId(`option-${index}`, menuId) : undefined; @@ -1033,7 +1044,7 @@ interface MenuButtonState { type MenuButtonAction = | { type: "CLICK_MENU_ITEM" } - | { type: "CLOSE_MENU"; payload: { buttonRef: ButtonRef } } + | { type: "CLOSE_MENU" } | { type: "OPEN_MENU_AT_FIRST_ITEM" } | { type: "OPEN_MENU_AT_INDEX"; payload: { index: number } } | { type: "OPEN_MENU_CLEARED" } @@ -1045,10 +1056,6 @@ type MenuButtonAction = | { type: "SET_BUTTON_ID"; payload: string } | { type: "SEARCH_FOR_ITEM"; payload: string }; -function isRightClick(nativeEvent: MouseEvent) { - return nativeEvent.which === 3 || nativeEvent.button === 2; -} - function focus( element: T | undefined | null ) { @@ -1131,6 +1138,20 @@ function reducer( } } +function useDevWarning(condition: any, message: string) { + if (__DEV__) { + /* eslint-disable react-hooks/rules-of-hooks */ + let messageRef = React.useRef(message); + React.useEffect(() => { + messageRef.current = message; + }, [message]); + React.useEffect(() => { + warning(condition, messageRef.current); + }, [condition]); + /* eslint-enable react-hooks/rules-of-hooks */ + } +} + //////////////////////////////////////////////////////////////////////////////// // Types @@ -1144,22 +1165,25 @@ type ButtonRef = React.RefObject; type MenuRef = React.RefObject; type PopoverRef = React.RefObject; -interface InternalMenuContextValue { +interface UnstableMenuContextValue { + menuId: string | undefined; + state: MenuButtonState; +} + +interface StableMenuContextValue { buttonRef: ButtonRef; buttonClickedRef: React.MutableRefObject; dispatch: React.Dispatch; - menuId: string | undefined; menuRef: MenuRef; popoverRef: PopoverRef; readyToSelect: React.MutableRefObject; selectCallbacks: React.MutableRefObject<(() => void)[]>; - state: MenuButtonState; } -type MenuContextValue = { +interface MenuContextValue { isExpanded: boolean; // id: string | undefined; -}; +} //////////////////////////////////////////////////////////////////////////////// // Exports diff --git a/packages/popover/package.json b/packages/popover/package.json index 61c535e49..fcdc163dc 100644 --- a/packages/popover/package.json +++ b/packages/popover/package.json @@ -1,24 +1,27 @@ { "name": "@reach/popover", - "version": "0.13.1", + "version": "0.15.0", "description": "Render a portal positioned relative to another element.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/popover" }, "dependencies": { - "@reach/portal": "0.13.1", - "@reach/rect": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/portal": "0.15.0", + "@reach/rect": "0.15.0", + "@reach/utils": "0.15.0", "tabbable": "^4.0.0", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/popover/src/index.tsx b/packages/popover/src/index.tsx index 2406c637a..8290c29ac 100644 --- a/packages/popover/src/index.tsx +++ b/packages/popover/src/index.tsx @@ -5,28 +5,28 @@ import * as React from "react"; import { Portal } from "@reach/portal"; import { useRect, PRect } from "@reach/rect"; -import { forwardRefWithAs, getOwnerDocument, useForkedRef } from "@reach/utils"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { useComposedRefs } from "@reach/utils/compose-refs"; import tabbable from "tabbable"; +import type * as Polymorphic from "@reach/utils/polymorphic"; + //////////////////////////////////////////////////////////////////////////////// /** * Popover */ -const Popover = forwardRefWithAs(function Popover( - props, - ref -) { +const Popover = React.forwardRef(function Popover(props, ref) { return ( ); -}); +}) as Polymorphic.ForwardRefComponent<"div", PopoverProps>; -type PopoverProps = { +interface PopoverProps { children: React.ReactNode; - targetRef: React.RefObject; + targetRef: React.RefObject; position?: Position; /** * Render the popover markup, but hide it – used by MenuButton so that it @@ -43,7 +43,7 @@ type PopoverProps = { * anywhere in public yet! */ unstable_observableRefs?: React.RefObject[]; -}; +} if (__DEV__) { Popover.displayName = "Popover"; @@ -57,7 +57,7 @@ if (__DEV__) { * Popover is conditionally rendered so we can't start measuring until it shows * up, so useRect needs to live down here not up in Popover */ -const PopoverImpl = forwardRefWithAs(function PopoverImpl( +const PopoverImpl = React.forwardRef(function PopoverImpl( { as: Comp = "div", targetRef, @@ -70,9 +70,9 @@ const PopoverImpl = forwardRefWithAs(function PopoverImpl( const popoverRef = React.useRef(null); const popoverRect = useRect(popoverRef, { observe: !props.hidden }); const targetRect = useRect(targetRef, { observe: !props.hidden }); - const ref = useForkedRef(popoverRef, forwardedRef); + const ref = useComposedRefs(popoverRef, forwardedRef); - useSimulateTabNavigationForReactTree(targetRef, popoverRef); + useSimulateTabNavigationForReactTree(targetRef as any, popoverRef); return ( (function PopoverImpl( }} /> ); -}); +}) as Polymorphic.ForwardRefComponent<"div", PopoverProps>; if (__DEV__) { PopoverImpl.displayName = "PopoverImpl"; @@ -114,10 +114,13 @@ function getStyles( : { visibility: "hidden" }; } -function getTopPosition(targetRect: PRect, popoverRect: PRect) { - const { directionUp } = getCollisions(targetRect, popoverRect); +function getTopPosition( + targetRect: PRect, + popoverRect: PRect, + isDirectionUp: boolean +) { return { - top: directionUp + top: isDirectionUp ? `${targetRect.top - popoverRect.height + window.pageYOffset}px` : `${targetRect.top + targetRect.height + window.pageYOffset}px`, }; @@ -128,12 +131,15 @@ const positionDefault: Position = (targetRect, popoverRect) => { return {}; } - const { directionRight } = getCollisions(targetRect, popoverRect); + const { directionRight, directionUp } = getCollisions( + targetRect, + popoverRect + ); return { left: directionRight ? `${targetRect.right - popoverRect.width + window.pageXOffset}px` : `${targetRect.left + window.pageXOffset}px`, - ...getTopPosition(targetRect, popoverRect), + ...getTopPosition(targetRect, popoverRect, directionUp), }; }; @@ -142,12 +148,12 @@ const positionRight: Position = (targetRect, popoverRect) => { return {}; } - const { directionLeft } = getCollisions(targetRect, popoverRect); + const { directionLeft, directionUp } = getCollisions(targetRect, popoverRect); return { left: directionLeft ? `${targetRect.left + window.pageXOffset}px` : `${targetRect.right - popoverRect.width + window.pageXOffset}px`, - ...getTopPosition(targetRect, popoverRect), + ...getTopPosition(targetRect, popoverRect, directionUp), }; }; @@ -156,10 +162,11 @@ const positionMatchWidth: Position = (targetRect, popoverRect) => { return {}; } + const { directionUp } = getCollisions(targetRect, popoverRect); return { width: targetRect.width, left: targetRect.left, - ...getTopPosition(targetRect, popoverRect), + ...getTopPosition(targetRect, popoverRect, directionUp), }; }; diff --git a/packages/portal/package.json b/packages/portal/package.json index 86019f441..d21614f73 100644 --- a/packages/portal/package.json +++ b/packages/portal/package.json @@ -1,21 +1,24 @@ { "name": "@reach/portal", - "version": "0.13.1", + "version": "0.15.0", "description": "Declarative portals for React", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/portal" }, "dependencies": { - "@reach/utils": "0.13.1", - "tslib": "^2.0.0" + "@reach/utils": "0.15.0", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/portal/src/index.tsx b/packages/portal/src/index.tsx index 9fe314242..447634789 100644 --- a/packages/portal/src/index.tsx +++ b/packages/portal/src/index.tsx @@ -12,7 +12,8 @@ */ import * as React from "react"; -import { useIsomorphicLayoutEffect, useForceUpdate } from "@reach/utils"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { useForceUpdate } from "@reach/utils/use-force-update"; import { createPortal } from "react-dom"; /** @@ -25,7 +26,7 @@ const Portal: React.FC = ({ children, type = "reach-portal" }) => { let portalNode = React.useRef(null); let forceUpdate = useForceUpdate(); - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { // This ref may be null when a hot-loader replaces components on the page if (!mountNode.current) return; // It's possible that the content of the portal has, itself, been portaled. diff --git a/packages/rect/package.json b/packages/rect/package.json index e30b3e115..7bc53d4fb 100644 --- a/packages/rect/package.json +++ b/packages/rect/package.json @@ -1,9 +1,12 @@ { "name": "@reach/rect", - "version": "0.13.1", + "version": "0.15.0", "description": "Measure React elements position in the DOM", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", @@ -11,13 +14,14 @@ }, "dependencies": { "@reach/observe-rect": "1.2.0", - "@reach/utils": "0.13.1", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/rect/src/index.tsx b/packages/rect/src/index.tsx index b3d0374ff..461f62486 100644 --- a/packages/rect/src/index.tsx +++ b/packages/rect/src/index.tsx @@ -11,12 +11,9 @@ import * as React from "react"; import PropTypes from "prop-types"; import observeRect from "@reach/observe-rect"; -import { - isBoolean, - isFunction, - useIsomorphicLayoutEffect as useLayoutEffect, - warning, -} from "@reach/utils"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { isBoolean, isFunction } from "@reach/utils/type-check"; +import warning from "tiny-warning"; //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/skip-nav/__tests__/skip-nav.test.tsx b/packages/skip-nav/__tests__/skip-nav.test.tsx index 06e1fed49..81599ede2 100644 --- a/packages/skip-nav/__tests__/skip-nav.test.tsx +++ b/packages/skip-nav/__tests__/skip-nav.test.tsx @@ -5,7 +5,7 @@ import { SkipNavLink, SkipNavContent } from "@reach/skip-nav"; describe("", () => { describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { let { container } = render(); expect(await axe(container)).toHaveNoViolations(); }); diff --git a/packages/skip-nav/package.json b/packages/skip-nav/package.json index 43148ba5f..2eff83335 100644 --- a/packages/skip-nav/package.json +++ b/packages/skip-nav/package.json @@ -1,21 +1,24 @@ { "name": "@reach/skip-nav", - "version": "0.13.1", + "version": "0.15.0", "description": "Skip navigation links for screen reader and keyboard users.", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/skip-nav" }, "dependencies": { - "@reach/utils": "0.13.1", - "tslib": "^2.0.0" + "@reach/utils": "0.15.0", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/skip-nav/src/index.tsx b/packages/skip-nav/src/index.tsx index 7531ed042..d50a85ea2 100644 --- a/packages/skip-nav/src/index.tsx +++ b/packages/skip-nav/src/index.tsx @@ -1,5 +1,7 @@ import * as React from "react"; -import { forwardRefWithAs, useCheckStyles } from "@reach/utils"; +import { useCheckStyles } from "@reach/utils/dev-utils"; + +import type * as Polymorphic from "@reach/utils/polymorphic"; // The user may want to provide their own ID (maybe there are multiple nav // menus on a page a use might want to skip at various points in tabbing?). @@ -14,32 +16,30 @@ let defaultId = "reach-skip-nav"; * * @see Docs https://reach.tech/skip-nav#skipnavlink */ -const SkipNavLink = forwardRefWithAs( - function SkipNavLink( - { as: Comp = "a", children = "Skip to content", contentId, ...props }, - forwardedRef - ) { - let id = contentId || defaultId; - useCheckStyles("skip-nav"); - return ( - - {children} - - ); - } -); +const SkipNavLink = React.forwardRef(function SkipNavLink( + { as: Comp = "a", children = "Skip to content", contentId, ...props }, + forwardedRef +) { + let id = contentId || defaultId; + useCheckStyles("skip-nav"); + return ( + + {children} + + ); +}) as Polymorphic.ForwardRefComponent<"a", SkipNavLinkProps>; /** * @see Docs https://reach.tech/skip-nav#skipnavlink-props */ -type SkipNavLinkProps = { +interface SkipNavLinkProps { /** * Allows you to change the text for your preferred phrase or localization. * @@ -53,7 +53,7 @@ type SkipNavLinkProps = { * @see Docs https://reach.tech/skip-nav#skipnavlink-contentid */ contentId?: string; -}; +} if (__DEV__) { SkipNavLink.displayName = "SkipNavLink"; @@ -68,27 +68,25 @@ if (__DEV__) { * * @see Docs https://reach.tech/skip-nav#skipnavcontent */ -const SkipNavContent = forwardRefWithAs( - function SkipNavContent( - { as: Comp = "div", id: idProp, ...props }, - forwardedRef - ) { - let id = idProp || defaultId; - return ( - - ); - } -); +const SkipNavContent = React.forwardRef(function SkipNavContent( + { as: Comp = "div", id: idProp, ...props }, + forwardedRef +) { + let id = idProp || defaultId; + return ( + + ); +}) as Polymorphic.ForwardRefComponent<"div", SkipNavContentProps>; /** * @see Docs https://reach.tech/skip-nav#skipnavcontent-props */ -type SkipNavContentProps = { +interface SkipNavContentProps { /** * You can place the `SkipNavContent` element as a sibling to your main * content or as a wrapper. @@ -114,7 +112,7 @@ type SkipNavContentProps = { * @see Docs https://reach.tech/skip-nav#skipnavcontent-id */ id?: string; -}; +} if (__DEV__) { SkipNavContent.displayName = "SkipNavContent"; diff --git a/packages/slider/__tests__/slider.test.tsx b/packages/slider/__tests__/slider.test.tsx index 8fa494a56..40000076a 100644 --- a/packages/slider/__tests__/slider.test.tsx +++ b/packages/slider/__tests__/slider.test.tsx @@ -23,7 +23,7 @@ describe("", () => { describe("rendering", () => {}); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { const { container } = render(); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/packages/slider/examples/with-tooltip.example.js b/packages/slider/examples/with-tooltip.example.js index 855ad4512..f58f7ba0e 100644 --- a/packages/slider/examples/with-tooltip.example.js +++ b/packages/slider/examples/with-tooltip.example.js @@ -1,5 +1,5 @@ import * as React from "react"; -import { wrapEvent } from "@reach/utils"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { useTooltip, TooltipPopup } from "@reach/tooltip"; import { SliderInput, @@ -35,7 +35,7 @@ function Example() { }, []); const getEventHandler = (handler) => - wrapEvent(preventDefaultWhenFocused, handler); + composeEventHandlers(preventDefaultWhenFocused, handler); return ( diff --git a/packages/slider/package.json b/packages/slider/package.json index 4cd55b3b4..b1e15dfa5 100644 --- a/packages/slider/package.json +++ b/packages/slider/package.json @@ -1,23 +1,27 @@ { "name": "@reach/slider", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React Slider Component", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/slider" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.9.0 || 17.x", diff --git a/packages/slider/src/index.tsx b/packages/slider/src/index.tsx index fd6044468..90c2d5547 100644 --- a/packages/slider/src/index.tsx +++ b/packages/slider/src/index.tsx @@ -25,24 +25,24 @@ import * as React from "react"; import PropTypes from "prop-types"; import { useId } from "@reach/auto-id"; +import { useControlledState } from "@reach/utils/use-controlled-state"; +import { isRightClick } from "@reach/utils/is-right-click"; +import { useStableLayoutCallback } from "@reach/utils/use-stable-callback"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { createNamedContext } from "@reach/utils/context"; +import { isFunction } from "@reach/utils/type-check"; +import { makeId } from "@reach/utils/make-id"; +import { noop } from "@reach/utils/noop"; import { - createNamedContext, - forwardRefWithAs, - getOwnerDocument, - isFunction, - isRightClick, - makeId, - memoWithAs, - noop, useCheckStyles, - useControlledState, useControlledSwitchWarning, - useEventCallback, - useForkedRef, - useIsomorphicLayoutEffect, - warning, - wrapEvent, -} from "@reach/utils"; +} from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; +import warning from "tiny-warning"; + +import type * as Polymorphic from "@reach/utils/polymorphic"; // TODO: Remove in 1.0 type SliderAlignment = "center" | "contain"; @@ -105,7 +105,7 @@ const sliderPropTypes = { * * @see Docs https://reach.tech/slider#slider */ -const Slider = forwardRefWithAs(function Slider( +const Slider = React.forwardRef(function Slider( { children, ...props }, forwardedRef ) { @@ -114,7 +114,7 @@ const Slider = forwardRefWithAs(function Slider( {...props} ref={forwardedRef} data-reach-slider="" - _componentName="Slider" + __componentName="Slider" > @@ -123,12 +123,12 @@ const Slider = forwardRefWithAs(function Slider( ); -}); +}) as Polymorphic.ForwardRefComponent<"div", SliderProps>; /** * @see Docs https://reach.tech/slider#slider-props */ -type SliderProps = { +interface SliderProps { /** * `Slider` can accept `SliderMarker` children to enhance display of specific * values along the track. @@ -244,7 +244,7 @@ type SliderProps = { * @see Docs https://reach.tech/slider#slider-step */ step?: number; -}; +} if (__DEV__) { Slider.displayName = "Slider"; @@ -265,9 +265,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/slider#sliderinput */ -const SliderInput = forwardRefWithAs< - SliderInputProps & { _componentName?: string } ->(function SliderInput( +const SliderInput = React.forwardRef(function SliderInput( { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, @@ -296,12 +294,12 @@ const SliderInput = forwardRefWithAs< orientation = SliderOrientation.Horizontal, step = 1, children, - _componentName = "SliderInput", + __componentName = "SliderInput", ...rest }, forwardedRef ) { - useControlledSwitchWarning(controlledValue, "value", _componentName); + useControlledSwitchWarning(controlledValue, "value", __componentName); warning( !DEPRECATED_getValueText, @@ -318,7 +316,7 @@ const SliderInput = forwardRefWithAs< let trackRef: TrackRef = React.useRef(null); let handleRef: HandleRef = React.useRef(null); let sliderRef: SliderRef = React.useRef(null); - let ref = useForkedRef(sliderRef, forwardedRef); + let ref = useComposedRefs(sliderRef, forwardedRef); let [hasFocus, setHasFocus] = React.useState(false); @@ -345,12 +343,12 @@ const SliderInput = forwardRefWithAs< : `${handleSize}px * ${trackPercent * 0.01}` })`; let handlePositionRef = React.useRef(handlePosition); - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { handlePositionRef.current = handlePosition; }, [handlePosition]); let onChangeRef = React.useRef(onChange); - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { onChangeRef.current = onChange; }, [onChange]); let updateValue = React.useCallback( @@ -383,7 +381,7 @@ const SliderInput = forwardRefWithAs< ); // https://www.w3.org/TR/wai-aria-practices-1.2/#slider_kbd_interaction - let handleKeyDown = useEventCallback((event: React.KeyboardEvent) => { + let handleKeyDown = useStableLayoutCallback((event: React.KeyboardEvent) => { if (disabled) { return; } @@ -487,7 +485,7 @@ const SliderInput = forwardRefWithAs< onPointerDown, onPointerUp, }); - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { appEvents.current.onMouseMove = onMouseMove; appEvents.current.onMouseDown = onMouseDown; appEvents.current.onMouseUp = onMouseUp; @@ -498,7 +496,7 @@ const SliderInput = forwardRefWithAs< appEvents.current.onPointerUp = onPointerUp; }, [onMouseMove, onMouseDown, onMouseUp, onTouchStart, onTouchEnd, onTouchMove, onPointerDown, onPointerUp]); - let handleSlideStart = useEventCallback((event: SomePointerEvent) => { + let handleSlideStart = useStableLayoutCallback((event: SomePointerEvent) => { if (isRightClick(event)) return; if (disabled) { @@ -530,7 +528,7 @@ const SliderInput = forwardRefWithAs< removeEndEvents.current = addEndListener(); }); - let setPointerCapture = useEventCallback((event: PointerEvent) => { + let setPointerCapture = useStableLayoutCallback((event: PointerEvent) => { if (isRightClick(event)) return; if (disabled) { pointerDownRef.current = false; @@ -540,13 +538,13 @@ const SliderInput = forwardRefWithAs< sliderRef.current?.setPointerCapture(event.pointerId); }); - let releasePointerCapture = useEventCallback((event: PointerEvent) => { + let releasePointerCapture = useStableLayoutCallback((event: PointerEvent) => { if (isRightClick(event)) return; sliderRef.current?.releasePointerCapture(event.pointerId); pointerDownRef.current = false; }); - let handlePointerMove = useEventCallback((event: SomePointerEvent) => { + let handlePointerMove = useStableLayoutCallback((event: SomePointerEvent) => { if (disabled || !pointerDownRef.current) { pointerDownRef.current = false; return; @@ -559,7 +557,7 @@ const SliderInput = forwardRefWithAs< updateValue(newValue); }); - let handleSlideStop = useEventCallback((event: SomePointerEvent) => { + let handleSlideStop = useStableLayoutCallback((event: SomePointerEvent) => { if (isRightClick(event)) return; pointerDownRef.current = false; @@ -577,11 +575,11 @@ const SliderInput = forwardRefWithAs< let addMoveListener = React.useCallback(() => { let ownerDocument = getOwnerDocument(sliderRef.current)!; - let touchListener = wrapEvent( + let touchListener = composeEventHandlers( appEvents.current.onTouchMove, handlePointerMove ); - let mouseListener = wrapEvent( + let mouseListener = composeEventHandlers( appEvents.current.onMouseMove, handlePointerMove ); @@ -596,15 +594,18 @@ const SliderInput = forwardRefWithAs< let addEndListener = React.useCallback(() => { let ownerDocument = getOwnerDocument(sliderRef.current)!; let ownerWindow = ownerDocument.defaultView || window; - let pointerListener = wrapEvent( + let pointerListener = composeEventHandlers( appEvents.current.onPointerUp, releasePointerCapture ); - let touchListener = wrapEvent( + let touchListener = composeEventHandlers( appEvents.current.onTouchEnd, handleSlideStop ); - let mouseListener = wrapEvent(appEvents.current.onMouseUp, handleSlideStop); + let mouseListener = composeEventHandlers( + appEvents.current.onMouseUp, + handleSlideStop + ); if ("PointerEvent" in ownerWindow) { ownerDocument.addEventListener("pointerup", pointerListener); } @@ -630,15 +631,15 @@ const SliderInput = forwardRefWithAs< let ownerDocument = getOwnerDocument(sliderElement)!; let ownerWindow = ownerDocument.defaultView || window; - let touchListener = wrapEvent( + let touchListener = composeEventHandlers( appEvents.current.onTouchStart, handleSlideStart ); - let mouseListener = wrapEvent( + let mouseListener = composeEventHandlers( appEvents.current.onMouseDown, handleSlideStart ); - let pointerListener = wrapEvent( + let pointerListener = composeEventHandlers( appEvents.current.onPointerDown, setPointerCapture ); @@ -704,7 +705,10 @@ const SliderInput = forwardRefWithAs< ); -}); +}) as Polymorphic.ForwardRefComponent< + "div", + SliderInputProps & { __componentName?: string } +>; /** * @see Docs https://reach.tech/slider#sliderinput-props @@ -736,12 +740,12 @@ if (__DEV__) { * * @see Docs https://reach.tech/slider#slidertrack */ -const SliderTrackImpl = forwardRefWithAs(function SliderTrack( +const SliderTrackImpl = React.forwardRef(function SliderTrack( { as: Comp = "div", children, style = {}, ...props }, forwardedRef ) { const { disabled, orientation, trackRef } = useSliderContext(); - const ref = useForkedRef(trackRef, forwardedRef); + const ref = useComposedRefs(trackRef, forwardedRef); return ( (function SliderTrack( {children} ); -}); +}) as Polymorphic.ForwardRefComponent<"div", SliderTrackProps>; if (__DEV__) { SliderTrackImpl.displayName = "SliderTrack"; @@ -764,12 +768,15 @@ if (__DEV__) { }; } -const SliderTrack = memoWithAs(SliderTrackImpl); +const SliderTrack = React.memo(SliderTrackImpl) as Polymorphic.MemoComponent< + "div", + SliderTrackProps +>; /** * @see Docs https://reach.tech/slider#slidertrack-props */ -type SliderTrackProps = { +interface SliderTrackProps { /** * `SliderTrack` expects ``, at minimum, for the Slider to * function. All other Slider subcomponents should be passed as children @@ -778,7 +785,7 @@ type SliderTrackProps = { * @see Docs https://reach.tech/slider#slidertrack-children */ children: React.ReactNode; -}; +} if (__DEV__) { SliderTrack.displayName = "SliderTrack"; @@ -794,7 +801,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/slider#sliderrange */ -const SliderRangeImpl = forwardRefWithAs(function SliderRange( +const SliderRangeImpl = React.forwardRef(function SliderRange( { as: Comp = "div", children, style = {}, ...props }, forwardedRef ) { @@ -809,17 +816,20 @@ const SliderRangeImpl = forwardRefWithAs(function SliderRange( data-orientation={orientation} /> ); -}); +}) as Polymorphic.ForwardRefComponent<"div", SliderRangeProps>; if (__DEV__) { SliderRangeImpl.displayName = "SliderRange"; SliderRangeImpl.propTypes = {}; } -const SliderRange = memoWithAs(SliderRangeImpl); +const SliderRange = React.memo(SliderRangeImpl) as Polymorphic.MemoComponent< + "div", + SliderRangeProps +>; // TODO: Remove in 1.0 -const SliderTrackHighlightImpl = forwardRefWithAs( +const SliderTrackHighlightImpl = React.forwardRef( function SliderTrackHighlightImpl(props, ref) { if (__DEV__) { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -838,11 +848,13 @@ const SliderTrackHighlightImpl = forwardRefWithAs( /> ); } -); +) as Polymorphic.ForwardRefComponent<"div", SliderRangeProps>; + if (__DEV__) { SliderTrackHighlightImpl.displayName = "SliderTrackHighlight"; SliderTrackHighlightImpl.propTypes = SliderRangeImpl.propTypes; } + export interface SliderTrackHighlightProps extends SliderRangeProps {} /** @@ -852,7 +864,9 @@ export interface SliderTrackHighlightProps extends SliderRangeProps {} * * @alias SliderRange */ -export const SliderTrackHighlight = memoWithAs(SliderTrackHighlightImpl); +export const SliderTrackHighlight = React.memo( + SliderTrackHighlightImpl +) as Polymorphic.MemoComponent<"div", SliderRangeProps>; /** * `SliderRange` accepts any props that a HTML div component accepts. @@ -860,7 +874,7 @@ export const SliderTrackHighlight = memoWithAs(SliderTrackHighlightImpl); * * @see Docs https://reach.tech/slider#sliderrange-props */ -type SliderRangeProps = {}; +interface SliderRangeProps {} if (__DEV__) { SliderRange.displayName = "SliderRange"; @@ -875,110 +889,109 @@ if (__DEV__) { * * @see Docs https://reach.tech/slider#sliderhandle */ -const SliderHandleImpl = forwardRefWithAs( - function SliderHandle( - { - // min, - // max, - as: Comp = "div", - onBlur, - onFocus, - style = {}, - onKeyDown, - ...props - }, - forwardedRef - ) { - const { - ariaLabel, - ariaLabelledBy, - ariaValueText, - disabled, - handlePosition, - handleRef, - isVertical, - handleKeyDown, - orientation, - setHasFocus, - sliderMin, - sliderMax, - value, - } = useSliderContext(); - - const ref = useForkedRef(handleRef, forwardedRef); +const SliderHandleImpl = React.forwardRef(function SliderHandle( + { + // min, + // max, + as: Comp = "div", + onBlur, + onFocus, + style = {}, + onKeyDown, + ...props + }, + forwardedRef +) { + const { + ariaLabel, + ariaLabelledBy, + ariaValueText, + disabled, + handlePosition, + handleRef, + isVertical, + handleKeyDown, + orientation, + setHasFocus, + sliderMin, + sliderMax, + value, + } = useSliderContext(); - return ( - { - setHasFocus(false); - })} - onFocus={wrapEvent(onFocus, () => { - setHasFocus(true); - })} - onKeyDown={wrapEvent(onKeyDown, handleKeyDown)} - style={{ - position: "absolute", - ...(isVertical - ? { bottom: handlePosition } - : { left: handlePosition }), - ...style, - }} - /> - ); - } -); + const ref = useComposedRefs(handleRef, forwardedRef); + + return ( + { + setHasFocus(false); + })} + onFocus={composeEventHandlers(onFocus, () => { + setHasFocus(true); + })} + onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)} + style={{ + position: "absolute", + ...(isVertical ? { bottom: handlePosition } : { left: handlePosition }), + ...style, + }} + /> + ); +}) as Polymorphic.ForwardRefComponent<"div", SliderHandleProps>; if (__DEV__) { SliderHandleImpl.displayName = "SliderHandle"; SliderHandleImpl.propTypes = {}; } -const SliderHandle = memoWithAs(SliderHandleImpl); +const SliderHandle = React.memo(SliderHandleImpl) as Polymorphic.MemoComponent< + "div", + SliderHandleProps +>; /** * `SliderRange` accepts any props that a HTML div component accepts. * * @see Docs https://reach.tech/slider#sliderhandle-props */ -type SliderHandleProps = {}; +interface SliderHandleProps {} if (__DEV__) { SliderHandle.displayName = "SliderHandle"; @@ -994,55 +1007,49 @@ if (__DEV__) { * * @see Docs https://reach.tech/slider#slidermarker */ -const SliderMarkerImpl = forwardRefWithAs( - function SliderMarker( - { as: Comp = "div", children, style = {}, value, ...props }, - forwardedRef - ) { - const { - disabled, - isVertical, - orientation, - sliderMin, - sliderMax, - value: sliderValue, - } = useSliderContext(); - - let inRange = !(value < sliderMin || value > sliderMax); - let absoluteStartPosition = `${valueToPercent( - value, - sliderMin, - sliderMax - )}%`; - - let state = - value < sliderValue - ? "under-value" - : value === sliderValue - ? "at-value" - : "over-value"; - - return inRange ? ( - - ) : null; - } -); +const SliderMarkerImpl = React.forwardRef(function SliderMarker( + { as: Comp = "div", children, style = {}, value, ...props }, + forwardedRef +) { + const { + disabled, + isVertical, + orientation, + sliderMin, + sliderMax, + value: sliderValue, + } = useSliderContext(); + + let inRange = !(value < sliderMin || value > sliderMax); + let absoluteStartPosition = `${valueToPercent(value, sliderMin, sliderMax)}%`; + + let state = + value < sliderValue + ? "under-value" + : value === sliderValue + ? "at-value" + : "over-value"; + + return inRange ? ( + + ) : null; +}) as Polymorphic.ForwardRefComponent<"div", SliderMarkerProps>; if (__DEV__) { SliderMarkerImpl.displayName = "SliderMarker"; @@ -1051,19 +1058,22 @@ if (__DEV__) { }; } -const SliderMarker = memoWithAs(SliderMarkerImpl); +const SliderMarker = React.memo(SliderMarkerImpl) as Polymorphic.MemoComponent< + "div", + SliderMarkerProps +>; /** * @see Docs https://reach.tech/slider#slidermarker-props */ -type SliderMarkerProps = { +interface SliderMarkerProps { /** * The value to denote where the marker should appear along the track. * * @see Docs https://reach.tech/slider#slidermarker-value */ value: number; -}; +} if (__DEV__) { SliderMarker.displayName = "SliderMarker"; @@ -1175,7 +1185,7 @@ function useDimensions(ref: React.RefObject) { ? ref.current.getBoundingClientRect() : 0; */ - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { let ownerDocument = getOwnerDocument(ref.current)!; let ownerWindow = ownerDocument.defaultView || window; if (ref.current) { diff --git a/packages/tabs/__tests__/tabs.test.tsx b/packages/tabs/__tests__/tabs.test.tsx index 8b9760aca..b3bbc67ab 100644 --- a/packages/tabs/__tests__/tabs.test.tsx +++ b/packages/tabs/__tests__/tabs.test.tsx @@ -355,7 +355,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { const { container } = render(
diff --git a/packages/tabs/package.json b/packages/tabs/package.json index a1df2d92a..51a54cffa 100644 --- a/packages/tabs/package.json +++ b/packages/tabs/package.json @@ -1,24 +1,27 @@ { "name": "@reach/tabs", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible React Tabs Component", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/tabs" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/descendants": "0.13.1", - "@reach/utils": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/descendants": "0.15.0", + "@reach/utils": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/tabs/src/index.tsx b/packages/tabs/src/index.tsx index 1102e14cd..cbd106a6d 100644 --- a/packages/tabs/src/index.tsx +++ b/packages/tabs/src/index.tsx @@ -26,29 +26,25 @@ import { useDescendantsInit, useDescendants, } from "@reach/descendants"; +import { getComputedStyle } from "@reach/utils/computed-styles"; +import { cloneValidElement } from "@reach/utils/clone-valid-element"; +import { useControlledState } from "@reach/utils/use-controlled-state"; +import { useIsomorphicLayoutEffect as useLayoutEffect } from "@reach/utils/use-isomorphic-layout-effect"; +import { createNamedContext } from "@reach/utils/context"; +import { isBoolean, isNumber, isFunction } from "@reach/utils/type-check"; +import { makeId } from "@reach/utils/make-id"; +import { noop } from "@reach/utils/noop"; import { - boolOrBoolString, - cloneValidElement, - createNamedContext, - forwardRefWithAs, - getElementComputedStyle, - isNumber, - isFunction, - makeId, - memoWithAs, - noop, useCheckStyles, useControlledSwitchWarning, - useControlledState, - useEventCallback, - useForkedRef, - useIsomorphicLayoutEffect, - useUpdateEffect, - wrapEvent, -} from "@reach/utils"; +} from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { useUpdateEffect } from "@reach/utils/use-update-effect"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { useId } from "@reach/auto-id"; import type { Descendant } from "@reach/descendants"; +import type * as Polymorphic from "@reach/utils/polymorphic"; const TabsDescendantsContext = createDescendantContext( "TabsDescendantsContext" @@ -81,7 +77,7 @@ enum TabsOrientation { * * @see Docs https://reach.tech/tabs#tabs */ -const Tabs = forwardRefWithAs(function Tabs( +const Tabs = React.forwardRef(function Tabs( { as: Comp = "div", children, @@ -200,12 +196,12 @@ const Tabs = forwardRefWithAs(function Tabs( ); -}); +}) as Polymorphic.ForwardRefComponent<"div", TabsProps>; /** * @see Docs https://reach.tech/tabs#tabs-props */ -type TabsProps = { +interface TabsProps { /** * Tabs expects `` and `` as children. The order doesn't * matter, you can have tabs on the top or the bottom. In fact, you could have @@ -262,7 +258,7 @@ type TabsProps = { * @see Docs https://reach.tech/tabs#tabs-onchange */ onChange?: (index: number) => void; -}; +} if (__DEV__) { Tabs.displayName = "Tabs"; @@ -304,7 +300,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/tabs#tablist */ -const TabListImpl = forwardRefWithAs(function TabList( +const TabListImpl = React.forwardRef(function TabList( { children, as: Comp = "div", onKeyDown, ...props }, forwardedRef ) { @@ -321,37 +317,35 @@ const TabListImpl = forwardRefWithAs(function TabList( let tabs = useDescendants(TabsDescendantsContext); let ownRef = React.useRef(null); - let ref = useForkedRef(forwardedRef, ownRef); + let ref = useComposedRefs(forwardedRef, ownRef); React.useEffect(() => { if ( ownRef.current && ((ownRef.current.ownerDocument && ownRef.current.ownerDocument.dir === "rtl") || - getElementComputedStyle(ownRef.current, "direction") === "rtl") + getComputedStyle(ownRef.current, "direction") === "rtl") ) { isRTL.current = true; } }, [isRTL]); - let handleKeyDown = useEventCallback( - wrapEvent( - onKeyDown, - useDescendantKeyDown(TabsDescendantsContext, { - currentIndex: - keyboardActivation === TabsKeyboardActivation.Manual - ? focusedIndex - : selectedIndex, - orientation, - rotate: true, - callback: onSelectTabWithKeyboard, - filter: (tab) => !tab.disabled, - rtl: isRTL.current, - }) - ) + let handleKeyDown = composeEventHandlers( + onKeyDown, + useDescendantKeyDown(TabsDescendantsContext, { + currentIndex: + keyboardActivation === TabsKeyboardActivation.Manual + ? focusedIndex + : selectedIndex, + orientation, + rotate: true, + callback: onSelectTabWithKeyboard, + filter: (tab) => !tab.disabled, + rtl: isRTL.current, + }) ); - useIsomorphicLayoutEffect(() => { + useLayoutEffect(() => { // In the event an uncontrolled component's selected index is disabled, // (this should only happen if the first tab is disabled and no default // index is set), we need to override the selection to the next selectable @@ -388,7 +382,7 @@ const TabListImpl = forwardRefWithAs(function TabList( })} ); -}); +}) as Polymorphic.ForwardRefComponent<"div", TabListProps>; if (__DEV__) { TabListImpl.displayName = "TabList"; @@ -398,12 +392,15 @@ if (__DEV__) { }; } -const TabList = memoWithAs(TabListImpl); +const TabList = React.memo(TabListImpl) as Polymorphic.MemoComponent< + "div", + TabListProps +>; /** * @see Docs https://reach.tech/tabs#tablist-props */ -type TabListProps = { +interface TabListProps { /** * `TabList` expects multiple `Tab` elements as children. * @@ -412,7 +409,7 @@ type TabListProps = { * @see Docs https://reach.tech/tabs#tablist-children */ children?: React.ReactNode; -}; +} if (__DEV__) { TabList.displayName = "TabList"; @@ -427,7 +424,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/tabs#tab */ -const Tab = forwardRefWithAs(function Tab( +const Tab = React.forwardRef(function Tab( { // TODO: Remove in 1.0 // @ts-ignore @@ -452,7 +449,7 @@ const Tab = forwardRefWithAs(function Tab( setFocusedIndex, } = React.useContext(TabsContext); const ownRef = React.useRef(null); - const ref = useForkedRef(forwardedRef, ownRef); + const ref = useComposedRefs(forwardedRef, ownRef); const index = useDescendant( { element: ownRef.current!, @@ -479,18 +476,6 @@ const Tab = forwardRefWithAs(function Tab( } }, [isSelected, userInteractedRef]); - let handleFocus = useEventCallback( - wrapEvent(onFocus, () => { - setFocusedIndex(index); - }) - ); - - let handleBlur = useEventCallback( - wrapEvent(onBlur, () => { - setFocusedIndex(-1); - }) - ); - return ( (function Tab( disabled={disabled} id={makeId(tabsId, "tab", index)} onClick={onSelect} - onFocus={handleFocus} - onBlur={handleBlur} + onFocus={composeEventHandlers(onFocus, () => { + setFocusedIndex(index); + })} + onBlur={composeEventHandlers(onBlur, () => { + setFocusedIndex(-1); + })} type={htmlType} > {children} ); -}); +}) as Polymorphic.ForwardRefComponent<"button", TabProps>; /** * @see Docs https://reach.tech/tabs#tab-props */ -type TabProps = { +interface TabProps { /** * `Tab` can receive any type of children. * @@ -542,7 +531,7 @@ type TabProps = { */ disabled?: boolean; index?: number; -}; +} if (__DEV__) { Tab.displayName = "Tab"; @@ -561,25 +550,26 @@ if (__DEV__) { * * @see Docs https://reach.tech/tabs#tabpanels */ -const TabPanelsImpl = forwardRefWithAs( - function TabPanels({ children, as: Comp = "div", ...props }, forwardedRef) { - let ownRef = React.useRef(); - let ref = useForkedRef(ownRef, forwardedRef); - let [tabPanels, setTabPanels] = useDescendantsInit(); - - return ( - - - {children} - - - ); - } -); +const TabPanelsImpl = React.forwardRef(function TabPanels( + { children, as: Comp = "div", ...props }, + forwardedRef +) { + let ownRef = React.useRef(); + let ref = useComposedRefs(ownRef, forwardedRef); + let [tabPanels, setTabPanels] = useDescendantsInit(); + + return ( + + + {children} + + + ); +}) as Polymorphic.ForwardRefComponent<"div", TabPanelsProps>; if (__DEV__) { TabPanelsImpl.displayName = "TabPanels"; @@ -589,12 +579,15 @@ if (__DEV__) { }; } -const TabPanels = memoWithAs(TabPanelsImpl); +const TabPanels = React.memo(TabPanelsImpl) as Polymorphic.MemoComponent< + "div", + TabPanelsProps +>; /** * @see Docs https://reach.tech/tabs#tabpanels-props */ -type TabPanelsProps = TabListProps & {}; +interface TabPanelsProps extends TabListProps {} if (__DEV__) { TabPanels.displayName = "TabPanels"; @@ -609,7 +602,7 @@ if (__DEV__) { * * @see Docs https://reach.tech/tabs#tabpanel */ -const TabPanel = forwardRefWithAs(function TabPanel( +const TabPanel = React.forwardRef(function TabPanel( { children, "aria-label": ariaLabel, as: Comp = "div", ...props }, forwardedRef ) { @@ -644,7 +637,7 @@ const TabPanel = forwardRefWithAs(function TabPanel( readyToHide.current = true; }, []); - let ref = useForkedRef( + let ref = useComposedRefs( forwardedRef, ownRef, isSelected ? selectedPanelRef : null @@ -669,19 +662,19 @@ const TabPanel = forwardRefWithAs(function TabPanel( {children} ); -}); +}) as Polymorphic.ForwardRefComponent<"div", TabPanelProps>; /** * @see Docs https://reach.tech/tabs#tabpanel-props */ -type TabPanelProps = { +interface TabPanelProps { /** * `TabPanel` can receive any type of children. * * @see Docs https://reach.tech/tabs#tabpanel-children */ children?: React.ReactNode; -}; +} if (__DEV__) { TabPanel.displayName = "TabPanel"; @@ -719,13 +712,13 @@ type TabDescendant = Descendant & { type TabPanelDescendant = Descendant; -type TabsContextValue = { +interface TabsContextValue { focusedIndex: number; id: string; selectedIndex: number; -}; +} -type InternalTabsContextValue = { +interface InternalTabsContextValue { focusedIndex: number; id: string; isControlled: boolean; @@ -740,7 +733,7 @@ type InternalTabsContextValue = { setFocusedIndex: React.Dispatch>; setSelectedIndex: React.Dispatch>; userInteractedRef: React.MutableRefObject; -}; +} //////////////////////////////////////////////////////////////////////////////// // Exports @@ -763,3 +756,7 @@ export { TabsOrientation, useTabsContext, }; + +function boolOrBoolString(value: any): value is "true" | true { + return value === "true" ? true : isBoolean(value) ? value : false; +} diff --git a/packages/tooltip/__tests__/tooltip.test.tsx b/packages/tooltip/__tests__/tooltip.test.tsx index 6f4511252..1f9f4046c 100644 --- a/packages/tooltip/__tests__/tooltip.test.tsx +++ b/packages/tooltip/__tests__/tooltip.test.tsx @@ -34,7 +34,7 @@ describe("", () => { }); describe("a11y", () => { - it("should not have basic a11y issues", async () => { + it("Should not have ARIA violations", async () => { let { container, getByText } = render(
diff --git a/packages/tooltip/package.json b/packages/tooltip/package.json index 2ddf65df6..cf97e1819 100644 --- a/packages/tooltip/package.json +++ b/packages/tooltip/package.json @@ -1,26 +1,30 @@ { "name": "@reach/tooltip", - "version": "0.13.1", + "version": "0.15.0", "description": "Accessible tooltips", "author": "React Training ", "license": "MIT", + "sideEffects": [ + "*.css" + ], "repository": { "type": "git", "url": "git+https://github.com/reach/reach-ui.git", "directory": "packages/tooltip" }, "dependencies": { - "@reach/auto-id": "0.13.1", - "@reach/portal": "0.13.1", - "@reach/rect": "0.13.1", - "@reach/utils": "0.13.1", - "@reach/visually-hidden": "0.13.1", + "@reach/auto-id": "0.15.0", + "@reach/portal": "0.15.0", + "@reach/rect": "0.15.0", + "@reach/utils": "0.15.0", + "@reach/visually-hidden": "0.15.0", "prop-types": "^15.7.2", - "tslib": "^2.0.0" + "tiny-warning": "^1.0.3", + "tslib": "^2.1.0" }, "devDependencies": { - "react": "^17.0.1", - "react-dom": "^17.0.1" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "peerDependencies": { "react": "^16.8.0 || 17.x", diff --git a/packages/tooltip/src/index.tsx b/packages/tooltip/src/index.tsx index a437e0953..d2c285c76 100644 --- a/packages/tooltip/src/index.tsx +++ b/packages/tooltip/src/index.tsx @@ -22,7 +22,7 @@ * There are a few features that are important to understand. * * 1. Tooltips don't show up until the user has rested on one, we don't want - * tooltips popupping up as you move your mouse around the page. + * tooltips popping up as you move your mouse around the page. * * 2. Once any tooltip becomes visible, other tooltips nearby should skip * resting and display immediately. @@ -42,21 +42,20 @@ import * as React from "react"; import { useId } from "@reach/auto-id"; -import { - forwardRefWithAs, - getOwnerDocument, - getDocumentDimensions, - makeId, - useCheckStyles, - useForkedRef, - wrapEvent, - warning, -} from "@reach/utils"; +import { getDocumentDimensions } from "@reach/utils/get-document-dimensions"; +import { getOwnerDocument } from "@reach/utils/owner-document"; +import { makeId } from "@reach/utils/make-id"; +import { useCheckStyles } from "@reach/utils/dev-utils"; +import { useComposedRefs } from "@reach/utils/compose-refs"; +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; import { Portal } from "@reach/portal"; import { VisuallyHidden } from "@reach/visually-hidden"; import { useRect } from "@reach/rect"; +import warning from "tiny-warning"; import PropTypes from "prop-types"; +import type * as Polymorphic from "@reach/utils/polymorphic"; + const MOUSE_REST_TIMEOUT = 100; const LEAVE_TIMEOUT = 500; @@ -264,7 +263,7 @@ function useTooltip({ // hopefully they always pass a ref if they ever pass one let ownRef = React.useRef(null); - let ref = useForkedRef(forwardedRef, ownRef); + let ref = useComposedRefs(forwardedRef, ownRef); let triggerRect = useRect(ownRef, { observe: isVisible }); React.useEffect(() => { @@ -300,7 +299,7 @@ function useTooltip({ return theirHandler; } - return wrapEvent(theirHandler, ourHandler); + return composeEventHandlers(theirHandler, ourHandler); } function wrapPointerEventHandler( @@ -363,19 +362,19 @@ function useTooltip({ "data-state": isVisible ? "tooltip-visible" : "tooltip-hidden", "data-reach-tooltip-trigger": "", ref, - onPointerEnter: wrapEvent( + onPointerEnter: composeEventHandlers( onPointerEnter, wrapPointerEventHandler(handleMouseEnter) ), - onPointerMove: wrapEvent( + onPointerMove: composeEventHandlers( onPointerMove, wrapPointerEventHandler(handleMouseMove) ), - onPointerLeave: wrapEvent( + onPointerLeave: composeEventHandlers( onPointerLeave, wrapPointerEventHandler(handleMouseLeave) ), - onPointerDown: wrapEvent( + onPointerDown: composeEventHandlers( onPointerDown, wrapPointerEventHandler(handleMouseDown) ), @@ -383,9 +382,9 @@ function useTooltip({ onMouseMove: wrapMouseEvent(onMouseMove, handleMouseMove), onMouseLeave: wrapMouseEvent(onMouseLeave, handleMouseLeave), onMouseDown: wrapMouseEvent(onMouseDown, handleMouseDown), - onFocus: wrapEvent(onFocus, handleFocus), - onBlur: wrapEvent(onBlur, handleBlur), - onKeyDown: wrapEvent(onKeyDown, handleKeyDown), + onFocus: composeEventHandlers(onFocus, handleFocus), + onBlur: composeEventHandlers(onBlur, handleBlur), + onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown), }; let tooltip: TooltipParams = { @@ -404,7 +403,7 @@ function useTooltip({ * * @see Docs https://reach.tech/tooltip#tooltip */ -const Tooltip = forwardRefWithAs(function ( +const Tooltip = React.forwardRef(function ( { children, label, @@ -454,12 +453,13 @@ const Tooltip = forwardRefWithAs(function ( /> ); -}); +}) as Polymorphic.ForwardRefComponent<"div", TooltipProps>; -type TooltipProps = { +interface TooltipProps + extends Omit { children: React.ReactNode; DEBUG_STYLE?: boolean; -} & Omit; +} if (__DEV__) { Tooltip.displayName = "Tooltip"; @@ -477,37 +477,35 @@ if (__DEV__) { * * @see Docs https://reach.tech/tooltip#tooltippopup */ -const TooltipPopup = forwardRefWithAs( - function TooltipPopup( - { - // could use children but we want to encourage simple strings - label, - // TODO: Remove `ariaLabel` prop in 1.0 and just use `aria-label` - ariaLabel: DEPRECATED_ariaLabel, - isVisible, - id, - ...props - }, - forwardRef - ) { - return isVisible ? ( - - - - ) : null; - } -); +const TooltipPopup = React.forwardRef(function TooltipPopup( + { + // could use children but we want to encourage simple strings + label, + // TODO: Remove `ariaLabel` prop in 1.0 and just use `aria-label` + ariaLabel: DEPRECATED_ariaLabel, + isVisible, + id, + ...props + }, + forwardRef +) { + return isVisible ? ( + + + + ) : null; +}) as Polymorphic.ForwardRefComponent<"div", TooltipPopupProps>; -type TooltipPopupProps = { +interface TooltipPopupProps extends TooltipContentProps { children?: React.ReactNode; -} & TooltipContentProps; +} if (__DEV__) { TooltipPopup.displayName = "TooltipPopup"; @@ -525,67 +523,65 @@ if (__DEV__) { * * @see Docs https://reach.tech/tooltip#tooltipcontent */ -const TooltipContent = forwardRefWithAs( - function TooltipContent( - { - // TODO: Remove `ariaLabel` prop in 1.0 and just use `aria-label` - ariaLabel, - "aria-label": realAriaLabel, - as: Comp = "div", - id, - isVisible, - label, - position = positionTooltip, - style, - triggerRect, - ...props - }, - forwardedRef - ) { - // The element that serves as the tooltip container has role tooltip. - // https://www.w3.org/TR/wai-aria-practices-1.2/#tooltip When an app passes - // an `aria-label`, we actually want to implement `role="tooltip"` on a - // visually hidden element inside of the trigger. In these cases we want the - // screen reader user to know both the content in the tooltip, but also the - // content in the badge. For screen reader users, the only content announced - // to them is whatever is in the tooltip. - let hasAriaLabel = (realAriaLabel || ariaLabel) != null; - - let ownRef = React.useRef(null); - let ref = useForkedRef(forwardedRef, ownRef); - let tooltipRect = useRect(ownRef, { observe: isVisible }); - return ( - - - {label} - - {hasAriaLabel && ( - - {realAriaLabel || ariaLabel} - - )} - - ); - } -); +const TooltipContent = React.forwardRef(function TooltipContent( + { + // TODO: Remove `ariaLabel` prop in 1.0 and just use `aria-label` + ariaLabel, + "aria-label": realAriaLabel, + as: Comp = "div", + id, + isVisible, + label, + position = positionTooltip, + style, + triggerRect, + ...props + }, + forwardedRef +) { + // The element that serves as the tooltip container has role tooltip. + // https://www.w3.org/TR/wai-aria-practices-1.2/#tooltip When an app passes + // an `aria-label`, we actually want to implement `role="tooltip"` on a + // visually hidden element inside of the trigger. In these cases we want the + // screen reader user to know both the content in the tooltip, but also the + // content in the badge. For screen reader users, the only content announced + // to them is whatever is in the tooltip. + let hasAriaLabel = (realAriaLabel || ariaLabel) != null; + + let ownRef = React.useRef(null); + let ref = useComposedRefs(forwardedRef, ownRef); + let tooltipRect = useRect(ownRef, { observe: isVisible }); + return ( + + + {label} + + {hasAriaLabel && ( + + {realAriaLabel || ariaLabel} + + )} + + ); +}) as Polymorphic.ForwardRefComponent<"div", TooltipContentProps>; -type TooltipContentProps = { +interface TooltipContentProps { ariaLabel?: string; position?: Position; label: React.ReactNode; isVisible?: boolean; triggerRect: DOMRect | null; -}; +} if (__DEV__) { TooltipContent.displayName = "TooltipContent"; @@ -699,7 +695,7 @@ function useDisabledTriggerOnSafari({ return () => { ownerDocument.removeEventListener("mousemove", handleMouseMove); }; - }, [disabled, isVisible]); + }, [disabled, isVisible, ref]); } //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/utils/__tests__/can-use-dom.test.ts b/packages/utils/__tests__/can-use-dom.test.ts new file mode 100644 index 000000000..6f1073af3 --- /dev/null +++ b/packages/utils/__tests__/can-use-dom.test.ts @@ -0,0 +1,25 @@ +import { canUseDOM } from "@reach/utils/can-use-dom"; + +describe("@reach/utils/can-use-dom", () => { + describe("canUseDOM", () => { + let windowSpy: jest.SpyInstance; + + beforeEach(() => { + windowSpy = jest.spyOn(window, "window", "get"); + }); + + afterEach(() => { + windowSpy.mockRestore(); + }); + + it("returns true with DOM globals", () => { + // globals are defined in JS DOM by default + expect(canUseDOM()).toBe(true); + }); + + it("returns false without DOM globals", () => { + windowSpy.mockImplementation(() => undefined); + expect(canUseDOM()).toBe(false); + }); + }); +}); diff --git a/packages/utils/__tests__/compose-event-handlers.test.ts b/packages/utils/__tests__/compose-event-handlers.test.ts new file mode 100644 index 000000000..a76306a0d --- /dev/null +++ b/packages/utils/__tests__/compose-event-handlers.test.ts @@ -0,0 +1,74 @@ +import { composeEventHandlers } from "@reach/utils/compose-event-handlers"; + +describe("@reach/utils/compose-event-handlers", () => { + describe("composeEventHandlers", () => { + let event = new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + }); + let elem = document.createElement("button"); + let listeners = { + internal: jest.fn(), + external: jest.fn(), + }; + + function external() { + listeners.external(); + } + + function internal() { + listeners.internal(); + } + + function externalPreventDefaulted(event: MouseEvent) { + event.preventDefault(); + listeners.external(); + } + + let composed = composeEventHandlers(external, internal); + let composedWithPreventDefault = composeEventHandlers( + externalPreventDefaulted, + internal + ); + + beforeEach(() => { + listeners.internal = jest.fn(); + listeners.external = jest.fn(); + }); + + afterAll(() => { + elem.parentElement?.removeChild(elem); + }); + + it("calls external handler", () => { + elem.addEventListener("click", composed); + elem.dispatchEvent(event); + expect(listeners.external).toHaveBeenCalledTimes(1); + elem.removeEventListener("click", composed); + }); + + it("calls internal handler", () => { + elem.addEventListener("click", composed); + elem.dispatchEvent(event); + expect(listeners.internal).toHaveBeenCalledTimes(1); + elem.removeEventListener("click", composed); + }); + + describe("when external handler calls event.preventDefault", () => { + it("calls external handler", () => { + elem.addEventListener("click", composedWithPreventDefault); + elem.dispatchEvent(event); + expect(listeners.external).toHaveBeenCalledTimes(1); + elem.removeEventListener("click", composedWithPreventDefault); + }); + + it("does not call internal handler", () => { + elem.addEventListener("click", composedWithPreventDefault); + elem.dispatchEvent(event); + expect(listeners.internal).not.toHaveBeenCalled(); + elem.removeEventListener("click", composedWithPreventDefault); + }); + }); + }); +}); diff --git a/packages/utils/__tests__/dev-utils.test.ts b/packages/utils/__tests__/dev-utils.test.ts new file mode 100644 index 000000000..e84824569 --- /dev/null +++ b/packages/utils/__tests__/dev-utils.test.ts @@ -0,0 +1,186 @@ +import { checkStyles, useCheckStyles } from "@reach/utils/dev-utils"; +import { renderHook } from "@testing-library/react-hooks"; + +describe("@reach/utils/dev-utils", () => { + describe("checkStyles", () => { + let originalWarn = console.warn; + let consoleOutput: string[] = []; + let mockedWarn = (output: any) => consoleOutput.push(output); + + beforeEach(() => { + console.warn = mockedWarn; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + describe("when process.env.NODE_ENV === 'test'", () => { + afterEach(() => { + consoleOutput = []; + }); + + it("should not issue warnings", () => { + checkStyles("accordion"); + expect(consoleOutput).toHaveLength(0); + }); + }); + + describe("when process.env.NODE_ENV === 'production'", () => { + const OLD_ENV = process.env; + + beforeAll(() => { + jest.resetModules(); // clears jest cache + process.env = { ...OLD_ENV, NODE_ENV: "production" }; + }); + + afterEach(() => { + consoleOutput = []; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("should not issue warnings", () => { + checkStyles("listbox"); + expect(consoleOutput).toHaveLength(0); + }); + }); + + describe("when process.env.NODE_ENV === 'development'", () => { + const OLD_ENV = process.env; + + beforeAll(() => { + jest.resetModules(); // clears jest cache + process.env = { ...OLD_ENV, NODE_ENV: "development" }; + }); + + afterEach(() => { + consoleOutput = []; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("should issue warnings", () => { + checkStyles("slider"); + expect(consoleOutput).toHaveLength(1); + }); + + it("should issue additional warnings for other packages", () => { + checkStyles("tabs"); + expect(consoleOutput).toHaveLength(1); + }); + + it("should not issue warnings for the same package more than once", () => { + checkStyles("tabs"); + expect(consoleOutput).toHaveLength(0); + }); + + it("should not issue warnings when --reach-{package} is set", () => { + document.body.style.setProperty("--reach-combobox", "1"); + checkStyles("combobox"); + expect(consoleOutput).toHaveLength(0); + + // clear custom property + document.body.style.setProperty("--reach-combobox", null); + }); + }); + }); + + describe("useCheckStyles", () => { + let originalWarn = console.warn; + let consoleOutput: string[] = []; + let mockedWarn = (output: any) => consoleOutput.push(output); + + beforeEach(() => { + console.warn = mockedWarn; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + describe("when process.env.NODE_ENV === 'test'", () => { + afterEach(() => { + consoleOutput = []; + }); + + it("should not issue warnings", () => { + renderHook(() => useCheckStyles("tooltip")); + expect(consoleOutput).toHaveLength(0); + }); + }); + + describe("when process.env.NODE_ENV === 'production'", () => { + const OLD_ENV = process.env; + + beforeAll(() => { + jest.resetModules(); // clears jest cache + process.env = { ...OLD_ENV, NODE_ENV: "production" }; + }); + + afterEach(() => { + consoleOutput = []; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("should not issue warnings", () => { + renderHook(() => useCheckStyles("menu-button")); + expect(consoleOutput).toHaveLength(0); + }); + }); + + describe("when process.env.NODE_ENV === 'development'", () => { + const OLD_ENV = process.env; + + beforeAll(() => { + jest.resetModules(); // clears jest cache + process.env = { ...OLD_ENV, NODE_ENV: "development" }; + }); + + afterEach(() => { + consoleOutput = []; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("should issue warnings", () => { + renderHook(() => useCheckStyles("checkbox")); + expect(consoleOutput).toHaveLength(1); + }); + + it("should issue additional warnings for other packages", () => { + renderHook(() => useCheckStyles("dialog")); + expect(consoleOutput).toHaveLength(1); + }); + + it("should not issue warnings for the same package more than once", () => { + renderHook(() => useCheckStyles("dialog")); + expect(consoleOutput).toHaveLength(0); + }); + + it("should not issue warnings when --reach-{package} is set", () => { + document.body.style.setProperty("--reach-disclosure", "1"); + renderHook(() => useCheckStyles("disclosure")); + expect(consoleOutput).toHaveLength(0); + + // clear custom property + document.body.style.setProperty("--reach-disclosure", null); + }); + }); + }); + + // TODO: Help wanted :) + // describe("useStateLogger", () => {}); + + // TODO: Help wanted :) + // describe("useControlledSwitchWarning", () => {}); +}); diff --git a/packages/utils/__tests__/polymorphic.test.tsx b/packages/utils/__tests__/polymorphic.test.tsx new file mode 100644 index 000000000..e9488962f --- /dev/null +++ b/packages/utils/__tests__/polymorphic.test.tsx @@ -0,0 +1,184 @@ +import * as React from "react"; +import { render } from "@testing-library/react"; +import type * as Polymorphic from "@reach/utils/polymorphic"; +import type { RenderResult } from "@testing-library/react"; + +interface ButtonProps { + isDisabled?: boolean; + another?: number; +} + +const Button = React.forwardRef((props, forwardedRef) => { + const { as: Comp = "button", isDisabled, ...buttonProps } = props; + return ; +}) as Polymorphic.ForwardRefComponent<"button", ButtonProps>; + +const ExtendedButtonUsingReactUtils = React.forwardRef< + React.ElementRef, + React.ComponentProps +>((props, forwardedRef) => { + return