diff --git a/.circleci/canary-version.js b/.circleci/canary-version.js deleted file mode 100755 index a84395224..000000000 --- a/.circleci/canary-version.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const root = require('../package.json'); -const styles = require('../packages/styles/package.json'); -const react = require('../packages/react/package.json'); - -if (!process.env.CIRCLE_SHA1) { - throw new Error('No CIRCLE SHA available.'); -} - -const sha = process.env.CIRCLE_SHA1.substring(0, 8); -const version = `${root.version}-canary.${sha}`; - -console.log('Updating versions to %s', version); - -root.version = version; -fs.writeFileSync('./package.json', JSON.stringify(root, null, 2)); - -styles.version = version; -fs.writeFileSync( - './packages/styles/package.json', - JSON.stringify(styles, null, 2) -); - -react.version = version; -fs.writeFileSync( - './packages/react/package.json', - JSON.stringify(react, null, 2) -); diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 2c9557ff4..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,165 +0,0 @@ -version: 2 - -orbs: - browser-tools: circleci/browser-tools@1.2.4 - -defaults: &defaults - docker: - - image: cimg/node:16.14-browsers - steps: - - browser-tools/install-chrome - - run: - command: | - google-chrome --version - name: Google Chrome Version - working_directory: ~/cauldron - -set_npm_auth: &set_npm_auth - run: npm config set "//registry.npmjs.org/:_authToken" $NPM_AUTH - -jobs: - react: - <<: *defaults - steps: - - checkout - - restore_cache: - keys: - - v2-react-yarn-{{ checksum "packages/react/yarn.lock" }} - - v2-react-yarn- - - run: NODE_ENV=production yarn --cwd=packages/react build - - run: yarn --cwd=packages/react test - styles: - <<: *defaults - steps: - - checkout - - restore_cache: - keys: - - v2-styles-yarn-{{ checksum "packages/styles/yarn.lock" }} - - v2-styles-yarn- - - run: NODE_ENV=production yarn --cwd=packages/styles build - dependencies: - <<: *defaults - steps: - - checkout - - restore_cache: - keys: - - v2-yarn-cache-{{ checksum "yarn.lock" }} - - v2-yarn-cache- - - restore_cache: - keys: - - v2-react-yarn-{{ checksum "packages/react/yarn.lock" }} - - v2-react-yarn- - - restore_cache: - keys: - - v2-styles-yarn-{{ checksum "packages/styles/yarn.lock" }} - - v2-styles-yarn- - - run: yarn - - save_cache: - key: v2-yarn-cache-{{ checksum "yarn.lock" }} - paths: - - node_modules - - save_cache: - paths: - - packages/react/node_modules - key: v2-react-yarn-{{ checksum "packages/react/yarn.lock" }} - - save_cache: - paths: - - packages/styles/node_modules - key: v2-styles-yarn-{{ checksum "packages/styles/yarn.lock" }} - checks: - <<: *defaults - steps: - - checkout - - restore_cache: - key: v2-yarn-cache-{{ checksum "yarn.lock" }} - - run: yarn lint - a11y: - <<: *defaults - steps: - - checkout - - restore_cache: - key: v2-yarn-cache-{{ checksum "yarn.lock" }} - - restore_cache: - key: v2-react-yarn-{{ checksum "packages/react/yarn.lock" }} - - restore_cache: - key: v2-styles-yarn-{{ checksum "packages/styles/yarn.lock" }} - - run: yarn build:styles - - run: yarn build:react - - run: yarn build:docs - - run: yarn test:a11y - canary_release: - <<: *defaults - steps: - - checkout - - <<: *set_npm_auth - - restore_cache: - keys: - - v2-yarn-cache-{{ checksum "yarn.lock" }} - - v2-yarn-cache- - - restore_cache: - keys: - - v2-react-yarn-{{ checksum "packages/react/yarn.lock" }} - - v2-react-yarn- - - restore_cache: - keys: - - v2-styles-yarn-{{ checksum "packages/styles/yarn.lock" }} - - v2-styles-yarn- - - run: npm whoami - - run: .circleci/canary-version.js - - run: cd packages/styles && npm publish --tag=next - - run: cd packages/react && npm publish --tag=next - release: - <<: *defaults - steps: - - checkout - - <<: *set_npm_auth - - restore_cache: - keys: - - v2-yarn-cache-{{ checksum "yarn.lock" }} - - v2-yarn-cache- - - restore_cache: - keys: - - v2-react-yarn-{{ checksum "packages/react/yarn.lock" }} - - v2-react-yarn- - - restore_cache: - keys: - - v2-styles-yarn-{{ checksum "packages/styles/yarn.lock" }} - - v2-styles-yarn- - - run: npm whoami - - run: cd packages/styles && npm publish - - run: cd packages/react && npm publish - -workflows: - version: 2 - build: - jobs: - - dependencies - - checks: - requires: - - dependencies - - react: - requires: - - dependencies - - checks - - styles: - requires: - - dependencies - - checks - - a11y: - requires: - - react - - styles - - canary_release: - requires: - - a11y - filters: - branches: - only: - - develop - - release: - requires: - - a11y - filters: - branches: - only: - - master diff --git a/.github/actions/dependencies/action.yml b/.github/actions/dependencies/action.yml new file mode 100644 index 000000000..1418824a4 --- /dev/null +++ b/.github/actions/dependencies/action.yml @@ -0,0 +1,40 @@ +name: 'Dependencies' +description: 'Installs and builds dependencies for Cauldron' +inputs: + root: + type: boolean + default: true + packages-react: + type: boolean + default: false + packages-styles: + type: boolean + default: false + +runs: + using: 'composite' + steps: + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: yarn + cache-dependency-path: '**/yarn.lock' + registry-url: 'https://registry.npmjs.org' + - name: Install root dependencies + run: yarn install --frozen-lockfile + shell: bash + # Note: Checking for both boolean and string true values due to referenced bug: + # https://github.com/actions/runner/issues/2238 + if: ${{ inputs.root == true || inputs.root == 'true' }} + - name: Install packages/react dependencies + run: yarn install --cwd packages/react + shell: bash + # Note: Checking for both boolean and string true values due to referenced bug: + # https://github.com/actions/runner/issues/2238 + if: ${{ inputs.packages-react == true || inputs.packages-react == 'true' }} + - name: Install packages/styles dependencies + run: yarn install --cwd packages/styles + shell: bash + # Note: Checking for both boolean and string true values due to referenced bug: + # https://github.com/actions/runner/issues/2238 + if: ${{ inputs.packages-styles == true || inputs.packages-styles == 'true' }} \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release-pr.yml similarity index 100% rename from .github/workflows/create-release.yml rename to .github/workflows/create-release-pr.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..01deab84e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,15 @@ +name: Lint + +on: + pull_request: + branches: + - develop + +jobs: + + eslint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dependencies + - run: yarn lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..467174819 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + branches: + - develop + - master + +jobs: + + tests: + uses: ./.github/workflows/tests.yml + + publish: + if: github.ref == 'refs/heads/master' + needs: [tests] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: 'master' + - uses: ./.github/actions/dependencies + with: + root: false + packages-react: true + packages-styles: true + - name: Build packages + run: | + NODE_ENV=production yarn --cwd packages/react build + NODE_ENV=production yarn --cwd packages/styles build + - name: Publish @deque/cauldron-styles + run: | + cd packages/styles + npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + - name: Publish @deque/cauldron-react + run: | + cd packages/react + npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + + canary: + if: github.ref == 'refs/heads/develop' + needs: [tests] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: 'develop' + - uses: ./.github/actions/dependencies + with: + root: false + packages-react: true + packages-styles: true + - name: Build packages + run: | + NODE_ENV=production yarn --cwd packages/react build + NODE_ENV=production yarn --cwd packages/styles build + - name: Publish @deque/cauldron-styles + run: | + cd packages/styles + PACKAGE_VERSION="$(npm pkg get version | tr -d \")" + npm version "$PACKAGE_VERSION-canary.${GITHUB_SHA:0:8}" --allow-same-version --no-git-tag-version + npm publish --tag=next + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + - name: Publish @deque/cauldron-react + run: | + cd packages/react + PACKAGE_VERSION="$(npm pkg get version | tr -d \")" + npm version "$PACKAGE_VERSION-canary.${GITHUB_SHA:0:8}" --allow-same-version --no-git-tag-version + npm publish --tag=next + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yml similarity index 88% rename from .github/workflows/tag-release.yaml rename to .github/workflows/tag-release.yml index 2e9b9eb8f..fc9d0eb79 100644 --- a/.github/workflows/tag-release.yaml +++ b/.github/workflows/tag-release.yml @@ -12,9 +12,6 @@ jobs: - uses: actions/checkout@v4 with: ref: 'master' - - uses: actions/setup-node@v4 - with: - node-version: 16 - run: | git config user.name attest-team-ci git config user.email aciattestteamci@deque.com diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..c1d174c7e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + pull_request: + branches: + - develop + workflow_call: + +jobs: + + react: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dependencies + with: + root: false + packages-react: true + packages-styles: true + - name: Build packages + run: | + NODE_ENV=production yarn --cwd packages/react build + - run: yarn test + + a11y: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dependencies + with: + packages-react: true + packages-styles: true + - name: Build packages + run: | + NODE_ENV=production yarn --cwd packages/react build + NODE_ENV=production yarn --cwd packages/styles build + - run: yarn build:docs + - run: yarn test:a11y diff --git a/CHANGELOG.md b/CHANGELOG.md index c1982303b..59a0607c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [5.13.0](https://github.com/dequelabs/cauldron/compare/v5.12.0...v5.13.0) (2023-12-01) + + +### Features + +* **Combobox:** allow combobox to take an input ref ([#1297](https://github.com/dequelabs/cauldron/issues/1297)) ([6dd5a34](https://github.com/dequelabs/cauldron/commit/6dd5a3404e743f0ec246056c2fe81666e064ce1c)) +* **Dialog:** ensure that dialogs are labelled by their heading ([#1260](https://github.com/dequelabs/cauldron/issues/1260)) ([e08b517](https://github.com/dequelabs/cauldron/commit/e08b5178d26dddcac433c170dd411820194121b0)) + ## [5.12.0](https://github.com/dequelabs/cauldron/compare/v5.11.0...v5.12.0) (2023-11-10) diff --git a/docs/pages/components/Combobox.mdx b/docs/pages/components/Combobox.mdx index 900506601..180ee8f41 100644 --- a/docs/pages/components/Combobox.mdx +++ b/docs/pages/components/Combobox.mdx @@ -344,7 +344,12 @@ When autocomplete is set to "automatic" the listbox will provide a filtered list name: 'portal', type: ['React.Ref', 'HTMLElement'], description: 'Alternative placement of combobox listbox. When not set, the listbox will be absolutely positioned inline.' - } + }, + { + name: 'inputRef', + type: 'React.Ref', + description: 'Ref for the input.' + }, ]} /> ### ComboboxOption diff --git a/package.json b/package.json index 6cf43b797..f9317f0b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cauldron", "private": true, - "version": "5.12.0", + "version": "5.13.0", "license": "MPL-2.0", "scripts": { "clean": "rimraf dist docs/dist", @@ -19,7 +19,7 @@ "predev": "yarn clean", "test": "yarn --cwd=packages/react test", "test:a11y": "ts-node e2e/accessibility.ts", - "postinstall": "yarn --cwd=packages/styles && yarn --cwd=packages/react", + "postinstall": "if [ -z \"$CI\" ]; then yarn --cwd=packages/styles && yarn --cwd=packages/react; fi", "release": "./scripts/release.sh" }, "prettier": { diff --git a/packages/react/__tests__/src/components/Combobox/index.js b/packages/react/__tests__/src/components/Combobox/index.js index c8f1184f8..0ea3c6782 100644 --- a/packages/react/__tests__/src/components/Combobox/index.js +++ b/packages/react/__tests__/src/components/Combobox/index.js @@ -206,18 +206,27 @@ test('should render required combobox', () => { }); test('should render combobox with error', () => { + const errorId = 'combo-error'; const wrapper = mount( - + Apple Banana Cantaloupe ); - expect(wrapper.find('.Error').exists()).toBeTruthy(); - expect(wrapper.find('.Error').text()).toEqual( + expect(wrapper.find(`#${errorId}`).exists()).toBeTruthy(); + expect(wrapper.find(`#${errorId}`).text()).toEqual( 'You forgot to choose a value.' ); + expect( + wrapper.find('input').getDOMNode().getAttribute('aria-describedby') + ).toBe(`other-id ${errorId}`); }); test('should open combobox listbox on click', () => { @@ -260,6 +269,18 @@ test('should focus combobox input on click', () => { expect(onFocus.calledOnce).toBeTruthy(); }); +test('should allow an input ref to be passed to the combobox', () => { + const inputRef = React.createRef(); + const wrapper = mount( + + Apple + + ); + + expect(inputRef.current).toBeTruthy(); + expect(inputRef.current).toEqual(wrapper.find('input').getDOMNode()); +}); + test('should open combobox listbox on focus', () => { const wrapper = mount( diff --git a/packages/react/__tests__/src/components/Dialog/index.js b/packages/react/__tests__/src/components/Dialog/index.js index 472e2da21..c94dd227b 100644 --- a/packages/react/__tests__/src/components/Dialog/index.js +++ b/packages/react/__tests__/src/components/Dialog/index.js @@ -45,6 +45,19 @@ test('focuses heading when "show" prop is updated from falsey to truthy', (done) }); }); +test('associates dialog with heading', () => { + const dialog = mount( + + hello + + ); + expect(dialog.find('[role="dialog"]').prop('aria-labelledby')).toBeTruthy(); + expect(dialog.find('h2').prop('id')).toBeTruthy(); + expect(dialog.find('[role="dialog"]').prop('aria-labelledby')).toEqual( + dialog.find('h2').prop('id') + ); +}); + test('calls onClose when clicked outside', () => { const onClose = jest.fn(); const dialog = mount( diff --git a/packages/react/__tests__/src/components/Popover/index.js b/packages/react/__tests__/src/components/Popover/index.js index 746f7a01b..80cece54a 100644 --- a/packages/react/__tests__/src/components/Popover/index.js +++ b/packages/react/__tests__/src/components/Popover/index.js @@ -24,9 +24,9 @@ afterEach(() => { mountNode = null; }); -const update = async wrapper => { +const update = async (wrapper) => { await act(async () => { - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); wrapper.update(); }); }; @@ -78,6 +78,7 @@ const WrapperPrompt = ({ buttonProps = {}, tooltipProps = {} }) => { target={ref} show onClose={onClose} + infoText="popover" {...tooltipProps} /> @@ -96,10 +97,7 @@ test('should auto-generate id', async () => { const id = wrapper.find('.Popover').props().id; expect(id).toBeTruthy(); expect(id).toEqual( - wrapper - .find('button') - .getDOMNode() - .getAttribute('aria-controls') + wrapper.find('button').getDOMNode().getAttribute('aria-controls') ); }); @@ -107,19 +105,13 @@ test('should attach attribute aria-expanded correctly based on shown state', asy const wrapper = mount(); await update(wrapper); expect( - wrapper - .find('button') - .getDOMNode() - .getAttribute('aria-expanded') + wrapper.find('button').getDOMNode().getAttribute('aria-expanded') ).toBeTruthy(); const shownStateFalsy = mount(); expect( - shownStateFalsy - .find('button') - .getDOMNode() - .getAttribute('aria-expanded') + shownStateFalsy.find('button').getDOMNode().getAttribute('aria-expanded') ).toBeFalsy(); }); @@ -138,10 +130,7 @@ test('should not overwrite user provided id and aria-describedby', async () => { await update(wrapper); expect(wrapper.find('.Popover').props().id).toEqual('popoverid'); expect( - wrapper - .find('button') - .getDOMNode() - .getAttribute('aria-describedby') + wrapper.find('button').getDOMNode().getAttribute('aria-describedby') ).toEqual('foo popoverid'); }); @@ -267,7 +256,9 @@ test('variant="prompt" should return no axe violations', async () => { }); test('should return no axe violations', async () => { - const wrapper = mount(); + const wrapper = mount( + + ); await update(wrapper); expect(await axe(wrapper.html())).toHaveNoViolations(); }); @@ -341,9 +332,6 @@ test('aria-labelledby is set correctly for prompt variant', async () => { const id = wrapper.find('.Popover').props().id; expect(`${id}-label`).toEqual( - wrapper - .find('.Popover') - .getDOMNode() - .getAttribute('aria-labelledby') + wrapper.find('.Popover').getDOMNode().getAttribute('aria-labelledby') ); }); diff --git a/packages/react/package.json b/packages/react/package.json index c774f37f7..ee3d35064 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@deque/cauldron-react", - "version": "5.12.0", + "version": "5.13.0", "license": "MPL-2.0", "description": "Fully accessible react components library for Deque Cauldron", "homepage": "https://cauldron.dequelabs.com/", @@ -57,7 +57,7 @@ "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "jest": "^24.7.1", - "jest-axe": "^3.4.0", + "jest-axe": "^8.0.0", "nyc": "^15.0.1", "postcss-cli": "^7.1.1", "postcss-import": "^12.0.1", diff --git a/packages/react/src/components/Combobox/Combobox.tsx b/packages/react/src/components/Combobox/Combobox.tsx index 5a84067bf..d4e780bfd 100644 --- a/packages/react/src/components/Combobox/Combobox.tsx +++ b/packages/react/src/components/Combobox/Combobox.tsx @@ -16,6 +16,7 @@ import type { ComboboxOptionState } from './ComboboxContext'; import type { ComboboxValue } from './ComboboxOption'; import type { ListboxOption } from '../Listbox/ListboxContext'; import useSharedRef from '../../utils/useSharedRef'; +import tokenList from '../../utils/token-list'; // Event Keys const [Enter, Escape, Home, End] = ['Enter', 'Escape', 'Home', 'End']; @@ -51,6 +52,7 @@ interface ComboboxProps onActiveChange?: (option: ListboxOption) => void; renderNoResults?: (() => JSX.Element) | React.ReactElement; portal?: React.RefObject | HTMLElement; + inputRef?: React.Ref; } const defaultAutoCompleteMatches = (inputValue: string, value: string) => { @@ -95,6 +97,8 @@ const Combobox = forwardRef( name, renderNoResults, portal, + inputRef: propInputRef = null, + 'aria-describedby': ariaDescribedby, ...props }, ref @@ -110,7 +114,7 @@ const Combobox = forwardRef( useState(null); const [id] = propId ? [propId] : useId(1, 'combobox'); const comboboxRef = useSharedRef(ref); - const inputRef = useRef(null); + const inputRef = useSharedRef(propInputRef); const listboxRef = useRef(null); const isControlled = typeof propValue !== 'undefined'; const isRequired = !!props.required; @@ -381,6 +385,14 @@ const Combobox = forwardRef( ); + const errorId = `${id}-error`; + const inputProps = { + ...props, + 'aria-describedby': error + ? tokenList(errorId, ariaDescribedby) + : ariaDescribedby + }; + return (
( aria-activedescendant={ open && activeDescendant ? activeDescendant.element.id : undefined } - {...props} + {...inputProps} onChange={handleChange} onKeyDown={handleKeyDown} onFocus={handleFocus} @@ -452,7 +464,7 @@ const Combobox = forwardRef( : comboboxListbox} {hasError && ( -
+
{error}
)} diff --git a/packages/react/src/components/Dialog/index.tsx b/packages/react/src/components/Dialog/index.tsx index 92124e703..fed7f3b1a 100644 --- a/packages/react/src/components/Dialog/index.tsx +++ b/packages/react/src/components/Dialog/index.tsx @@ -8,6 +8,7 @@ import Icon from '../Icon'; import ClickOutsideListener from '../ClickOutsideListener'; import AriaIsolate from '../../utils/aria-isolate'; import setRef from '../../utils/setRef'; +import nextId from 'react-id-generator'; import { isBrowser } from '../../utils/is-browser'; export interface DialogProps extends React.HTMLAttributes { @@ -58,6 +59,7 @@ export default class Dialog extends React.Component { private element: HTMLDivElement | null; private heading: HTMLHeadingElement | null; + private headingId: string = nextId('dialog-title-'); constructor(props: DialogProps) { super(props); @@ -148,6 +150,7 @@ export default class Dialog extends React.Component { } setRef(dialogRef, el); }} + aria-labelledby={this.headingId} {...other} >
@@ -156,6 +159,7 @@ export default class Dialog extends React.Component { className="Dialog__heading" ref={(el: HTMLHeadingElement) => (this.heading = el)} tabIndex={-1} + id={this.headingId} > {typeof heading === 'object' && 'text' in heading ? heading.text diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index 6fdcacffd..5fe4bd483 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -126,7 +126,9 @@ const Popover = forwardRef( initialPlacement; const additionalProps = - variant === 'prompt' ? { 'aria-labelledby': `${id}-label` } : {}; + variant === 'prompt' && !props['aria-label'] + ? { 'aria-labelledby': `${id}-label` } + : {}; // Keep targetElement in sync with target prop useEffect(() => { @@ -162,9 +164,8 @@ const Popover = forwardRef( useEffect(() => { if (show && popoverRef.current) { // Find the first focusable element inside the container - const firstFocusableElement = popoverRef.current.querySelector( - focusableSelector - ); + const firstFocusableElement = + popoverRef.current.querySelector(focusableSelector); if (firstFocusableElement instanceof HTMLElement) { firstFocusableElement.focus(); @@ -253,8 +254,8 @@ const Popover = forwardRef( role="dialog" style={styles.popper} {...attributes.popper} - {...props} {...additionalProps} + {...props} >