diff --git a/.eslintrc-prettier.js b/.eslintrc-prettier.js new file mode 100644 index 0000000000..7822824178 --- /dev/null +++ b/.eslintrc-prettier.js @@ -0,0 +1,9 @@ +const prettierConfig = require('./.prettierrc.js'); +const baseConfig = require('./.eslintrc.js'); + +module.exports = { + extends: './.eslintrc.js', + rules: { + 'prettier/prettier': ['warn', prettierConfig] + } +} diff --git a/.eslintrc.js b/.eslintrc.js index 46f82c8606..a839dcd604 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,3 @@ -const prettierConfig = require('./.prettierrc.js'); - module.exports = { env: { browser: true, @@ -23,7 +21,7 @@ module.exports = { ecmaFeatures: { jsx: true, }, - project: ['./tsconfig.json', './cypress/tsconfig.json'], + project: ['./tsconfig.eslint.json', './cypress/tsconfig.json'], sourceType: 'module', }, settings: { @@ -37,12 +35,12 @@ module.exports = { plugins: [ 'workday-custom-rules', '@typescript-eslint', - '@typescript-eslint/tslint', 'jest', 'react', 'prettier', 'react-hooks', 'emotion', + 'jsdoc', ], rules: { 'workday-custom-rules/restricted-imports': 'error', @@ -60,11 +58,13 @@ module.exports = { 'default-case': 'error', 'dot-notation': 'error', 'eol-last': 'off', + eqeqeq: 'error', 'guard-for-in': 'error', 'linebreak-style': 'off', 'new-parens': 'off', 'newline-per-chained-call': 'off', 'no-caller': 'error', + 'no-duplicate-imports': 'error', 'no-debugger': 'error', 'no-empty': 'error', 'no-empty-function': 'error', @@ -75,44 +75,17 @@ module.exports = { 'no-multiple-empty-lines': 'off', 'no-new-wrappers': 'error', 'no-param-reassign': 'error', + 'no-redeclare': 'error', 'no-undef-init': 'error', + 'no-unused-expressions': 'off', + '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-labels': 'error', - 'no-use-before-define': 'warn', // Decide on this + 'no-use-before-define': 'off', // TS takes care of this one... 'no-var': 'error', 'prefer-const': 'error', - 'quote-props': 'off', 'space-before-function-paren': 'off', 'react/jsx-no-bind': 'off', // Keep perf implications in mind, but was giving too many warnings and hurting readability curly: 'error', radix: 'error', - 'prettier/prettier': ['error', prettierConfig], - // NOTE: Commented out everything below that caused problems. A lot of this is likely included in the stuff above. - '@typescript-eslint/tslint/config': [ - 'error', - { - rules: { - // align: [true, 'parameters', 'arguments', 'statements'], - 'comment-format': [true, 'check-space'], - // deprecation: true, // turned off for button deprecation - 'jsdoc-format': true, // eslint-plugin-jsdoc - // 'jsx-no-string-ref': true, - // 'jsx-self-close': true, - 'no-duplicate-imports': true, - 'no-duplicate-variable': true, - // 'no-shadowed-variable': true, - 'no-unused-expression': true, - 'one-line': [true, 'check-catch', 'check-open-brace', 'check-whitespace'], - 'triple-equals': [true, 'allow-null-check'], - typedef: [true, 'parameter', 'property-declaration'], - 'variable-name': [ - true, - 'ban-keywords', - 'check-format', - 'allow-leading-underscore', - 'allow-pascal-case', - ], - }, - }, - ], }, }; diff --git a/.github/tsc.json b/.github/tsc.json new file mode 100644 index 0000000000..158f7e83d3 --- /dev/null +++ b/.github/tsc.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "tsc", + "pattern": [ + { + "regexp": "^(?:\\s+\\d+\\>)?([^\\s].*)\\((\\d+),(\\d+)\\)\\s*:\\s+(error|warning|info)\\s+(\\w{1,2}\\d+)\\s*:\\s*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e0629809..bf5c416b68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Required to retrieve git history - uses: actions/setup-node@v1 with: node-version: 10.x @@ -38,6 +40,9 @@ jobs: env: CYPRESS_CACHE_FOLDER: .cache/cypress + - name: Setup TSC matcher + run: node ./utils/add-matchers.js + # Keep steps separate for Github Actions annotation matching: https://github.com/actions/setup-node/blob/83c9f7a7df54d6b57455f7c57ac414f2ae5fb8de/src/setup-node.ts#L26-L33 - name: Lint run: yarn lint @@ -45,6 +50,9 @@ jobs: - name: Dependency Check run: yarn depcheck + - name: Type Check + run: yarn typecheck + - name: Unit tests run: yarn test @@ -52,7 +60,7 @@ jobs: run: yarn build-storybook --quiet - name: Start Server - run: yarn http-server docs -p 9001 & npx wait-on http://localhost:9001 + run: npx http-server docs -p 9001 & npx wait-on http://localhost:9001 - uses: chromaui/action@v1 with: diff --git a/.gitignore b/.gitignore index ca9f8b4f4b..f6917eeae0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ lerna-debug.log* # build modules/**/dist modules/**/ts-tmp +modules/**/ts3.5 build/* *.tsbuildinfo diff --git a/.postcssrc.js b/.postcssrc.js new file mode 100644 index 0000000000..096de4de62 --- /dev/null +++ b/.postcssrc.js @@ -0,0 +1,14 @@ +const date = new Date(); + +module.exports = { + map: true, + plugins: { + 'postcss-discard-duplicates': {}, + autoprefixer: {}, + 'css-mqpacker': {}, + 'postcss-banner': { + banner: `Copyright 2019-${date.getFullYear()} Workday, Inc.`, + }, + 'postcss-inline-svg': {}, + }, +}; diff --git a/.storybook/config.js b/.storybook/config.js index fc4155cd15..6458b12c7b 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -17,8 +17,8 @@ function loadStories() { const allExports = []; allReqs.forEach(req => { - req.keys().forEach(fname => { - const story = req(fname); + req.keys().forEach(filename => { + const story = req(filename); if (story.default) allExports.push(story); }); diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html index 7ca2ae856a..2f54884c16 100644 --- a/.storybook/manager-head.html +++ b/.storybook/manager-head.html @@ -1,3 +1,9 @@ + + diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 205f562ad6..cb4b07dd6a 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -5,7 +5,6 @@ const createCompiler = require('@storybook/addon-docs/mdx-compiler-plugin'); const modulesPath = path.resolve(__dirname, '../modules'); const welcomeSectionPath = path.resolve(__dirname, './'); const utilsPath = path.resolve(__dirname, '../utils'); -const postcssConfigPath = path.resolve(__dirname, './postcss.config'); module.exports = ({config, mode}) => { // This is so we get consistent results when loading .ts/tsx and .mdx files @@ -20,26 +19,18 @@ module.exports = ({config, mode}) => { }, ]; - // Exclude all node_modules from babel-loader - config.module.rules - .find(rule => /mjs\|jsx/.test(rule.test.toString())) - .exclude.push(/node_modules/); - - // Filter out extraneous rules added by CRA (react-scripts) - // react-scripts automatically adds js/ts matchers for a `src` folder which we don't use so these rules are moot - config.module.rules = config.module.rules.filter( - rule => !/js\|mjs\|jsx\|ts\|tsx/.test(rule.test.toString()) - ); - - // Override CRA postcss presets - config.module.rules.forEach(rule => { - if (rule.test.toString().includes('scss|sass')) { - delete rule.use[2].options.plugins; - - rule.use[2].options.config = { - path: postcssConfigPath, - }; - } + config.module.rules.push({ + test: /\.(scss|css)$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: {importLoaders: 2}, + }, + 'postcss-loader', + 'sass-loader', + ], + include: modulesPath, }); // Add `.ts` and `.tsx` as a resolvable extension. diff --git a/.travis.yml b/.travis.yml index bfdc658d16..fc3f7a2ab8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,8 +11,8 @@ git: stages: -- name: master - if: branch = master AND type != pull_request +- name: trunk + if: (branch = master OR branch =~ /^prerelease\//) AND type != pull_request - name: tag if: branch =~ /^v\d+\.\d+(\.\d+)?(-\S*)?$/ AND tag IS present @@ -26,7 +26,6 @@ install: jobs: include: - - stage: tag script: - yarn build @@ -37,20 +36,22 @@ jobs: on: tags: true - - stage: master + - stage: trunk script: - >- # Chromatic relies on a built Storybook, so exit early if build-storybook fails yarn build-storybook --quiet && yarn chromatic --quiet --auto-accept-changes --exit-once-uploaded --storybook-build-dir docs - yarn build + after_failure: + - node utils/report-failure.js env: - CHROMATIC_APP_CODE="dlpro96xybh" deploy: - provider: script - script: npm config set //registry.npmjs.org/:_authToken=$NPM_PUBLISH_TOKEN && node utils/publish-canary.js $SLACK_WEBHOOK $TRAVIS_BUILD_WEB_URL + script: npm config set //registry.npmjs.org/:_authToken=$NPM_PUBLISH_TOKEN && node utils/publish-canary.js skip_cleanup: true on: - branch: master + all_branches: true - provider: pages skip_cleanup: true local_dir: docs diff --git a/4.0-MIGRATION-GUIDE.md b/4.0-MIGRATION-GUIDE.md new file mode 100644 index 0000000000..c8fbbf1e42 --- /dev/null +++ b/4.0-MIGRATION-GUIDE.md @@ -0,0 +1,408 @@ +# Canvas Kit 4.0 Migration Guide + +Below are the breaking changes made in Canvas Kit v4. Please reach out if you have any questions +about the update. + +CSS users rejoice! :tada: No breaking changes in this release. The following changes all relate to +our React infrastructure and components. + +- [Infrastructure Upgrades](#infrastructure-upgrades) +- [Theming Changes](#theming-changes) +- [Breaking Component Changes](#breaking-component-changes) + - [General](#general) + - [Core](#core) + - [Avatar](#avatar) + - [Button](#button) + - [IconButtonToggleGroup](#iconbuttontogglegroup) + - [InputProvider](#inputprovider) + - [Modal](#modal) + - [DrawerHeader](#drawerheader) + - [SearchBar](#searchbar) + - [Menu](#menu) + - [Popup](#popup) + - [SidePanel](#sidepanel) + - [Skeleton](#skeleton) + - [Popper](#popper) + - [Tooltip](#tooltip) + +## Infrastructure Upgrades + +Breaking: + +- React & ReactDOM upgraded to 16.12 (https://github.com/Workday/canvas-kit/pull/533) + - We are now fully adopting hooks, so <= 16.7 is no longer supported + +Non-breaking: + +- Typescript upgraded to v3.8 (https://github.com/Workday/canvas-kit/pull/533) + - [downlevel-dts](https://github.com/sandersn/downlevel-dts) was used to resolve breaking changes + in `typescript@3.7`, so older versions of typescript are still supported. However, it is + recommended to use v3.8. +- Many of our dependencies have been updated to address a low level vulnerability. This shouldn't + affect your day to day usage of Canvas Kit. + +## Theming Changes + +We have promoted all of the theming functionality out of Canvas Kit Labs. +[It now lives in `@workday/canvas-kit-react-common`](https://github.com/Workday/canvas-kit/tree/prerelease/v4/modules/common/react/lib/theming). +This includes the `CanvasProvider` component. We've also made some stability improvements (see +below). + +PRs: + +- https://github.com/Workday/canvas-kit/pull/558 +- https://github.com/Workday/canvas-kit/pull/594 +- https://github.com/Workday/canvas-kit/pull/593 + +#### Changes + +- We now call `createCanvasTheme` as part of our internal `useTheme` hook to ensure we are always + accessing defined theme fields. It is no longer required to wrap your partial theme with + `createCanvasTheme` before passing it into `CanvasProvider`/`ThemeProvider`. `CanvasProvider` now + accepts a theme of the type `PartialEmotionCanvasTheme`. +- Because of this change, if you're using the Canvas theme passed from emotion within your + components, you now need to wrap the theme (e.g. `useTheme(theme)`) to ensure all fields are + defined. +- In order to better support non-Canvas themes, the `CanvasTheme` object now needs to be namespaced + under `canvas`: + +```tsx +{ + theme: { + canvas: { + palette: { + // ... + }, + breakpoints: { + // ... + }, + direction: ContentDirection.LTR + } + } +} +``` + +- This means several type references have changed: + + - `CanvasTheme` > `EmotionCanvasTheme` + - `PartialCanvasTheme` > `PartialEmotionCanvasTheme` + +## Breaking Component Changes + +### General + +- We've moved away from using `SyntheticEvent` typing in favor of using more accurate types + (https://github.com/Workday/canvas-kit/pull/499) +- Popper dependency has been upgraded to v2 and now all popups use React Portals (potential z-index + breaking change) +- Several ARIA label props have been renamed for clarity + (https://github.com/Workday/canvas-kit/pull/551). These changes are broken down by component + below. +- Our `focusRing` utility has been updated with a new API to support theming and improve legibility. + (https://github.com/Workday/canvas-kit/pull/558 & https://github.com/Workday/canvas-kit/pull/726). + Example: + ```tsx + focusRing(2, 2, true, false, buttonColors.focusRingInner, buttonColors.focusRingOuter); + ``` + becomes + ```tsx + focusRing({ + separation: 2, + innerColor: buttonColors.focusRingInner, + outerColor: buttonColors.focusRingOuter, + }); + ``` + +### Core + +We've made minor changes to our link variant text styles based on feedback from accessibility. As +part of this change, we've also added a new `Hyperlink` component to +`@workday/canvas-kit-react-button` to make applying these styles easier. + +We've updated to `@workday/canvas-colors-web@2.0.0`, which comes with a few breaking changes: + +- `canvas.colors.primary` & `colors.primary` were previously deprecated and are no longer available + under this namespace. All of these semantic colors are still accessible via the semantic colors + exports (`buttonColors`, `inputColors`, etc.) +- `canvas.colors.gradients` & `colors.gradients` exports have been moved to `canvas.gradients` or + `gradients`. +- `canvas.inputColors.warning` & `inputColors.warning` exports have been changed to + `*inputColors.alert` to match other conventions +- Narrow incorrect `CanvasColor` type from `string | undefined` to a list of all canvas colors + +PRs: + +- https://github.com/Workday/canvas-kit/pull/541 +- https://github.com/Workday/canvas-kit/pull/706 + +### Avatar + +- `AvatarButton` has been removed. By default `Avatar` will now be a button. If you need the old + plain div version you can pass the prop `as="div"`. +- The component is now a functional component instead of a class. If you are using ref on the class + version it will not be pointing to the same thing. `buttonRef` has changed to `ref` since it could + now reference a button or a div +- Visual change: Avatar images appear once they are load. While loading or if they fail to load the + default icon will be shown. So you may want to check which variant you are using even in the image + case. + +PR: + +- https://github.com/Workday/canvas-kit/pull/614 + +### Button + +We've refactored our Button components to simplify logic and add support for theming. + +PRs: + +- https://github.com/Workday/canvas-kit/pull/471 +- https://github.com/Workday/canvas-kit/pull/509 +- https://github.com/Workday/canvas-kit/pull/527 +- https://github.com/Workday/canvas-kit/pull/540 +- https://github.com/Workday/canvas-kit/pull/541 + +#### Changes + +- Some of the button variants have been split into different components to prevent invalid API + combinations. `DeleteButton`, `HighlightButton`, and `OutlineButton` are now separate components + with their own interface. Here are some of the invalid prop combinations that are no longer + possible: + + - Delete button with a data label or icon + - Dropdown button with a data label or icon + - Highlight button with a data label + - Highlight button without an icon + - Dropdown with variants other than primary and secondary + - Small buttons with an icon or data label + - Small Highlight button + - Small Dropdown button + - etc. + + - **Required changes:** + - ` +``` + +After: + +```tsx +Label +``` + +--- + +### IconButtonToggleGroup + +This component has been renamed to `SegmentedControl` and has been converted into it's own component +(`@workday/canvas-kit-react-segmented-control`). `IconButtonToggleGroup` is no longer exported from +`@workday/canvas-kit-react-button`. + +PRs: + +- https://github.com/Workday/canvas-kit/pull/505 +- https://github.com/Workday/canvas-kit/pull/524 + +Before: + +```tsx +import {IconButtonToggleGroup} from '@workday/canvas-kit-react-button'; + + + + +; +``` + +After: + +```tsx +import {SegmentedControl} from '@workday/canvas-kit-labs-react-segmented-control'; + + + + +; +``` + +--- + +### InputProvider + +Our `InputProvider` did not work with React Portals (since the popups get placed outside of the +`InputProvider` container `div`. `InputProvider` provider has been updated to use `document.body` +(configurable with the `containerElement` prop). + +PR: https://github.com/Workday/canvas-kit/pull/546 + +--- + +### Modal + +Modal now uses React Portals which could cause a visual breaking change related to z-indexing. +Modals now use a popup stack manager that controls z-indexing. Adding your own zIndex will no longer +have any effect. Modals accurately handle escape key, so `closeOnEscape` has been removed. If you +used this feature, you may want to look into the PopupStack. + +PRs: + +- https://github.com/Workday/canvas-kit/pull/419 +- https://github.com/Workday/canvas-kit/pull/670 + +--- + +### DrawerHeader + +The following props where renamed for appropriate aria naming and clarity + +- `closeIconLabel` -> `closeIconAriaLabel` + +PRs: + +- https://github.com/Workday/canvas-kit/pull/551 + +--- + +### SearchBar + +The following props where renamed for appropriate aria naming and clarity + +- `submitLabel` -> `submitAriaLabel` +- `openButtonLabel` -> `openButtonAriaLabel` +- `closeButtonLabel` -> `closeButtonAriaLabel` + +PRs: + +- https://github.com/Workday/canvas-kit/pull/551 + +--- + +### Menu + +The following props where renamed for appropriate aria naming and clarity + +- `labeledBy` -> `'aria-labelledby'` + +PRs: + +- https://github.com/Workday/canvas-kit/pull/551 + +--- + +### Popup + +The following props where renamed for appropriate aria naming and clarity + +- `closeLabel` -> `closeButtonAriaLabel` + +PRs: + +- https://github.com/Workday/canvas-kit/pull/551 + +--- + +### SidePanel + +The following props where renamed for appropriate aria naming and clarity + +- `closeNavigationLabel` -> `closeNavigationAriaLabel` +- `openNavigationLabel` -> `openNavigationAriaLabel` + +PRs: + +- https://github.com/Workday/canvas-kit/pull/551 + +--- + +### Skeleton + +The following props where renamed for appropriate aria naming and clarity + +- `loadingLabel` -> `'aria-label` + +PRs: + +- https://github.com/Workday/canvas-kit/pull/551 + +--- + +### Popper + +`Popper` was changed to a Functional Component with a forwarded ref. If you passed a `ref` object to +`Popper` before, it will now point to the element rather than the `Popper` instance. Popper was +moved to the `@workday/canvas-kit-react-popup` module. This change aligns with the concept that +Popup is a type of UI behavior. Popups can be built on top of the popup system in the Popup module. + +PRs: + +- https://github.com/Workday/canvas-kit/pull/528 +- https://github.com/Workday/canvas-kit/pull/670 + +--- + +### Tooltip + +Tooltip now uses React Portals and has been completely updated to make attaching tooltips much +easier. + +PR: + +- https://github.com/Workday/canvas-kit/pull/528 + +The original `Tooltip` did little more than add a `role="tooltip"` to a styled component. The +original tooltip is now exported as `TooltipContainer` to make it easier to migrate without +rewriting all tooltips. The new experience is much better and will remove the need for wrapping +components, but if you'd like to keep using the old tooltip as is, your imports will have to be +updated to use the old API: **Before:** + +```ts +import {Tooltip} from '@workday/canvas-kit-react-tooltip'; +``` + +**After:** + +```ts +import {TooltipContainer as Tooltip} from '@workday/canvas-kit-react-tooltip'; +``` + +Also with this change, the tooltip no longer gets the role `tooltip` and must be added manually. + +--- + +**More to come! Check out [our 4.0 tracking issue](https://github.com/Workday/canvas-kit/issues/483) +for all planned changes.** diff --git a/API_PATTERN_GUIDELINES.md b/API_PATTERN_GUIDELINES.md index b10e6329e7..7c3bfddd1e 100644 --- a/API_PATTERN_GUIDELINES.md +++ b/API_PATTERN_GUIDELINES.md @@ -133,12 +133,13 @@ Some of the below rules are inspired by painpoints we've encountered in this pro #### Input Provider -- All Canvas Kit components should support a wrapping `InputProvider` component to provide the - cleanest experience for mouse users. Read the docs +- All Canvas Kit components should support an `InputProvider` component to provide the cleanest + experience for mouse users. Read the docs [here](https://github.com/Workday/canvas-kit/tree/master/modules/core/react#input-provider). -- Do not use `InputProvider` within your components. It is meant to be a higher order component - wrapping a whole application of Canvas components +- Do not use `InputProvider` within your components. It is meant to be used only once in your + application. It does not require wrapping any children - Make sure you provide fully accessible styling by default, and only override for mouse usage. +- ```tsx [`[data-whatinput='mouse'] &:focus, @@ -238,9 +239,31 @@ foo(); - Use `defaultProps` whenever you find yourself checking for the existence of something before executing branching logic. It significantly reduces conditionals, facilitating easier testing and less bugs. -- Any prop included in `defaultProps` should be typed as required in the component interface. - However, it can still be documented as optional in the README. You can find more details - [here](https://stackoverflow.com/questions/37282159/default-property-value-in-react-component-using-typescript) +- We prefer to colocate our default props and destructure them which allows consumers to rename our + components on import. +- Note: If you assign a default value to a prop, make sure to make the prop as optional in the + interface. + +```jsx +const someInterface { + /** + * If true, sets the Checkbox checked to true + * @default false + */ + checked?: boolean; + /** + * If true, set the Checkbox to the disabled state. + * @default false + */ + disabled?: boolean; + /** + * The value of the Checkbox. + */ + value?: string; +} +//... +const {checked = false, disabled = false, value} = this.props; +``` #### Class Function Binding @@ -325,7 +348,9 @@ The base pattern for prop descriptions is: `The of the .` value?: string; ``` -Be as specific as possible. For example, suppose there is a `label` prop for `Checkbox` which specifies the text of the label. Rather than describe `label` as `The label of the Checkbox`, the following is preferable: +Be as specific as possible. For example, suppose there is a `label` prop for `Checkbox` which +specifies the text of the label. Rather than describe `label` as `The label of the Checkbox`, the +following is preferable: ``` /** @@ -343,7 +368,8 @@ Feel free to provide additional detail in the description: value: number; ``` -Be sure to specify a proper `@default` for enum props. Listing the named values which are accepted by the enum prop is encouraged: +Be sure to specify a proper `@default` for enum props. Listing the named values which are accepted +by the enum prop is encouraged: ``` /** @@ -353,16 +379,18 @@ Be sure to specify a proper `@default` for enum props. Listing the named values openDirection?: SidePanelOpenDirection; ``` -Use a modified pattern for function props: `The function called when .` For example: +Use a modified pattern for function props: `The function called when .` For +example: ``` /** * The function called when the Checkbox state changes. */ -onChange?: (e: React.SyntheticEvent) => void; +onChange?: (e: React.ChangeEvent) => void; ``` -The pattern for booleans is also different: `If true, .` For standard 2-state booleans, set `@default false` in the description. For example: +The pattern for booleans is also different: `If true, .` For standard 2-state +booleans, set `@default false` in the description. For example: ``` /** @@ -382,7 +410,8 @@ Provide additional detail for 2-state booleans where the `false` outcome cannot centeredNav?: boolean; ``` -For 3-state booleans, you will need to describe all 3 cases: `If true . If false . If undefined .` +For 3-state booleans, you will need to describe all 3 cases: +`If true . If false . If undefined .` We also recommend the following pattern for errors: @@ -393,7 +422,10 @@ We also recommend the following pattern for errors: error?: ErrorType; ``` -Occasionally, you may encounter props which don't play nicely with the suggested guidelines. Rather than following the patterns to the letter, adjust them to provide a better description if necessary. For example, rather than ambiguously describing `id` as `The id of the Checkbox`, provide a more explicit description: +Occasionally, you may encounter props which don't play nicely with the suggested guidelines. Rather +than following the patterns to the letter, adjust them to provide a better description if necessary. +For example, rather than ambiguously describing `id` as `The id of the Checkbox`, provide a more +explicit description: ``` /** diff --git a/CHANGELOG.md b/CHANGELOG.md index 441368f8f8..8f90a5084f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,100 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 4.0.0 (2020-06-15) + +The changes below are the consolidation of changes made across all 4.0.0 beta versions (`v4.0.0-beta.0-5`). + +To review the breaking changes made in this release, check out the [v4.0.0 Migration Guide](./4.0-MIGRATION-GUIDE.md). + +### Infrastructure +- ci: Release canary builds for prerelease branches ([#481](https://github.com/Workday/canvas-kit/pull/481)) [@anicholls](https://github.com/anicholls) +- ci: Fix prerelease canary builds ([#501](https://github.com/Workday/canvas-kit/pull/501)) [@anicholls](https://github.com/anicholls) +- feat: Add script for easy promotion of labs components ([#522](https://github.com/Workday/canvas-kit/pull/522)) [@anicholls](https://github.com/anicholls) +- chore: Manage dependencies ([#533](https://github.com/Workday/canvas-kit/pull/533)) [@anicholls](https://github.com/anicholls) +- fix: Remove SyntheticEvent type usage ([#499](https://github.com/Workday/canvas-kit/pull/499)) [@donovangini](https://github.com/donovangini) +- refactor: Destructure default props ([#525](https://github.com/Workday/canvas-kit/pull/525)) [@mannycarrera4](https://github.com/mannycarrera4) +- chore: Upgrade packages to fix vulnerabilities ([#531](https://github.com/Workday/canvas-kit/pull/531)) [@anicholls](https://github.com/anicholls) +- feat(core): Add window configuration option to inherit font family ([#553](https://github.com/Workday/canvas-kit/pull/553)) [@anicholls](https://github.com/anicholls) +- fix: Add type checking to PRs and fix type errors ([#609](https://github.com/Workday/canvas-kit/pull/609)) [@NicholasBoll](https://github.com/NicholasBoll) +- ci: Use sha in prerelease version to avoid duplicates ([#616](https://github.com/Workday/canvas-kit/pull/616)) [@anicholls](https://github.com/anicholls) +- ci: Trim sha before using it for canary preid ([#619](https://github.com/Workday/canvas-kit/pull/619)) [@anicholls](https://github.com/anicholls) +- ci: Fix version regex for canary publish ([#622](https://github.com/Workday/canvas-kit/pull/622)) [@anicholls](https://github.com/anicholls) +- fix: Clean up ts3.5 files ([#630](https://github.com/Workday/canvas-kit/pull/630)) [@NicholasBoll](https://github.com/NicholasBoll) +- ci: Add script to announce trunk build failures in slack ([#628](https://github.com/Workday/canvas-kit/pull/628)) [@anicholls](https://github.com/anicholls) +- chore: Upgrade Babel and presets to support optional chaining ([#631](https://github.com/Workday/canvas-kit/pull/631)) [@NicholasBoll](https://github.com/NicholasBoll) +- chore: Fix version issue in beta build ([#644](https://github.com/Workday/canvas-kit/pull/644)) [@anicholls](https://github.com/anicholls) +- chore: Fix create-module and promote-module ([#660](https://github.com/Workday/canvas-kit/pull/660)) [@NicholasBoll](https://github.com/NicholasBoll) +- fix: Fix check-lockfile call during precommit linting ([#663](https://github.com/Workday/canvas-kit/pull/663)) [@jamesfan](https://github.com/jamesfan) +- ci: Improve canary builds & publish behavior ([#665](https://github.com/Workday/canvas-kit/pull/665)) [@anicholls](https://github.com/anicholls) +- docs: Clean up 4.0 migration guide ([#677](https://github.com/Workday/canvas-kit/pull/677)) [@anicholls](https://github.com/anicholls) +- fix: Cleanup after merging master into prerelease/v4 [@anicholls](https://github.com/anicholls) +- chore: Update canvas-colors-web dependencies ([#706](https://github.com/Workday/canvas-kit/pull/706)) [@anicholls](https://github.com/anicholls) + +### Theming + +- chore: Promote theming functions out of labs ([#558](https://github.com/Workday/canvas-kit/pull/558)) [@mannycarrera4](https://github.com/mannycarrera4) +- chore: Move theme functionality from labs to common ([#594](https://github.com/Workday/canvas-kit/pull/594)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(common): Improve theming API stability ([#593](https://github.com/Workday/canvas-kit/pull/593)) [@anicholls](https://github.com/anicholls) +- fix(common): Auto-generate contrast color for partial theme ([#700](https://github.com/Workday/canvas-kit/pull/700)) [@donovangini](https://github.com/donovangini) + +### Components +- refactor(button): Simplify Button components and prep for theming ([#471](https://github.com/Workday/canvas-kit/pull/471)) [@anicholls](https://github.com/anicholls) +- refactor: Rename and move IconButtonToggleGroup to SegmentedControl ([#505](https://github.com/Workday/canvas-kit/pull/505)) [@anicholls](https://github.com/anicholls) +- fix(modal): Use React portals for accessibility fixes ([#419](https://github.com/Workday/canvas-kit/pull/419)) [@NicholasBoll](https://github.com/NicholasBoll) +- chore: Promote SegmentedControl out of labs ([#524](https://github.com/Workday/canvas-kit/pull/524)) [@anicholls](https://github.com/anicholls) +- fix(button): Misc. fixes after refactor ([#509](https://github.com/Workday/canvas-kit/pull/509)) [@anicholls](https://github.com/anicholls) +- feat(button): Add theming support to buttons ([#527](https://github.com/Workday/canvas-kit/pull/527)) [@anicholls](https://github.com/anicholls) +- refactor(button): TextButton design updates ([#540](https://github.com/Workday/canvas-kit/pull/540)) [@anicholls](https://github.com/anicholls) +- feat(button): Add Hyperlink component ([#541](https://github.com/Workday/canvas-kit/pull/541)) [@anicholls](https://github.com/anicholls) +- feat(tooltip): Refactor to a simpler API ([#528](https://github.com/Workday/canvas-kit/pull/528)) [@NicholasBoll](https://github.com/NicholasBoll) +- feat(core): Allow InputProvider to use a configurable container ([#546](https://github.com/Workday/canvas-kit/pull/546)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(button): Fix IconButton states and update TextButton CSS ([#577](https://github.com/Workday/canvas-kit/pull/577)) [@anicholls](https://github.com/anicholls) +- ci(tooltip): Fix chromatic flag ([#585](https://github.com/Workday/canvas-kit/pull/585)) [@NicholasBoll](https://github.com/NicholasBoll) +- fix: Rename prop labels to match aria labels ([#551](https://github.com/Workday/canvas-kit/pull/551)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(modal): Add missing aria-modal=true and add aria-label ([#588](https://github.com/Workday/canvas-kit/pull/588)) [@alexandrzavalii](https://github.com/alexandrzavalii) +- feat(button): Add href support ([#590](https://github.com/Workday/canvas-kit/pull/590)) [@anicholls](https://github.com/anicholls) +- fix(color-picker): Fix accessibility announcement for color input ([#639](https://github.com/Workday/canvas-kit/pull/639)) [@mannycarrera4](https://github.com/mannycarrera4) +- test(toast): Fix chromatic stories for toast ([#625](https://github.com/Workday/canvas-kit/pull/625)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(pagination): Provide aria live attribute for accessbility ([#620](https://github.com/Workday/canvas-kit/pull/620)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(avatar): Combine Avatar & AvatarButton and provide fallback image ([#614](https://github.com/Workday/canvas-kit/pull/614)) [@vibdev](https://github.com/vibdev) +- feat(select): Add theming to select in labs ([#648](https://github.com/Workday/canvas-kit/pull/648)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(avatar): Fix misalignment on ie11 ([#676](https://github.com/Workday/canvas-kit/pull/676)) [@alexandrzavalii](https://github.com/alexandrzavalii) +- fix(toast): Action link align on new line ([#682](https://github.com/Workday/canvas-kit/pull/682)) [@alexandrzavalii](https://github.com/alexandrzavalii) +- fix(button): Update button readme with toolbar section ([#680](https://github.com/Workday/canvas-kit/pull/680)) [@mannycarrera4](https://github.com/mannycarrera4) +- feat(button): Add toolbar dropdown button ([#684](https://github.com/Workday/canvas-kit/pull/684)) [@mannycarrera4](https://github.com/mannycarrera4) +- test(card): Add stories and enable snapshots ([#708](https://github.com/Workday/canvas-kit/pull/708)) [@mannycarrera4](https://github.com/mannycarrera4) +- feat: Add a Popup Stack manager to Canvas Kit ([#670](https://github.com/Workday/canvas-kit/pull/670)) [@NicholasBoll](https://github.com/NicholasBoll) +- fix: Use theme contrast color for input "checks" ([#719](https://github.com/Workday/canvas-kit/pull/719)) [@anicholls](https://github.com/anicholls) +- fix(button): Misc. styling fixes and update to focusRing API ([#726](https://github.com/Workday/canvas-kit/pull/726)) [@anicholls](https://github.com/anicholls) +- fix(form-field): Fix default prop bug ([#702](https://github.com/Workday/canvas-kit/pull/702)) [@alanbsmith](https://github.com/alanbsmith) +- fix(segmented-control): Misc. fixes and story improvements ([#730](https://github.com/Workday/canvas-kit/pull/730)) [@anicholls](https://github.com/anicholls) +- fix(icon): Fix icon color references ([#733](https://github.com/Workday/canvas-kit/pull/733)) [@anicholls](https://github.com/anicholls) +- feat(select): Render menu using a portal ([#641](https://github.com/Workday/canvas-kit/pull/641)) [@jamesfan](https://github.com/jamesfan) + +# 4.0.0-beta.5 (2020-06-12) + +### Infrastructure +- docs: Clean up 4.0 migration guide ([#677](https://github.com/Workday/canvas-kit/pull/677)) [@anicholls](https://github.com/anicholls) +- fix: Cleanup after merging master into prerelease/v4 [@anicholls](https://github.com/anicholls) +- chore: Update canvas-colors-web dependencies ([#706](https://github.com/Workday/canvas-kit/pull/706)) [@anicholls](https://github.com/anicholls) + +### Components +- fix(avatar): Fix misalignment on ie11 ([#676](https://github.com/Workday/canvas-kit/pull/676)) [@alexandrzavalii](https://github.com/alexandrzavalii) +- fix(toast): Action link align on new line ([#682](https://github.com/Workday/canvas-kit/pull/682)) [@alexandrzavalii](https://github.com/alexandrzavalii) +- fix(button): Update button readme with toolbar section ([#680](https://github.com/Workday/canvas-kit/pull/680)) [@mannycarrera4](https://github.com/mannycarrera4) +- feat(button): Add toolbar dropdown button ([#684](https://github.com/Workday/canvas-kit/pull/684)) [@mannycarrera4](https://github.com/mannycarrera4) +- test(card): Add stories and enable snapshots ([#708](https://github.com/Workday/canvas-kit/pull/708)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(common): Auto-generate contrast color for partial theme ([#700](https://github.com/Workday/canvas-kit/pull/700)) [@donovangini](https://github.com/donovangini) +- feat: Add a Popup Stack manager to Canvas Kit ([#670](https://github.com/Workday/canvas-kit/pull/670)) [@NicholasBoll](https://github.com/NicholasBoll) +- fix: Use theme contrast color for input "checks" ([#719](https://github.com/Workday/canvas-kit/pull/719)) [@anicholls](https://github.com/anicholls) +- fix(button): Misc. styling fixes and update to focusRing API ([#726](https://github.com/Workday/canvas-kit/pull/726)) [@anicholls](https://github.com/anicholls) +- fix(form-field): Fix default prop bug ([#702](https://github.com/Workday/canvas-kit/pull/702)) [@alanbsmith](https://github.com/alanbsmith) +- fix(segmented-control): Misc. fixes and story improvements ([#730](https://github.com/Workday/canvas-kit/pull/730)) [@anicholls](https://github.com/anicholls) +- fix(icon): Fix icon color references ([#733](https://github.com/Workday/canvas-kit/pull/733)) [@anicholls](https://github.com/anicholls) +- feat(select): Render menu using a portal ([#641](https://github.com/Workday/canvas-kit/pull/641)) [@jamesfan](https://github.com/jamesfan) + # 3.9.0 (2020-06-15) ### Infrastructure @@ -40,6 +134,58 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - fix: Align hover ripple for checkbox and radio components in IE11 ([#651](https://github.com/Workday/canvas-kit/pull/651)) [@lychyi](https://github.com/lychyi) - fix(switch): Fix click target for switch ([#671](https://github.com/Workday/canvas-kit/pull/671)) [@mannycarrera4](https://github.com/mannycarrera4) +# 4.0.0-beta.4 (2020-05-20) + +### Infrastructure +- chore: Fix create-module and promote-module ([#660](https://github.com/Workday/canvas-kit/pull/660)) [@NicholasBoll](https://github.com/NicholasBoll) +- fix: Fix check-lockfile call during precommit linting ([#663](https://github.com/Workday/canvas-kit/pull/663)) [@jamesfan](https://github.com/jamesfan) +- ci: Improve canary builds & publish behavior ([#665](https://github.com/Workday/canvas-kit/pull/665)) [@anicholls](https://github.com/anicholls) + +### Components + +- fix(avatar): Combine Avatar & AvatarButton and provide fallback image ([#614](https://github.com/Workday/canvas-kit/pull/614)) [@vibdev](https://github.com/vibdev) +- feat(select): Add theming to select in labs ([#648](https://github.com/Workday/canvas-kit/pull/648)) [@mannycarrera4](https://github.com/mannycarrera4) + +# 4.0.0-beta.3 (2020-05-12) + +### Infrastructure + +- chore: Upgrade Babel and presets to support optional chaining ([#631](https://github.com/Workday/canvas-kit/pull/631)) [@NicholasBoll](https://github.com/NicholasBoll) +- chore: Fix version issue in beta build ([#644](https://github.com/Workday/canvas-kit/pull/644)) [@anicholls](https://github.com/anicholls) + +### Components + +- fix(pagination): Provide aria live attribute for accessbility ([#620](https://github.com/Workday/canvas-kit/pull/620)) [@mannycarrera4](https://github.com/mannycarrera4) + +# 4.0.0-beta.2 (2020-05-11) + +### Infrastructure + +- chore: Upgrade packages to fix vulnerabilities ([#531](https://github.com/Workday/canvas-kit/pull/531)) [@anicholls](https://github.com/anicholls) +- feat(core): Add window configuration option to inherit font family ([#553](https://github.com/Workday/canvas-kit/pull/553)) [@anicholls](https://github.com/anicholls) +- fix: Add type checking to PRs and fix type errors ([#609](https://github.com/Workday/canvas-kit/pull/609)) [@NicholasBoll](https://github.com/NicholasBoll) +- ci: Use sha in prerelease version to avoid duplicates ([#616](https://github.com/Workday/canvas-kit/pull/616)) [@anicholls](https://github.com/anicholls) +- ci: Trim sha before using it for canary preid ([#619](https://github.com/Workday/canvas-kit/pull/619)) [@anicholls](https://github.com/anicholls) +- ci: Fix version regex for canary publish ([#622](https://github.com/Workday/canvas-kit/pull/622)) [@anicholls](https://github.com/anicholls) +- fix: Clean up ts3.5 files ([#630](https://github.com/Workday/canvas-kit/pull/630)) [@NicholasBoll](https://github.com/NicholasBoll) +- ci: Add script to announce trunk build failures in slack ([#628](https://github.com/Workday/canvas-kit/pull/628)) [@anicholls](https://github.com/anicholls) + +### Theming + +- chore: Promote theming functions out of labs ([#558](https://github.com/Workday/canvas-kit/pull/558)) [@mannycarrera4](https://github.com/mannycarrera4) +- chore: Move theme functionality from labs to common ([#594](https://github.com/Workday/canvas-kit/pull/594)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(common): Improve theming API stability ([#593](https://github.com/Workday/canvas-kit/pull/593)) [@anicholls](https://github.com/anicholls) + +### Components + +- fix(button): Fix IconButton states and update TextButton CSS ([#577](https://github.com/Workday/canvas-kit/pull/577)) [@anicholls](https://github.com/anicholls) +- ci(tooltip): Fix chromatic flag ([#585](https://github.com/Workday/canvas-kit/pull/585)) [@NicholasBoll](https://github.com/NicholasBoll) +- fix: Rename prop labels to match aria labels ([#551](https://github.com/Workday/canvas-kit/pull/551)) [@mannycarrera4](https://github.com/mannycarrera4) +- fix(modal): Add missing aria-modal=true and add aria-label ([#588](https://github.com/Workday/canvas-kit/pull/588)) [@alexandrzavalii](https://github.com/alexandrzavalii) +- feat(button): Add href support ([#590](https://github.com/Workday/canvas-kit/pull/590)) [@anicholls](https://github.com/anicholls) +- fix(color-picker): Fix accessibility announcement for color input ([#639](https://github.com/Workday/canvas-kit/pull/639)) [@mannycarrera4](https://github.com/mannycarrera4) +- test(toast): Fix chromatic stories for toast ([#625](https://github.com/Workday/canvas-kit/pull/625)) [@mannycarrera4](https://github.com/mannycarrera4) + # 3.7.0 (2020-05-06) ### Infrastructure @@ -60,6 +206,19 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - feat(select): Implement Canvas menu ([#454](https://github.com/Workday/canvas-kit/pull/454)) [@jamesfan](https://github.com/jamesfan) - test(popup): Add tests for Popup ([#600](https://github.com/Workday/canvas-kit/pull/600)) [@mannycarrera4](https://github.com/mannycarrera4) +# 4.0.0-beta.1 (2020-04-13) + +### Infrastructure + +- fix: Remove SyntheticEvent type usage ([#499](https://github.com/Workday/canvas-kit/pull/499)) [@donovangini](https://github.com/donovangini) +- refactor: Destructure default props ([#525](https://github.com/Workday/canvas-kit/pull/525)) [@mannycarrera4](https://github.com/mannycarrera4) + +### Components +- refactor(button): TextButton design updates ([#540](https://github.com/Workday/canvas-kit/pull/540)) [@anicholls](https://github.com/anicholls) +- feat(button): Add Hyperlink component ([#541](https://github.com/Workday/canvas-kit/pull/541)) [@anicholls](https://github.com/anicholls) +- feat(tooltip): Refactor to a simpler API ([#528](https://github.com/Workday/canvas-kit/pull/528)) [@NicholasBoll](https://github.com/NicholasBoll) +- feat(core): Allow InputProvider to use a configurable container ([#546](https://github.com/Workday/canvas-kit/pull/546)) [@mannycarrera4](https://github.com/mannycarrera4) + # 3.6.0 (2020-04-13) ### Infrastructure @@ -76,6 +235,24 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - fix(color-picker): Design & use case improvements ([#519](https://github.com/Workday/canvas-kit/pull/519)) [@anicholls](https://github.com/anicholls) - fix(menu): Fix flashing on initial selected index ([#561](https://github.com/Workday/canvas-kit/pull/561)) [@NicholasBoll](https://github.com/NicholasBoll) +# 4.0.0-beta.0 (2020-03-30) + +### Infrastructure + +- ci: Release canary builds for prerelease branches ([#481](https://github.com/Workday/canvas-kit/pull/481)) [@anicholls](https://github.com/anicholls) +- ci: Fix prerelease canary builds ([#501](https://github.com/Workday/canvas-kit/pull/501)) [@anicholls](https://github.com/anicholls) +- feat: Add script for easy promotion of labs components ([#522](https://github.com/Workday/canvas-kit/pull/522)) [@anicholls](https://github.com/anicholls) +- chore: Manage dependencies ([#533](https://github.com/Workday/canvas-kit/pull/533)) [@anicholls](https://github.com/anicholls) + +### Components + +- **[BREAKING]** refactor(button): Simplify Button components and prep for theming ([#471](https://github.com/Workday/canvas-kit/pull/471)) [@anicholls](https://github.com/anicholls) +- **[BREAKING]** refactor: Rename and move IconButtonToggleGroup to SegmentedControl ([#505](https://github.com/Workday/canvas-kit/pull/505)) [@anicholls](https://github.com/anicholls) +- **[BREAKING]** fix(modal): Use React portals for accessibility fixes ([#419](https://github.com/Workday/canvas-kit/pull/419)) [@NicholasBoll](https://github.com/NicholasBoll) +- **[BREAKING]** chore: Promote SegmentedControl out of labs ([#524](https://github.com/Workday/canvas-kit/pull/524)) [@anicholls](https://github.com/anicholls) +- fix(button): Misc. fixes after refactor ([#509](https://github.com/Workday/canvas-kit/pull/509)) [@anicholls](https://github.com/anicholls) +- feat(button): Add theming support to buttons ([#527](https://github.com/Workday/canvas-kit/pull/527)) [@anicholls](https://github.com/anicholls) + # 3.5.0 (2020-03-12) ### Infrastructure diff --git a/babel.config.js b/babel.config.js index 9286cdf7bb..bea37de398 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,12 +2,7 @@ module.exports = { env: { test: { presets: ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'], - plugins: [ - 'emotion', - '@babel/proposal-class-properties', - '@babel/proposal-object-rest-spread', - '@babel/plugin-transform-runtime', - ], + plugins: ['emotion', '@babel/proposal-class-properties', '@babel/plugin-transform-runtime'], }, }, }; diff --git a/cypress/helpers/modal.ts b/cypress/helpers/modal.ts index 924948c0ac..8c4a64a156 100644 --- a/cypress/helpers/modal.ts +++ b/cypress/helpers/modal.ts @@ -1,19 +1,6 @@ -/** - * Gets the modal element with the `[role=dialog]` - * @param testId Optional test id to target the desired modal - * @example - * h.modal.get() - * .should('have.attr', 'role', 'dialog') - */ -export function get(testId?: string): Cypress.Chainable { - const selector = testId ? `[data-testid='${testId}'] [role=dialog]` : `[role=dialog]`; - - return cy.get(selector); -} - /** * Gets the title component of the Modal. This is a required element for accessibility - * @param $modal Modal component + * @param $modal Modal element with [role=dialog] * @example * h.modal.get() * .pipe(h.modal.getTitle) @@ -26,7 +13,7 @@ export function getTitle($modal: JQuery): JQuery { /** * Gets the top-right 'X' button if available. Will fail if it is not present - * @param $modal Modal component + * @param $modal Modal element with [role=dialog] * @example * h.modal.get() * .pipe(h.modal.getCloseButton) @@ -35,3 +22,11 @@ export function getTitle($modal: JQuery): JQuery { export function getCloseButton($modal: JQuery): JQuery { return $modal.find('[data-close]'); } + +/** + * Gets the Overlay element given the modal element + * @param $modal Modal element with [role=dialog] + */ +export function getOverlay($modal: JQuery): JQuery { + return $modal.parent().parent(); +} diff --git a/cypress/integration/Avatar.spec.ts b/cypress/integration/Avatar.spec.ts new file mode 100644 index 0000000000..935b85e0f8 --- /dev/null +++ b/cypress/integration/Avatar.spec.ts @@ -0,0 +1,39 @@ +import * as h from '../helpers'; + +describe('Avatar', () => { + before(() => { + h.stories.visit(); + }); + + context('given default avatar light is rendered', () => { + beforeEach(() => { + h.stories.load('Components|Indicators/Avatar/React/Default', 'Light'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + }); + + context('given avatar button light is rendered', () => { + beforeEach(() => { + h.stories.load('Components|Indicators/Avatar Button/React/Default', 'Light'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + }); + + context('given avatar button image is rendered', () => { + beforeEach(() => { + h.stories.load('Components|Indicators/Avatar/React/Avatar Button', 'Image'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + cy.get('img').should('be.visible'); // wait for image to load + cy.checkA11y(); + }); + }); +}); diff --git a/cypress/integration/Button.spec.ts b/cypress/integration/Button.spec.ts index 2dae24ec00..d33179542d 100644 --- a/cypress/integration/Button.spec.ts +++ b/cypress/integration/Button.spec.ts @@ -7,7 +7,7 @@ describe('Button', () => { context('given primary buttons are rendered', () => { beforeEach(() => { - h.stories.load('Components|Buttons/Button/React', 'Primary'); + h.stories.load('Components|Buttons/Button/React/Standard', 'Primary'); }); it('should not have any axe errors', () => { diff --git a/cypress/integration/ColorPicker.spec.ts b/cypress/integration/ColorPicker.spec.ts index f429777c7d..e525c41d0e 100644 --- a/cypress/integration/ColorPicker.spec.ts +++ b/cypress/integration/ColorPicker.spec.ts @@ -2,8 +2,6 @@ import * as h from '../helpers'; const getColorInput = () => cy.get('[type="text"]'); -const getIconButton = () => cy.get('button.wdc-ckr-icon-button'); - const getColorPickerPopup = () => cy.get('[role=dialog]'); const expandHex = (hex: string) => { @@ -13,13 +11,6 @@ const expandHex = (hex: string) => { }); }; -const hexToRgb = (hex: string) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(expandHex(hex)); - return result - ? `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})` - : null; -}; - const colorInputStory = 'Components|Inputs/Color Picker/Color Input/React/Top Label'; const colorPreviewStory = 'Components|Inputs/Color Picker/Color Preview/React/Top Label'; const colorPickerStory = 'Labs/Color Picker/React'; @@ -126,7 +117,7 @@ describe('ColorPicker', () => { context('when the IconButton is clicked', () => { beforeEach(() => { h.stories.load(colorPickerStory, 'Icon Button Popup'); - getIconButton().click(); + cy.get('button').click(); }); it('should pass accessibility checks', () => { @@ -141,7 +132,7 @@ describe('ColorPicker', () => { const color = '8660d1'; beforeEach(() => { cy.get(`.wdc-color-picker--color-${color}`).click(); - getIconButton().click(); + cy.get('button').click(); }); it('should have check icon', () => { @@ -154,12 +145,12 @@ describe('ColorPicker', () => { context('when color reset is clicked', () => { beforeEach(() => { cy.get(`.wdc-color-picker--color-8660d1`).click(); - getIconButton().click(); + cy.get('button').click(); cy.get('[data-testid="color-picker-reset"]').click(); }); it('should set the color picker value to the reset color', () => { - getIconButton().click(); + cy.get('button').click(); cy.get(`.wdc-color-picker--color-0875e1`) .find('.wd-icon') .should('exist'); @@ -174,9 +165,10 @@ describe('ColorPicker', () => { it('should set the selected color to input value', () => { getColorInput().type('#123123'); getColorPickerPopup() - .find('button.wdc-ckr-icon-button') + .find('button') + .last() .click(); - getIconButton().click(); + cy.get('button').click(); getColorInput() .parent() .find('.wd-icon') diff --git a/cypress/integration/Modal.spec.ts b/cypress/integration/Modal.spec.ts index 49852f61c3..6e4292d164 100644 --- a/cypress/integration/Modal.spec.ts +++ b/cypress/integration/Modal.spec.ts @@ -6,11 +6,10 @@ function getModalTargetButton() { describe('Modal', () => { before(() => { - cy.viewport(500, 300); h.stories.visit(); }); - ['Default', 'With useModal hook'].forEach(story => { + ['Default', 'WithoutHook'].forEach(story => { context(`given the '${story}' story is rendered`, () => { beforeEach(() => { h.stories.load('Components|Popups/Modal/React', story); @@ -36,7 +35,29 @@ describe('Modal', () => { }); it('should open the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('be.visible'); + }); + + it('should place the portal as a child of the body element', () => { + cy.get('body').then($body => { + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) + .parent() + .should($el => { + expect($el[0]).to.equal($body[0]); + }); + }); + }); + + it('should hide non-modal content from assistive technology', () => { + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) + .siblings() + .should($siblings => { + $siblings.each((_, $sibling) => { + expect($sibling).to.have.attr('aria-hidden', 'true'); + }); + }); }); it('should not have any axe errors', () => { @@ -45,22 +66,25 @@ describe('Modal', () => { context('the modal', () => { it('should have a the role of dialog', () => { - h.modal.get().should('have.attr', 'role', 'dialog'); + cy.findByLabelText('Delete Item').should('have.attr', 'role', 'dialog'); }); it('should have an aria-labelledby attribute', () => { - h.modal.get().should('have.attr', 'aria-labelledby'); + cy.findByLabelText('Delete Item').should('have.attr', 'aria-labelledby'); + }); + + it('should have an aria-modal=true', () => { + cy.findByLabelText('Delete Item').should('have.attr', 'aria-modal', 'true'); }); it('should contain the title', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('contain', 'Delete Item'); }); it('should be labelled by the title element', () => { - h.modal.get().should($modal => { + cy.findByLabelText('Delete Item').should($modal => { const labelId = $modal.attr('aria-labelledby'); const titleId = h.modal.getTitle($modal).attr('id'); @@ -69,8 +93,7 @@ describe('Modal', () => { }); it('should transfer focus to the x icon element', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getCloseButton) .should('have.focus'); }); @@ -81,8 +104,7 @@ describe('Modal', () => { .tab() .should('contain', 'Cancel') .tab(); - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getCloseButton) .should('have.focus'); }); @@ -90,14 +112,13 @@ describe('Modal', () => { context('when the close button is clicked', () => { beforeEach(() => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getCloseButton) .click(); }); it('should close the modal', () => { - h.modal.get().should('not.be.visible'); + cy.findByLabelText('Delete Item').should('not.be.visible'); }); it('should transfer focus back to the target button', () => { @@ -113,20 +134,19 @@ describe('Modal', () => { }); it('should close the modal', () => { - h.modal.get().should('not.be.visible'); + cy.findByLabelText('Delete Item').should('not.be.visible'); }); }); context('when the overlay is clicked', () => { beforeEach(() => { - h.modal - .get() - .parent() // overlay + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) .click('top'); }); it('should close the modal', () => { - h.modal.get().should('not.be.visible'); + cy.findByLabelText('Delete Item').should('not.be.visible'); }); }); }); @@ -158,7 +178,29 @@ describe('Modal', () => { }); it('should open the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('be.visible'); + }); + + it('should place the portal as a child of the body element', () => { + cy.get('body').then($body => { + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) + .parent() + .should($el => { + expect($el[0]).to.equal($body[0]); + }); + }); + }); + + it('should hide non-modal content from assistive technology', () => { + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) + .siblings() + .should($siblings => { + $siblings.each((_, $sibling) => { + expect($sibling).to.have.attr('aria-hidden', 'true'); + }); + }); }); it('should not have any axe errors', () => { @@ -166,37 +208,38 @@ describe('Modal', () => { }); it('should transfer focus to the header element', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('have.focus'); }); it('should not show a focus ring on the header', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('have.css', 'outlineStyle', 'none'); }); context('the modal', () => { it('should have a the role of dialog', () => { - h.modal.get().should('have.attr', 'role', 'dialog'); + cy.findByLabelText('Delete Item').should('have.attr', 'role', 'dialog'); }); it('should have an aria-labelledby attribute', () => { - h.modal.get().should('have.attr', 'aria-labelledby'); + cy.findByLabelText('Delete Item').should('have.attr', 'aria-labelledby'); + }); + + it('should have an aria-modal=true', () => { + cy.findByLabelText('Delete Item').should('have.attr', 'aria-modal', 'true'); }); it('should contain the title', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('contain', 'Delete Item'); }); it('should be labelled by the title element', () => { - h.modal.get().should($modal => { + cy.findByLabelText('Delete Item').should($modal => { const labelId = $modal.attr('aria-labelledby'); const titleId = h.modal.getTitle($modal).attr('id'); @@ -205,15 +248,13 @@ describe('Modal', () => { }); it('should transfer focus to the header element', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('have.focus'); }); it('should trap focus inside the modal element', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('have.focus'); cy.tab() @@ -233,20 +274,19 @@ describe('Modal', () => { }); it('should not close the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('be.visible'); }); }); context('when the overlay is clicked', () => { beforeEach(() => { - h.modal - .get() - .parent() // overlay + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) .click('top'); }); it('should not close the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('be.visible'); }); }); }); @@ -273,27 +313,30 @@ describe('Modal', () => { }); it('should open the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('be.visible'); }); context('the modal', () => { it('should have a the role of dialog', () => { - h.modal.get().should('have.attr', 'role', 'dialog'); + cy.findByLabelText('Delete Item').should('have.attr', 'role', 'dialog'); }); it('should have an aria-labelledby attribute', () => { - h.modal.get().should('have.attr', 'aria-labelledby'); + cy.findByLabelText('Delete Item').should('have.attr', 'aria-labelledby'); + }); + + it('should have an aria-modal=true', () => { + cy.findByLabelText('Delete Item').should('have.attr', 'aria-modal', 'true'); }); it('should contain the title', () => { - h.modal - .get() + cy.findByLabelText('Delete Item') .pipe(h.modal.getTitle) .should('contain', 'Delete Item'); }); it('should be labelled by the title element', () => { - h.modal.get().should($modal => { + cy.findByLabelText('Delete Item').should($modal => { const labelId = $modal.attr('aria-labelledby'); const titleId = h.modal.getTitle($modal).attr('id'); @@ -302,21 +345,17 @@ describe('Modal', () => { }); it('should transfer focus to the Cancel button element', () => { - h.modal - .get() - .find('button:contains(Cancel)') - .should('have.focus'); + cy.findByLabelText('Item name').should('have.focus'); }); it('should trap focus inside the modal element', () => { - cy.focused().should('have.text', 'Cancel'); cy.focused() .tab() .should('contain', 'Delete') .tab() .should('contain', 'Cancel') .tab(); - cy.focused().should('have.text', 'Delete'); + cy.findByLabelText('Item name').should('have.focus'); }); }); @@ -328,20 +367,101 @@ describe('Modal', () => { }); it('should not close the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('be.visible'); + }); + }); + + context('when the overlay is clicked', () => { + beforeEach(() => { + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) + .click('top'); + }); + + it('should not close the modal', () => { + cy.findByLabelText('Delete Item').should('be.visible'); + }); + }); + }); + }); + + context(`given the 'StackedModals' story is rendered`, () => { + beforeEach(() => { + h.stories.load('Testing|React/Popups/Modal', 'StackedModals'); + }); + + context('when both modals are opened', () => { + beforeEach(() => { + cy.contains('button', 'Delete Item').click(); + cy.contains('button', 'Yes, Delete').click(); + }); + + it('should open the second modal', () => { + cy.findByLabelText('Really Delete Item').should('exist'); + }); + + context('when the Escape key is pressed', () => { + beforeEach(() => { + cy.get('body').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should close the second modal', () => { + cy.findByLabelText('Really Delete Item').should('not.exist'); + }); + + it('should not close the first modal', () => { + cy.findByLabelText('Delete Item').should('exist'); }); }); context('when the overlay is clicked', () => { beforeEach(() => { - h.modal - .get() - .parent() // overlay + cy.findByLabelText('Really Delete Item') + .pipe(h.modal.getOverlay) .click('top'); }); + it('should close the second modal', () => { + cy.findByLabelText('Really Delete Item').should('not.exist'); + }); + + it('should not close the first modal', () => { + cy.findByLabelText('Delete Item').should('exist'); + }); + }); + }); + }); + + context(`given the 'ModalWithPopup' story is rendered`, () => { + beforeEach(() => { + h.stories.load('Testing|React/Popups/Modal', 'ModalWithPopup'); + }); + + context('when both modal and popup are opened', () => { + beforeEach(() => { + cy.contains('button', 'Delete Item').click(); + cy.contains('button', 'Yes, Delete').click(); + }); + + it('should open the second modal', () => { + cy.findByLabelText('Really Delete Item').should('exist'); + }); + + context('when the modal overlay is clicked', () => { + beforeEach(() => { + cy.findByLabelText('Delete Item') + .pipe(h.modal.getOverlay) + .click('top'); + }); + + it('should close the popup', () => { + cy.findByLabelText('Really Delete Item').should('not.exist'); + }); + it('should not close the modal', () => { - h.modal.get().should('be.visible'); + cy.findByLabelText('Delete Item').should('exist'); }); }); }); diff --git a/cypress/integration/Popup.spec.ts b/cypress/integration/Popup.spec.ts index 92ca86bb84..ae524a0b3b 100644 --- a/cypress/integration/Popup.spec.ts +++ b/cypress/integration/Popup.spec.ts @@ -65,6 +65,59 @@ describe('Popup', () => { getPopup().should('not.visible'); }); }); + + context('when the escape key is pressed', () => { + beforeEach(() => { + cy.get('body').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should close the popup', () => { + getPopup().should('not.visible'); + }); + }); + + context('when the user clicks outside the popup', () => { + beforeEach(() => { + cy.get('body').click('topLeft'); + }); + + it('should close the popup', () => { + getPopup().should('not.visible'); + }); + }); + }); + }); + + context('given the MultiplePopups story is rendered', () => { + beforeEach(() => { + h.stories.load('Testing|React/Popups/Popup', 'MultiplePopups'); + }); + + context('when Open Popup 1 button is clicked', () => { + beforeEach(() => { + cy.findByText('Open Popup 1').click(); + }); + + it('should open Popup 1', () => { + cy.findByLabelText('Popup 1').should('be.visible'); + }); + + context('then Open Popup 2 button is click', () => { + beforeEach(() => { + cy.findByText('Open Popup 2').click(); + }); + + it('should open Popup 2', () => { + cy.findByLabelText('Popup 2').should('be.visible'); + }); + + // TODO Skip for now until we have a systematic approach to fix this issue + it.skip('should close Popup 1', () => { + cy.findAllByLabelText('Popup 1').should('not.exist'); + }); + }); }); }); }); diff --git a/cypress/integration/PopupStack.spec.ts b/cypress/integration/PopupStack.spec.ts new file mode 100644 index 0000000000..664da8e3ad --- /dev/null +++ b/cypress/integration/PopupStack.spec.ts @@ -0,0 +1,153 @@ +import * as h from '../helpers'; +import {queries} from '@testing-library/dom'; + +const beOnTopOfLabelledByText = (labelText: string) => ($el: JQuery) => { + const comparingElement = queries.getByLabelText(Cypress.$('body')[0], labelText); + + const comparingElementZIndex = + parseFloat(comparingElement.style.zIndex) || + parseFloat(comparingElement.parentElement?.style.zIndex || ''); + const actualElementZIndex = + parseFloat($el.css('zIndex')) || parseFloat($el.parent().css('zIndex')); + expect(actualElementZIndex).to.be.greaterThan(comparingElementZIndex); +}; + +describe('PopupStack', () => { + before(() => { + h.stories.visit(); + }); + + beforeEach(() => { + h.stories.load('Testing|React/Popups/Popup Stack', 'MixedPopupTypes'); + }); + + it('should start with Window 3 stacked on top of 3 Windows', () => { + cy.findByLabelText('Window 3') + .should(beOnTopOfLabelledByText('Window 2')) + .should(beOnTopOfLabelledByText('Window 4')) + .should(beOnTopOfLabelledByText('Window 1')); + }); + + context('when Window 2 is clicked', () => { + beforeEach(() => { + cy.findByLabelText('Window 2').click(); + }); + + it('should place Window 2 above others', () => { + cy.findByLabelText('Window 2') + .should(beOnTopOfLabelledByText('Window 3')) + .should(beOnTopOfLabelledByText('Window 1')); + }); + }); + + context('when Window 1 Tooltip is hovered', () => { + beforeEach(() => { + cy.findByText('Contents of Window 1').trigger('mouseover'); + }); + + it('should place Window 1 Tooltip all stacked items', () => { + cy.findByRole('tooltip') + .should(beOnTopOfLabelledByText('Window 1')) + .should(beOnTopOfLabelledByText('Window 2')) + .should(beOnTopOfLabelledByText('Window 4')); + }); + }); + + context('when Delete Item button is clicked', () => { + beforeEach(() => { + cy.contains('button', 'Delete Item').click(); + }); + + it('should open Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('be.visible'); + }); + + context('when Window 2 is clicked', () => { + beforeEach(() => { + cy.findByLabelText('Window 2').click(); + }); + + it('should close Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('not.exist'); + }); + + it('should place Window 2 above others', () => { + cy.findByLabelText('Window 2') + .should(beOnTopOfLabelledByText('Window 1')) + .should(beOnTopOfLabelledByText('Window 3')); + }); + }); + + context('when Window 2 Tooltip is hovered', () => { + beforeEach(() => { + cy.findByText('Contents of Window 2').trigger('mouseover'); + }); + + context('when Window 2 Tooltip is clicked', () => { + beforeEach(() => { + cy.findByText('Contents of Window 2').click(); + }); + + it('should close Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('not.exist'); + }); + + it('should place Window 2 above others', () => { + cy.findByLabelText('Window 2') + .should(beOnTopOfLabelledByText('Window 1')) + .should(beOnTopOfLabelledByText('Window 3')); + }); + }); + + context('when the Escape key is pressed', () => { + beforeEach(() => { + cy.get('html').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should close the Tooltip', () => { + cy.findByRole('tooltip').should('not.exist'); + }); + + it('should not close the Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('be.visible'); + }); + + context('when the Escape key is pressed again', () => { + beforeEach(() => { + cy.get('html').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should close the Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('not.exist'); + }); + }); + }); + }); + + context('when user clicks outside', () => { + beforeEach(() => { + cy.get('html').click('bottomRight'); + }); + + it('should close Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('not.exist'); + }); + }); + + context('when the Escape key is pressed', () => { + beforeEach(() => { + cy.get('html').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should close Delete Item popup', () => { + cy.findByLabelText('Delete Item').should('not.exist'); + }); + }); + }); +}); diff --git a/cypress/integration/Tooltip.spec.ts b/cypress/integration/Tooltip.spec.ts new file mode 100644 index 0000000000..073a57d1b4 --- /dev/null +++ b/cypress/integration/Tooltip.spec.ts @@ -0,0 +1,125 @@ +import * as h from '../helpers'; + +describe('Tooltip', () => { + before(() => { + h.stories.visit(); + }); + + context('given Default is rendered', () => { + beforeEach(() => { + h.stories.load('Components|Popups/Tooltip/React', 'Default'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + + it('should have an aria-label of "Close"', () => { + cy.get('button').should('have.ariaLabel', 'Close'); + }); + + context('when close icon is hovered', () => { + beforeEach(() => { + cy.get('button').trigger('mouseover'); + }); + + it('should open the tooltip', () => { + cy.findByRole('tooltip').should('be.visible'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + + context('when the tooltip is hovered', () => { + beforeEach(() => { + cy.get('button').trigger('mouseout'); + cy.findByRole('tooltip').trigger('mouseover'); + }); + + it('should not close the tooltip', () => { + cy.findByRole('tooltip').should('be.visible'); + }); + }); + + context('when ESC key is pressed', () => { + beforeEach(() => { + cy.get('body').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should close the tooltip', () => { + cy.findByRole('tooltip').should('not.be.visible'); + }); + }); + }); + + context('when close icon gains focus', () => { + beforeEach(() => { + cy.get('button').focus(); + }); + + it('should open the tooltip', () => { + cy.findByRole('tooltip').should('be.visible'); + }); + + context('then the close icon loses focus', () => { + beforeEach(() => { + cy.get('button').blur(); + }); + + it('should close the tooltip', () => { + cy.findByRole('tooltip').should('not.be.visible'); + }); + }); + + context('then Escape key is pressed', () => { + beforeEach(() => { + cy.get('button').trigger('keydown', { + key: 'Escape', + }); + }); + + it('should not remove focus from the close icon button', () => { + cy.get('button').should('have.focus'); + }); + }); + }); + }); + + context('given Describe Type is rendered', () => { + beforeEach(() => { + h.stories.load('Components|Popups/Tooltip/React', 'Describe Type'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + + it('should not have an aria-describedby', () => { + cy.get('button').should('not.have.attr', 'aria-describedby'); + }); + + context('when Delete button is hovered', () => { + beforeEach(() => { + cy.get('button').trigger('mouseover'); + }); + + it('should show the tooltip', () => { + cy.findByRole('tooltip').should('be.visible'); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + + it('should have an aria-describedby linking to the button', () => { + cy.get('button').should( + 'have.ariaDescription', + 'The service will restart after this action' + ); + }); + }); + }); +}); diff --git a/cypress/integration/storybook.spec.ts b/cypress/integration/storybook.spec.ts index aa14e49064..7bd389c00c 100644 --- a/cypress/integration/storybook.spec.ts +++ b/cypress/integration/storybook.spec.ts @@ -6,10 +6,7 @@ describe('Storybook', () => { it('should render the Getting Started page', () => { cy.visit('/'); cy.get('iframe#storybook-preview-iframe') - .pipe( - getIframeBody, - {timeout: 20000} - ) + .pipe(getIframeBody, {timeout: 20000}) .should('contain', 'Workday Canvas Kit'); }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index eda2b6eabe..f984ab8c88 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,5 +1,4 @@ import * as axe from 'axe-core'; -import {Promise} from 'cypress/types/bluebird'; declare global { interface Window { @@ -40,3 +39,74 @@ Cypress.Commands.overwrite('tab', (originalFn, subject) => { log.end(); }); }); + +declare global { + namespace Cypress { + interface Chainable {} + + interface Chainer { + /** + * Asserts the element has an aria description matching the text + * @example + * cy.get('body').should('have.ariaDescription', 'string') + */ + (chainer: 'have.ariaDescription', text: string): Chainable; + /** + * Asserts the element has an aria label matching the text. This + * can be from an `aria-label` or an `aria-labelledby` + * @example + * cy.get('body').should('have.ariaLabel', 'string') + */ + (chainer: 'have.ariaLabel', text: string): Chainable; + } + } +} + +export const haveAriaDescription = (text: string) => ($target: JQuery) => { + expect($target).to.have.attr('aria-describedby'); + + const id = $target.attr('aria-describedby'); + const $descriptionEl = Cypress.$(`[id="${id}"]`); + if (!$descriptionEl.length) { + throw Error( + `Could not find an element with an id matching the aria-describedby: ${$target[0].outerHTML}` + ); + } + + expect($descriptionEl).to.have.text(text); +}; + +export const haveAriaLabel = (text: string) => ($target: JQuery) => { + if ($target.attr('aria-label')) { + expect($target).to.have.attr('aria-label', text); + } else if ($target.attr('aria-labelledby')) { + const id = $target.attr('aria-labelledby'); + const $labelledEl = Cypress.$(`[id="${id}"]`); + if (!$labelledEl.length) { + throw Error( + `Could not found an element with an id matching the aria-labelledby: ${$target[0].outerHTML}` + ); + } + + expect($labelledEl).to.have.text(text); + } else { + throw Error(`Expected element to have an aria-label or aria-labelledby, but did not find one.`); + } +}; + +function isKeyOf(obj: T, key: any): key is keyof T { + return typeof key === 'string' && key in obj; +} + +Cypress.Commands.overwrite('should', (originalFn, subject, expectation, ...args) => { + const customMatchers = { + 'have.ariaDescription': haveAriaDescription(args[0]), + 'have.ariaLabel': haveAriaLabel(args[0]), + }; + // See if the expectation is a string and if it is a member of Jest's expect + if (isKeyOf(customMatchers, expectation)) { + return originalFn(subject, customMatchers[expectation]); + } + + return originalFn(subject, expectation, ...args); +}); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 3377d9021a..d8604e9ba2 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "lib": ["es2015", "es2017", "dom"], + "strict": true, "types": ["cypress"] }, "exclude": [] diff --git a/lerna.json b/lerna.json index 8d20324345..6f58eac2c8 100644 --- a/lerna.json +++ b/lerna.json @@ -2,13 +2,17 @@ "packages": [ "modules/**" ], - "version": "3.9.0", + "version": "4.0.0", "npmClient": "yarn", "useWorkspaces": true, "command": { "version": { "gitTagVersion": false, - "push": false + "push": false, + "forcePublish": "*" + }, + "publish": { + "forcePublish": "*" } } } diff --git a/modules/_canvas-kit-css/package.json b/modules/_canvas-kit-css/package.json index ac2fc7d7b0..0d713bcd2a 100644 --- a/modules/_canvas-kit-css/package.json +++ b/modules/_canvas-kit-css/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-css", - "version": "3.8.0", + "version": "4.0.0", "description": "The parent module that contains all Workday Canvas Kit CSS components", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -16,28 +16,27 @@ "url": "https://github.com/Workday/canvas-kit/tree/master/modules/_canvas-kit-css" }, "dependencies": { - "@workday/canvas-kit-css-action-bar": "^3.8.0", - "@workday/canvas-kit-css-badge": "^3.8.0", - "@workday/canvas-kit-css-banner": "^3.8.0", - "@workday/canvas-kit-css-button": "^3.8.0", - "@workday/canvas-kit-css-card": "^3.8.0", - "@workday/canvas-kit-css-checkbox": "^3.8.0", - "@workday/canvas-kit-css-common": "^3.8.0", - "@workday/canvas-kit-css-core": "^3.8.0", - "@workday/canvas-kit-css-form-field": "^3.8.0", - "@workday/canvas-kit-css-icon": "^3.8.0", - "@workday/canvas-kit-css-layout": "^3.8.0", - "@workday/canvas-kit-css-loading-animation": "^3.8.0", - "@workday/canvas-kit-css-menu": "^3.8.0", - "@workday/canvas-kit-css-modal": "^3.8.0", - "@workday/canvas-kit-css-page-header": "^3.8.0", - "@workday/canvas-kit-css-popup": "^3.8.0", - "@workday/canvas-kit-css-radio": "^3.8.0", - "@workday/canvas-kit-css-select": "^3.8.0", - "@workday/canvas-kit-css-table": "^3.8.0", - "@workday/canvas-kit-css-text-area": "^3.8.0", - "@workday/canvas-kit-css-text-input": "^3.8.0", - "@workday/canvas-kit-css-tooltip": "^3.8.0" + "@workday/canvas-kit-css-action-bar": "^4.0.0", + "@workday/canvas-kit-css-banner": "^4.0.0", + "@workday/canvas-kit-css-button": "^4.0.0", + "@workday/canvas-kit-css-card": "^4.0.0", + "@workday/canvas-kit-css-checkbox": "^4.0.0", + "@workday/canvas-kit-css-common": "^4.0.0", + "@workday/canvas-kit-css-core": "^4.0.0", + "@workday/canvas-kit-css-form-field": "^4.0.0", + "@workday/canvas-kit-css-icon": "^4.0.0", + "@workday/canvas-kit-css-layout": "^4.0.0", + "@workday/canvas-kit-css-loading-animation": "^4.0.0", + "@workday/canvas-kit-css-menu": "^4.0.0", + "@workday/canvas-kit-css-modal": "^4.0.0", + "@workday/canvas-kit-css-page-header": "^4.0.0", + "@workday/canvas-kit-css-popup": "^4.0.0", + "@workday/canvas-kit-css-radio": "^4.0.0", + "@workday/canvas-kit-css-select": "^4.0.0", + "@workday/canvas-kit-css-table": "^4.0.0", + "@workday/canvas-kit-css-text-area": "^4.0.0", + "@workday/canvas-kit-css-text-input": "^4.0.0", + "@workday/canvas-kit-css-tooltip": "^4.0.0" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/modules/_canvas-kit-react/index.ts b/modules/_canvas-kit-react/index.ts index d71316bca9..5dca12422d 100644 --- a/modules/_canvas-kit-react/index.ts +++ b/modules/_canvas-kit-react/index.ts @@ -17,6 +17,7 @@ export * from '@workday/canvas-kit-react-modal'; export * from '@workday/canvas-kit-react-page-header'; export * from '@workday/canvas-kit-react-popup'; export * from '@workday/canvas-kit-react-radio'; +export * from '@workday/canvas-kit-react-segmented-control'; export * from '@workday/canvas-kit-react-select'; export * from '@workday/canvas-kit-react-side-panel'; export * from '@workday/canvas-kit-react-skeleton'; diff --git a/modules/_canvas-kit-react/package.json b/modules/_canvas-kit-react/package.json index 496be54764..313dbb6191 100644 --- a/modules/_canvas-kit-react/package.json +++ b/modules/_canvas-kit-react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-react", - "version": "3.9.0", + "version": "4.0.0", "description": "The parent module that contains all Workday Canvas Kit React components", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -14,16 +14,25 @@ }, "files": [ "dist/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -37,34 +46,35 @@ "react": ">= 16.8 < 17" }, "dependencies": { - "@workday/canvas-kit-react-action-bar": "^3.9.0", - "@workday/canvas-kit-react-avatar": "^3.9.0", - "@workday/canvas-kit-react-badge": "^3.9.0", - "@workday/canvas-kit-react-banner": "^3.9.0", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-card": "^3.9.0", - "@workday/canvas-kit-react-checkbox": "^3.9.0", - "@workday/canvas-kit-react-color-picker": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-cookie-banner": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "@workday/canvas-kit-react-form-field": "^3.9.0", - "@workday/canvas-kit-react-icon": "^3.9.0", - "@workday/canvas-kit-react-layout": "^3.9.0", - "@workday/canvas-kit-react-loading-animation": "^3.9.0", - "@workday/canvas-kit-react-modal": "^3.9.0", - "@workday/canvas-kit-react-page-header": "^3.9.0", - "@workday/canvas-kit-react-popup": "^3.9.0", - "@workday/canvas-kit-react-radio": "^3.9.0", - "@workday/canvas-kit-react-select": "^3.9.0", - "@workday/canvas-kit-react-side-panel": "^3.9.0", - "@workday/canvas-kit-react-skeleton": "^3.9.0", - "@workday/canvas-kit-react-status-indicator": "^3.9.0", - "@workday/canvas-kit-react-switch": "^3.9.0", - "@workday/canvas-kit-react-table": "^3.9.0", - "@workday/canvas-kit-react-text-area": "^3.9.0", - "@workday/canvas-kit-react-text-input": "^3.9.0", - "@workday/canvas-kit-react-toast": "^3.9.0", - "@workday/canvas-kit-react-tooltip": "^3.9.0" + "@workday/canvas-kit-react-action-bar": "^4.0.0", + "@workday/canvas-kit-react-avatar": "^4.0.0", + "@workday/canvas-kit-react-badge": "^4.0.0", + "@workday/canvas-kit-react-banner": "^4.0.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-card": "^4.0.0", + "@workday/canvas-kit-react-checkbox": "^4.0.0", + "@workday/canvas-kit-react-color-picker": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-cookie-banner": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-kit-react-form-field": "^4.0.0", + "@workday/canvas-kit-react-icon": "^4.0.0", + "@workday/canvas-kit-react-layout": "^4.0.0", + "@workday/canvas-kit-react-loading-animation": "^4.0.0", + "@workday/canvas-kit-react-modal": "^4.0.0", + "@workday/canvas-kit-react-page-header": "^4.0.0", + "@workday/canvas-kit-react-popup": "^4.0.0", + "@workday/canvas-kit-react-radio": "^4.0.0", + "@workday/canvas-kit-react-segmented-control": "^4.0.0", + "@workday/canvas-kit-react-select": "^4.0.0", + "@workday/canvas-kit-react-side-panel": "^4.0.0", + "@workday/canvas-kit-react-skeleton": "^4.0.0", + "@workday/canvas-kit-react-status-indicator": "^4.0.0", + "@workday/canvas-kit-react-switch": "^4.0.0", + "@workday/canvas-kit-react-table": "^4.0.0", + "@workday/canvas-kit-react-text-area": "^4.0.0", + "@workday/canvas-kit-react-text-input": "^4.0.0", + "@workday/canvas-kit-react-toast": "^4.0.0", + "@workday/canvas-kit-react-tooltip": "^4.0.0" } } diff --git a/modules/_labs/color-picker/react/README.md b/modules/_labs/color-picker/react/README.md index 044214d338..fd94ad4696 100644 --- a/modules/_labs/color-picker/react/README.md +++ b/modules/_labs/color-picker/react/README.md @@ -29,7 +29,7 @@ import * as React from 'react'; import ColorPicker from '@workday/canvas-kit-labs-react-color-picker'; import {colors} from '@workday/canvas-kit-react-core'; import {Button} from '@workday/canvas-kit-react-button'; -import {Popper} from '@workday/canvas-kit-react-common'; +import {Popper} from '@workday/canvas-kit-react-popup'; const MyComponent: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); diff --git a/modules/_labs/color-picker/react/lib/ColorPicker.tsx b/modules/_labs/color-picker/react/lib/ColorPicker.tsx index 182e54f36f..c3c4fb9ec6 100644 --- a/modules/_labs/color-picker/react/lib/ColorPicker.tsx +++ b/modules/_labs/color-picker/react/lib/ColorPicker.tsx @@ -29,7 +29,7 @@ export interface ColorPickerProps extends React.HTMLAttributes { */ showCustomHexInput?: boolean; /** - * The label text of the custom hex input. + * The label text of the custom hex input. This is also used as the aria-label * @default 'Custom Hex Color' */ customHexInputLabel?: string; diff --git a/modules/_labs/color-picker/react/lib/parts/SwatchBook.tsx b/modules/_labs/color-picker/react/lib/parts/SwatchBook.tsx index 1f36570ab0..83fc5159e8 100644 --- a/modules/_labs/color-picker/react/lib/parts/SwatchBook.tsx +++ b/modules/_labs/color-picker/react/lib/parts/SwatchBook.tsx @@ -34,7 +34,7 @@ const SwatchContainer = styled('div')( '&:focus': { outline: 'none', - ...focusRing(2, 2), + ...focusRing({separation: 2}), }, }, ({isSelected}) => ({ diff --git a/modules/_labs/color-picker/react/package.json b/modules/_labs/color-picker/react/package.json index 218fdb74d2..ad9169d8ac 100644 --- a/modules/_labs/color-picker/react/package.json +++ b/modules/_labs/color-picker/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-color-picker", - "version": "3.9.0", + "version": "4.0.0", "description": "Color Picker is a component for selecting a color.", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,15 +15,24 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6" + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts" }, "keywords": [ "canvas", @@ -36,15 +45,15 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-labs-react-core": "^3.9.0", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-color-picker": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "@workday/canvas-kit-react-form-field": "^3.9.0", - "@workday/canvas-kit-react-icon": "^3.9.0", - "@workday/canvas-kit-react-popup": "^3.9.0", - "@workday/canvas-kit-react-text-input": "^3.9.0", + "@workday/canvas-kit-labs-react-core": "^4.0.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-color-picker": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-kit-react-form-field": "^4.0.0", + "@workday/canvas-kit-react-icon": "^4.0.0", + "@workday/canvas-kit-react-popup": "^4.0.0", + "@workday/canvas-kit-react-text-input": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20" }, "peerDependencies": { diff --git a/modules/_labs/color-picker/react/stories/stories_ColorPicker.tsx b/modules/_labs/color-picker/react/stories/stories_ColorPicker.tsx index 8002a30208..cc4e0d0d51 100644 --- a/modules/_labs/color-picker/react/stories/stories_ColorPicker.tsx +++ b/modules/_labs/color-picker/react/stories/stories_ColorPicker.tsx @@ -5,7 +5,7 @@ import {action} from '@storybook/addon-actions'; import withReadme from 'storybook-readme/with-readme'; import {ColorInput} from '@workday/canvas-kit-react-color-picker'; import {colors} from '@workday/canvas-kit-react-core'; -import {Popper} from '@workday/canvas-kit-react-common'; +import {Popper} from '@workday/canvas-kit-react-popup'; import {IconButton} from '@workday/canvas-kit-react-button'; import {bgColorIcon} from '@workday/canvas-system-icons-web'; import {ColorPicker} from '../index'; @@ -61,8 +61,8 @@ storiesOf('Labs|Color Picker/React', module) const [color, setColor] = React.useState(defaultColor); const [colorInputValidColor, setColorInputValidColor] = React.useState(defaultColor); const [colorInputValue, setColorInputValue] = React.useState(defaultColor); - const inputRef = React.useRef(null); - const popupRef = React.useRef(null); + const inputRef = React.useRef(null); + const popupRef = React.useRef(null); const resetColor = () => { setColor(defaultColor); @@ -92,8 +92,7 @@ storiesOf('Labs|Color Picker/React', module) ]; const onBlur = (e: React.FocusEvent) => { - // @ts-ignore - if (!popupRef.current || !popupRef.current.popper.popper.contains(e.relatedTarget)) { + if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) { setOpen(false); } }; diff --git a/modules/_labs/combobox/react/lib/Combobox.tsx b/modules/_labs/combobox/react/lib/Combobox.tsx index 990559ff64..8a76029a7a 100644 --- a/modules/_labs/combobox/react/lib/Combobox.tsx +++ b/modules/_labs/combobox/react/lib/Combobox.tsx @@ -41,7 +41,7 @@ export interface ComboboxProps extends GrowthBehavior, React.HTMLAttributes; + onChange?: (e: React.ChangeEvent) => void; /** * The function called when the Combobox text input focuses. */ @@ -121,7 +121,7 @@ const getOptionId = (baseId?: string, index?: number) => `${baseId}-${optionIdPa const getTextFromElement = (children?: React.ReactNode) => { let text = ''; React.Children.map(children, child => { - if (child == null || typeof child === 'boolean' || child === {}) { + if (!child || typeof child === 'boolean' || child === {}) { text += ''; } else if (typeof child === 'string' || typeof child === 'number') { text += child.toString(); @@ -215,7 +215,7 @@ const Combobox = ({ }, [autocompleteItems, isFocused, value]); const handleAutocompleteClick = ( - event: React.SyntheticEvent, + event: React.KeyboardEvent | React.MouseEvent, menuItemProps: MenuItemProps ): void => { if (menuItemProps.isDisabled) { @@ -225,7 +225,7 @@ const Combobox = ({ setIsFocused(false); setInputValue(getTextFromElement(menuItemProps.children)); if (menuItemProps.onClick) { - menuItemProps.onClick(event); + menuItemProps.onClick(event as React.MouseEvent); } }; @@ -280,7 +280,7 @@ const Combobox = ({ case 'ArrowUp': case 'Up': // IE/Edge specific value const upIndex = - selectedAutocompleteIndex != null ? selectedAutocompleteIndex - 1 : lastItem; + selectedAutocompleteIndex !== null ? selectedAutocompleteIndex - 1 : lastItem; nextIndex = upIndex < 0 ? lastItem : upIndex; event.stopPropagation(); event.preventDefault(); @@ -289,7 +289,7 @@ const Combobox = ({ case 'ArrowDown': case 'Down': // IE/Edge specific value const downIndex = - selectedAutocompleteIndex != null ? selectedAutocompleteIndex + 1 : firstItem; + selectedAutocompleteIndex !== null ? selectedAutocompleteIndex + 1 : firstItem; nextIndex = downIndex >= autoCompleteItemCount ? firstItem : downIndex; event.stopPropagation(); event.preventDefault(); @@ -301,7 +301,7 @@ const Combobox = ({ break; case 'Enter': - if (selectedAutocompleteIndex != null) { + if (selectedAutocompleteIndex !== null) { const item = autocompleteItems[selectedAutocompleteIndex]; handleAutocompleteClick(event, item.props); if (item.props.isDisabled) { @@ -341,7 +341,7 @@ const Combobox = ({ inputRef: inputRef, 'aria-autocomplete': 'list', 'aria-activedescendant': - selectedAutocompleteIndex != null + selectedAutocompleteIndex !== null ? getOptionId(componentId, selectedAutocompleteIndex) : '', onChange: handleSearchInputChange, diff --git a/modules/_labs/combobox/react/package.json b/modules/_labs/combobox/react/package.json index 69932dd712..3f7ef881f8 100644 --- a/modules/_labs/combobox/react/package.json +++ b/modules/_labs/combobox/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-combobox", - "version": "3.9.0", + "version": "4.0.0", "description": "Text input with an autocomplete menu", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,16 +15,25 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -41,12 +50,12 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-labs-react-menu": "^3.9.0", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-card": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "@workday/canvas-kit-react-text-input": "^3.9.0", + "@workday/canvas-kit-labs-react-menu": "^4.0.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-card": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-kit-react-text-input": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20", "uuid": "^3.3.3" } diff --git a/modules/_labs/combobox/react/stories/stories.tsx b/modules/_labs/combobox/react/stories/stories.tsx index daaa5eee6e..50b788eb44 100644 --- a/modules/_labs/combobox/react/stories/stories.tsx +++ b/modules/_labs/combobox/react/stories/stories.tsx @@ -43,7 +43,7 @@ class Autocomplete extends React.Component< .map((x: any, i: string) => autocompleteResult(i)) .splice(0, 5)} onChange={this.autocompleteCallback} - showClearButton={this.props.showClearButton == null ? true : this.props.showClearButton} + showClearButton={this.props.showClearButton === null ? true : this.props.showClearButton} labelId="autocomplete-123" onFocus={action('Focus')} onBlur={action('Blur')} diff --git a/modules/_labs/core/react/README.md b/modules/_labs/core/react/README.md index 6299788ebb..fb47d2d2c1 100644 --- a/modules/_labs/core/react/README.md +++ b/modules/_labs/core/react/README.md @@ -90,59 +90,3 @@ const Box = styled('div')(space) padding-left: 40px; */ ``` - -## Providers - -Providers are higher order (wrapping) components used to provide global configuration to Canvas -components. - -### Canvas Provider - -This provider includes all of the Canvas Providers below. This is the way most consumers should use -the provider. This provider is required for our theming capabilities, so you can find more -information in the [theming documentation](./lib/theming/README.md). - -**We strongly encourage you to use this in your application to wrap all Canvas components.** - -```tsx -import * as React from 'react'; -import {CanvasProvider} from '@workday/canvas-kit-react'; - -{/* All your components containing any Canvas components */}; -``` - -#### Storybook Decorator - -We provide a [storybook decorator](../../utils/storybook/CanvasProviderDecorator.tsx) to wrap your -stories in a `CanvasProvider` (including `InputProvider`) automatically. - -Add this decorator to your `/.storybook/config.js` configuration file to apply to all stories: - -```js -import {CanvasProviderDecorator} from '../utils/storybook'; - -addDecorator(CanvasProviderDecorator); -``` - -Or, add it to stories individually: - -```js -import {CanvasProviderDecorator} from '../../../../utils/storybook'; - -storiesOf('My Story', module) - .addDecorator(CanvasProviderDecorator) - .add('All', () => ); -``` - -### Input Provider - -See the [@workday/canvas-kit-react-core docs](../../../core/react/README.md#input-provider) - -## Theming - -Theming documentation has its own README. You can find it [here](./lib/theming/README.md) - -## Bidirectionality - -Bidirectionality is provided by Theming. You can find Theming documentation -[here](./lib/theming/README.md#bidirectionality) diff --git a/modules/_labs/core/react/index.ts b/modules/_labs/core/react/index.ts index a98d35e97e..242d2142a6 100644 --- a/modules/_labs/core/react/index.ts +++ b/modules/_labs/core/react/index.ts @@ -1,8 +1,6 @@ import type from './lib/type'; import space from './lib/space'; -import CanvasProvider from './lib/CanvasProvider'; export default type; -export {type, space, CanvasProvider}; +export {type, space}; export * from './lib/type'; export * from './lib/StaticStates'; -export * from './lib/theming'; diff --git a/modules/_labs/core/react/lib/StaticStates.tsx b/modules/_labs/core/react/lib/StaticStates.tsx index edd6482aaf..582da497e5 100644 --- a/modules/_labs/core/react/lib/StaticStates.tsx +++ b/modules/_labs/core/react/lib/StaticStates.tsx @@ -1,10 +1,8 @@ import * as React from 'react'; -import {useTheme} from './theming/useTheme'; -import CanvasProvider from './CanvasProvider'; -import {CanvasTheme} from './theming'; +import {useTheme, CanvasProvider, EmotionCanvasTheme} from '@workday/canvas-kit-react-common'; export const StaticStates: React.FC = ({children}) => { - const theme: CanvasTheme & {_staticStates?: boolean} = useTheme(); + const theme: EmotionCanvasTheme & {_staticStates?: boolean} = useTheme(); theme._staticStates = true; return {children}; diff --git a/modules/_labs/core/react/lib/theming/createCanvasTheme.ts b/modules/_labs/core/react/lib/theming/createCanvasTheme.ts deleted file mode 100644 index 3e5fcb1f66..0000000000 --- a/modules/_labs/core/react/lib/theming/createCanvasTheme.ts +++ /dev/null @@ -1,99 +0,0 @@ -import chroma from 'chroma-js'; -import merge from 'lodash/merge'; -import colors from '@workday/canvas-colors-web'; -import {defaultCanvasTheme} from './theme'; -import { - CanvasTheme, - PartialCanvasTheme, - CanvasThemePalette, - PartialCanvasThemePalette, - ContentDirection, -} from './types'; -import {CanvasColor} from '@workday/canvas-kit-react-core'; - -const {gradients, primary, ...allColors} = colors; - -enum ColorDirection { - Darken, - Brighten, -} - -function shiftColor(hexColor: string, direction: ColorDirection) { - const canvasColor = Object.keys(allColors).find( - key => allColors[key as CanvasColor] === hexColor - ); - - const darken = direction === ColorDirection.Darken; - - if (canvasColor) { - const colorRegex = /([a-zAz]*)(\d{3})/g; - const match = colorRegex.exec(canvasColor); - - if (match) { - const baseColor = match[1]; - const shadeNumber = parseInt(match[2], 10); - - const newShade = darken ? shadeNumber + 100 : shadeNumber - 100; - - if (newShade >= 100 && newShade <= 600) { - return colors[(baseColor + newShade) as CanvasColor]; - } - } - } - - try { - const newColor = darken ? chroma(hexColor).darken() : chroma(hexColor).brighten(); - return newColor.hex(); - } catch (e) { - console.warn(`Invalid color '${hexColor}' used in theme`); - return hexColor; - } -} - -function fillPalette(palette?: PartialCanvasThemePalette): CanvasThemePalette | {} { - if (!palette) { - return {}; - } - const shades = {...palette}; - - if (!shades.main) { - console.warn( - 'The color provided to fillPalette(palette) is invalid. The palette object needs to have a `main` property' - ); - return {}; - } - - const dark = shades.dark || shiftColor(shades.main, ColorDirection.Darken); - const darkest = shades.darkest || shiftColor(dark, ColorDirection.Darken); - const light = shades.light || shiftColor(shades.main, ColorDirection.Brighten); - const lightest = shades.lightest || shiftColor(light, ColorDirection.Brighten); - - return { - lightest, - light, - main: shades.main, - dark, - darkest, - contrast: shades.contrast || colors.frenchVanilla100, - }; -} - -export function createCanvasTheme(partialTheme: PartialCanvasTheme): CanvasTheme { - const {palette = {}, breakpoints = {}, direction} = partialTheme; - const {primary, alert, error, success, neutral, common = {}} = palette!; - - const mergeable: PartialCanvasTheme = { - palette: { - common, - primary: fillPalette(primary), - alert: fillPalette(alert), - error: fillPalette(error), - success: fillPalette(success), - neutral: fillPalette(neutral), - }, - breakpoints, - direction: direction === ContentDirection.RTL ? direction : ContentDirection.LTR, - }; - - return merge({}, defaultCanvasTheme, mergeable) as CanvasTheme; -} diff --git a/modules/_labs/core/react/lib/theming/useTheme.ts b/modules/_labs/core/react/lib/theming/useTheme.ts deleted file mode 100644 index 644c9aca7e..0000000000 --- a/modules/_labs/core/react/lib/theming/useTheme.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; -import get from 'lodash/get'; -import {ThemeContext} from '@emotion/core'; -import {CanvasTheme} from './types'; -import {defaultCanvasTheme} from './theme'; - -/** - * Hook function to get the correct theme object. - * @param {Object=} theme - The theme object returned from the emotion ThemeContext - * (through ThemeProvider). - * NOTE: If you are using a class component, you MUST pass the theme. - * If not passed, the function will try to pull the theme from ThemeContext. - * If that does not work, it will try to retrieve it from the window object. - * As a last resort, it will return the default Canvas theme. - * - * Providing the default theme here is currently a work around for when no - * ThemeProvider or context exists. - * Tracked on https://github.com/emotion-js/emotion/issues/1193. - */ -export function useTheme(theme?: Object): CanvasTheme { - if (theme && Object.keys(theme).length !== 0) { - return theme as CanvasTheme; - } - - try { - const context = React.useContext(ThemeContext); - if (context && Object.keys(context).length !== 0) { - return context as CanvasTheme; - } - } catch (e) { - // Context not supported or invalid (probably called from within a class component) - } - - const windowTheme = get(window, 'window.workday.canvas.theme'); - if (windowTheme) { - return windowTheme; - } - - return defaultCanvasTheme; -} diff --git a/modules/_labs/core/react/package.json b/modules/_labs/core/react/package.json index 22fb3da285..d6516ffa8c 100644 --- a/modules/_labs/core/react/package.json +++ b/modules/_labs/core/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-core", - "version": "3.9.0", + "version": "4.0.0", "description": "A group of core primitives (i.e. colors, text, etc.)", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,15 +15,24 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -40,14 +49,8 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-colors-web": "^0.17.13", - "@workday/canvas-kit-react-core": "^3.9.0", - "chroma-js": "^2.1.0", - "emotion-theming": "^10.0.10", - "lodash": "^4.17.14", - "rtl-css-js": "^1.13.1" - }, - "devDependencies": { - "@workday/canvas-system-icons-web": "^1.0.20" + "@workday/canvas-colors-web": "^2.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0" } } diff --git a/modules/_labs/core/react/spec/createCanvasTheme.spec.tsx b/modules/_labs/core/react/spec/createCanvasTheme.spec.tsx deleted file mode 100644 index fca8cb1c4f..0000000000 --- a/modules/_labs/core/react/spec/createCanvasTheme.spec.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import {defaultCanvasTheme, createCanvasTheme} from '../lib/theming'; -import lodash from 'lodash'; - -describe('useTheme', () => { - test('calling without any input provides the default theme', () => { - const theme = createCanvasTheme({}); - - expect(theme).toEqual(defaultCanvasTheme); - }); - - test('calling with a custom palette should replace that palette', () => { - const palette = { - lightest: 'orange', - light: 'orange', - main: 'orange', - dark: 'orange', - darkest: 'orange', - contrast: 'orange', - }; - - const input = { - palette: { - primary: palette, - }, - }; - const theme = createCanvasTheme(input); - const expected = defaultCanvasTheme; - expected.palette.primary = palette; - - expect(theme).toEqual(expected); - }); - - test('calling with a custom palette with only one color should replace that palette with an auto-generated one', () => { - const input = { - palette: { - primary: { - main: 'orange', - }, - }, - }; - const theme = createCanvasTheme(input); - const expected = defaultCanvasTheme; - expected.palette.primary = { - lightest: '#ffff7d', - light: '#ffd64a', - main: 'orange', - dark: '#c67600', - darkest: '#904a00', - contrast: '#ffffff', - }; - - expect(theme).toEqual(expected); - }); - - test('custom theme should not override defaultCanvasTheme when merged', () => { - const input = { - palette: { - primary: { - main: 'orange', - }, - }, - }; - const original = lodash.cloneDeep(defaultCanvasTheme); - createCanvasTheme(input); - - expect(original).toEqual(defaultCanvasTheme); - }); -}); diff --git a/modules/_labs/drawer/react/lib/Drawer.tsx b/modules/_labs/drawer/react/lib/Drawer.tsx index 7b9162efc5..8fbc615326 100644 --- a/modules/_labs/drawer/react/lib/Drawer.tsx +++ b/modules/_labs/drawer/react/lib/Drawer.tsx @@ -89,21 +89,15 @@ const ChildrenContainer = styled('div')>( export default class Drawer extends React.Component { static OpenDirection = DrawerDirection; - static defaultProps = { - openDirection: DrawerDirection.Right, - padding: spacing.s, - width: 360, - showDropShadow: false, - }; public render() { const { + padding = spacing.s, + width = 360, + openDirection = DrawerDirection.Right, + showDropShadow = false, children, - padding, - width, - openDirection, header, - showDropShadow, role, ...elemProps } = this.props; diff --git a/modules/_labs/drawer/react/lib/DrawerHeader.tsx b/modules/_labs/drawer/react/lib/DrawerHeader.tsx index e2c5f98e37..fc11386c35 100644 --- a/modules/_labs/drawer/react/lib/DrawerHeader.tsx +++ b/modules/_labs/drawer/react/lib/DrawerHeader.tsx @@ -17,15 +17,15 @@ export interface DrawerHeaderProps extends React.HTMLAttributes * The `aria-label` for the DrawHeader close button. Useful for i18n. * @default Close */ - closeIconLabel?: string; + closeIconAriaLabel?: string; /** * The background color of the DrawerHeader. */ - headerColor: CanvasColor | string; + headerColor?: CanvasColor | string; /** * The border color of the DrawerHeader. This should match something close to `headerColor`. */ - borderColor: CanvasColor | string; + borderColor?: CanvasColor | string; /** * If true, render the icon and header in white. Useful for preserving contrast with a dark `headerColor`. * @default false @@ -72,21 +72,14 @@ const CloseButton = styled(IconButton)({ }); export default class DrawerHeader extends React.Component { - static defaultProps = { - closeIconLabel: 'Close', - headerColor: colors.soap100, - borderColor: colors.soap500, - inverse: false, - }; - public render() { const { + closeIconAriaLabel = 'Close', + headerColor = colors.soap100, + borderColor = colors.soap500, + inverse = false, onClose, title, - closeIconLabel, - headerColor, - borderColor, - inverse, id, ...elemProps } = this.props; @@ -96,11 +89,11 @@ export default class DrawerHeader extends React.Component {title} - {onClose && closeIconLabel && ( + {onClose && closeIconAriaLabel && ( )} diff --git a/modules/_labs/drawer/react/package.json b/modules/_labs/drawer/react/package.json index 7b3d1d5e2b..7619f15160 100644 --- a/modules/_labs/drawer/react/package.json +++ b/modules/_labs/drawer/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-drawer", - "version": "3.9.0", + "version": "4.0.0", "description": "A Drawer component that allows for custom content to be added", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,15 +15,24 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -37,14 +46,14 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20" }, "peerDependencies": { "react": ">= 16.8 < 17" }, "devDependencies": { - "@workday/canvas-kit-labs-react-core": "^3.9.0" + "@workday/canvas-kit-labs-react-core": "^4.0.0" } } diff --git a/modules/_labs/drawer/react/stories/stories.tsx b/modules/_labs/drawer/react/stories/stories.tsx index 8f76aa4367..ee287d830a 100644 --- a/modules/_labs/drawer/react/stories/stories.tsx +++ b/modules/_labs/drawer/react/stories/stories.tsx @@ -64,7 +64,7 @@ storiesOf('Labs|Drawer/React', module) @@ -94,7 +94,7 @@ storiesOf('Labs|Drawer/React', module) - { alert('clicked avatar'); }} @@ -169,7 +169,7 @@ Default: `DubLogoTitle` (for "Dub" variants) or `WorkdayLogoTitle` (for "Full" v --- -#### `onMenuClick: (React.SyntheticEvent) => void` +#### `onMenuClick: (React.MouseEvent) => void` > A click handler for when the user clicks the mobile collapsed nav icon. @@ -190,14 +190,14 @@ The Global Header (or App Header) is used for Workday applications. ## Usage ```tsx -import {AvatarButton} from '@workday/canvas-kit-react-avatar'; +import {Avatar} from '@workday/canvas-kit-react-avatar'; import {GlobalHeader, DubLogoTitle} from '@workday/canvas-kit-labs-react-header'; import {Avatar} from '@workday/canvas-kit-react-avatar'; import {IconButton} from '@workday/canvas-kit-react-button'; import {notificationsIcon, inboxIcon} from '@workday/canvas-system-icons-web'; const HeaderBrand = () => -const HeaderAvatar = () => +const HeaderAvatar = () => const handleSearchSubmit = event => { const query = (event.target as HTMLFormElement).getElementsByTagName('input')[0].value; console.log("Submitted query: ", query) @@ -205,9 +205,9 @@ const handleSearchSubmit = event => { const openMenu = e => console.log("Menu opened") /** - * In this instance, the right-most child will be an AvatarButton component, when the GlobalHeader + * In this instance, the right-most child will be an Avatar component, when the GlobalHeader * shrinks below the specified breakpoint (720 in this case), the children get replaced by a menuToggle. - * For most GlobalHeader implementations, the menuToggle is also the AvatarButton component. + * For most GlobalHeader implementations, the menuToggle is also the Avatar component. */ } @@ -254,7 +254,7 @@ Default: `` Default: `justifyIcon` from `@workday/canvas-system-icons-web` -#### `onMenuClick: (React.SyntheticEvent) => void` +#### `onMenuClick: (React.MouseEvent) => void` > A click handler for when the user clicks the `menuToggle` element. diff --git a/modules/_labs/header/react/lib/GlobalHeader.tsx b/modules/_labs/header/react/lib/GlobalHeader.tsx index 3922adfdd4..63abbe4542 100644 --- a/modules/_labs/header/react/lib/GlobalHeader.tsx +++ b/modules/_labs/header/react/lib/GlobalHeader.tsx @@ -8,7 +8,7 @@ export interface GlobalHeaderProps { * The custom brand node of the GlobalHeader. This React node replaces the dub logo and title. * @default DubLogoTitle */ - brand: React.ReactNode; + brand?: React.ReactNode; /** * The custom menu toggle node of the GlobalHeader. This React node replaces the default menu toggle. */ @@ -16,7 +16,7 @@ export interface GlobalHeaderProps { /** * The function called when the responsive menu icon is clicked. */ - onMenuClick?: (e: React.SyntheticEvent) => void; + onMenuClick?: (e: React.MouseEvent) => void; /** * If true, render the GlobalHeader in collapsed mode. * @default false @@ -29,12 +29,9 @@ export interface GlobalHeaderProps { } export default class GlobalHeader extends React.Component { - static defaultProps = { - brand: , - }; public render() { const { - brand, + brand = , menuToggle, onMenuClick, isCollapsed, diff --git a/modules/_labs/header/react/lib/Header.tsx b/modules/_labs/header/react/lib/Header.tsx index 91a71b1694..e5c0db3837 100644 --- a/modules/_labs/header/react/lib/Header.tsx +++ b/modules/_labs/header/react/lib/Header.tsx @@ -8,6 +8,7 @@ import {HeaderHeight, HeaderTheme, HeaderVariant} from './shared/types'; import {IconButton, IconButtonProps} from '@workday/canvas-kit-react-button'; import {SystemIcon, SystemIconProps} from '@workday/canvas-kit-react-icon'; import {justifyIcon} from '@workday/canvas-system-icons-web'; +import {PickRequired} from '@workday/canvas-kit-react-common'; export interface HeaderProps extends React.HTMLAttributes { /** @@ -18,12 +19,12 @@ export interface HeaderProps extends React.HTMLAttributes { * The theme of the Header. Accepts `White`, `Blue`, or `Transparent`. * @default HeaderTheme.White */ - themeColor: HeaderTheme; + themeColor?: HeaderTheme; /** * The variant of the Header. Accepts `Dub` (small) or `Full` (large). * @default HeaderVariant.Dub */ - variant: HeaderVariant; + variant?: HeaderVariant; /** * The text of the Header title. Not used if `brand` is provided. */ @@ -44,7 +45,7 @@ export interface HeaderProps extends React.HTMLAttributes { /** * The function called when the responsive menu icon is clicked. */ - onMenuClick?: (e: React.SyntheticEvent) => void; + onMenuClick?: (e: React.MouseEvent) => void; /** * The React element to render in the left slot of the Header. This is typically a SearchBar component. */ @@ -58,7 +59,7 @@ export interface HeaderProps extends React.HTMLAttributes { const childrenSpacing = spacing.s; -const HeaderShell = styled('div')>( +const HeaderShell = styled('div')>( { display: 'flex', alignItems: 'center', @@ -98,7 +99,7 @@ const BrandLink = styled('a')({ }, }); -const navStyle = ({themeColor}: Pick) => { +const navStyle = ({themeColor}: PickRequired) => { const theme = themes[themeColor]; return css({ @@ -179,7 +180,9 @@ const navStyle = ({themeColor}: Pick) => { }); }; -const ChildrenSlot = styled('div')>( +const ChildrenSlot = styled('div')< + PickRequired +>( { marginRight: spacing.m, // TODO: remove this when we get real icon buttons @@ -194,7 +197,7 @@ const ChildrenSlot = styled('div') ({ + ({centeredNav, isCollapsed}) => ({ '> *:not(.canvas-header--menu-icon)': { display: isCollapsed ? 'none' : 'flex', }, @@ -210,7 +213,7 @@ class Brand extends React.Component< Pick > { render() { - const {variant, brand, themeColor, title} = this.props; + const {variant = HeaderVariant.Dub, brand, themeColor = HeaderTheme.White, title} = this.props; switch (variant) { case HeaderVariant.Global: { @@ -239,7 +242,7 @@ class MenuIconButton extends React.Component< Pick > { render() { - const {themeColor, menuToggle, onMenuClick} = this.props; + const {themeColor = HeaderTheme.White, menuToggle, onMenuClick} = this.props; if (menuToggle) { const menuToggleElement = menuToggle as React.ReactElement; const onClick = menuToggleElement.props.onClick @@ -269,10 +272,6 @@ class MenuIconButton extends React.Component< export default class Header extends React.Component { static Theme = HeaderTheme; static Variant = HeaderVariant; - static defaultProps = { - themeColor: HeaderTheme.White, - variant: HeaderVariant.Dub, - }; /** * Helper that recursively maps ReactNodes to their theme-based equivalent. @@ -350,8 +349,8 @@ export default class Header extends React.Component { render() { const { menuToggle, - themeColor, - variant, + themeColor = HeaderTheme.White, + variant = HeaderVariant.Dub, centeredNav, title, brand, diff --git a/modules/_labs/header/react/lib/parts/DubLogoTitle.tsx b/modules/_labs/header/react/lib/parts/DubLogoTitle.tsx index cb5d433b60..7038d75086 100644 --- a/modules/_labs/header/react/lib/parts/DubLogoTitle.tsx +++ b/modules/_labs/header/react/lib/parts/DubLogoTitle.tsx @@ -10,7 +10,7 @@ export type DubTitleProps = { * The theme of the DubLogoTitle. Accepts `White`, `Blue`, or `Transparent`. * @default HeaderTheme.White */ - themeColor: HeaderTheme; + themeColor?: HeaderTheme; /** * The text of the DubLogoTitle. Not used if `brand` is provided. */ @@ -63,21 +63,18 @@ const DubLogo = styled('div')({ }); export class DubLogoTitle extends React.Component { - static defaultProps = { - themeColor: HeaderTheme.White, - }; - render() { + const {themeColor = HeaderTheme.White, title} = this.props; return ( - {this.props.title && {this.props.title}} + {title && {title}} ); diff --git a/modules/_labs/header/react/lib/parts/SearchBar.tsx b/modules/_labs/header/react/lib/parts/SearchBar.tsx index c23f8ab83a..2140a62777 100644 --- a/modules/_labs/header/react/lib/parts/SearchBar.tsx +++ b/modules/_labs/header/react/lib/parts/SearchBar.tsx @@ -59,7 +59,7 @@ export interface SearchBarProps extends GrowthBehavior, React.FormHTMLAttributes * The screenreader label text for the SearchBar submit button. * @default Search */ - submitLabel: string; + submitAriaLabel?: string; /** * The screenreader label text for the SearchBar clear button. * @default Reset Search Form @@ -69,12 +69,12 @@ export interface SearchBarProps extends GrowthBehavior, React.FormHTMLAttributes * The screenreader label text for the button to open the collapsed SearchBar. * @default Open Search */ - openButtonLabel: string; + openButtonAriaLabel?: string; /** * The screenreader label text for the button to close the open SearchBar. * @default Cancel */ - closeButtonLabel: string; + closeButtonAriaLabel?: string; /** * If true, render the SearchBar with a button to clear the text input. * @default true @@ -272,14 +272,6 @@ const SearchInput = styled(TextInput)< export class SearchBar extends React.Component { static Theme = SearchTheme; - static defaultProps = { - placeholder: 'Search', - inputLabel: 'Search', - submitLabel: 'Search', - openButtonLabel: 'Open Search', - closeButtonLabel: 'Cancel', - showClearButton: true, - }; private inputRef = React.createRef(); private openRef = React.createRef(); @@ -344,7 +336,11 @@ export class SearchBar extends React.Component { componentDidUpdate(prevProps: SearchBarProps, prevState: SearchBarState) { const showFormToggled = this.state.showForm !== prevState.showForm; if (showFormToggled) { - this.state.showForm ? this.focusInput() : this.focusOpen(); + if (this.state.showForm) { + this.focusInput(); + } else { + this.focusOpen(); + } } } @@ -378,6 +374,13 @@ export class SearchBar extends React.Component { render() { const { + clearButtonAriaLabel = 'Reset Search Form', + placeholder = 'Search', + inputLabel = 'Search', + submitAriaLabel = 'Search', + openButtonAriaLabel = 'Open Search', + closeButtonAriaLabel = 'Cancel', + showClearButton = true, grow, onSubmit, isCollapsed, @@ -385,14 +388,7 @@ export class SearchBar extends React.Component { autocompleteItems, initialValue, searchTheme, - placeholder, rightAlign, - inputLabel, - submitLabel, - showClearButton, - clearButtonAriaLabel, - closeButtonLabel, - openButtonLabel, ...elemProps } = this.props; @@ -410,7 +406,7 @@ export class SearchBar extends React.Component { > { isHidden={!!isCollapsed && !this.state.showForm} /> { onFocus={this.handleFocus} onBlur={this.handleBlur} showClearButton={!isCollapsed && showClearButton} - clearButtonAriaLabel={clearButtonAriaLabel || 'Reset Search Form'} + clearButtonAriaLabel={clearButtonAriaLabel} labelId={this.labelId} > { ({ }); export class WorkdayLogoTitle extends React.Component { - static defaultProps = { - themeColor: HeaderTheme.White, - title: '', - }; - public render() { - const {themeColor, title, variant, ...elemProps} = this.props; + const {themeColor = HeaderTheme.White, title = '', variant, ...elemProps} = this.props; return ( @@ -81,14 +76,14 @@ export class WorkdayLogoTitle extends React.Component { {...this.props} dangerouslySetInnerHTML={{ __html: - this.props.themeColor === HeaderTheme.White - ? this.props.variant === HeaderVariant.Global + themeColor === HeaderTheme.White + ? variant === HeaderVariant.Global ? miniWdayLogoBlue : wdayLogoBlue : wdayLogoWhite, }} /> - {this.props.title && {this.props.title}} + {title && {title}} ); diff --git a/modules/_labs/header/react/lib/shared/themes.tsx b/modules/_labs/header/react/lib/shared/themes.tsx index d6c8c24ebd..c32f387e67 100644 --- a/modules/_labs/header/react/lib/shared/themes.tsx +++ b/modules/_labs/header/react/lib/shared/themes.tsx @@ -1,5 +1,5 @@ import {focusRing} from '@workday/canvas-kit-react-common'; -import {colors, iconColors, depth, CSSProperties} from '@workday/canvas-kit-react-core'; +import {colors, gradients, iconColors, depth, CSSProperties} from '@workday/canvas-kit-react-core'; import chroma from 'chroma-js'; import {HeaderTheme, SearchTheme} from './types'; @@ -37,7 +37,7 @@ export const themes: Themes = { }, [HeaderTheme.Blue]: { color: colors.frenchVanilla100, - background: colors.gradients.blueberry, + background: gradients.blueberry, depth: depth['3'], systemIcon: { color: colors.frenchVanilla100, diff --git a/modules/_labs/header/react/package.json b/modules/_labs/header/react/package.json index 3edda77a64..0152c7e39c 100644 --- a/modules/_labs/header/react/package.json +++ b/modules/_labs/header/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-header", - "version": "3.9.0", + "version": "4.0.0", "description": "A Canvas-styled application header", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,16 +15,25 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -41,14 +50,14 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-labs-react-combobox": "^3.9.0", - "@workday/canvas-kit-labs-react-menu": "^3.9.0", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "@workday/canvas-kit-react-form-field": "^3.9.0", - "@workday/canvas-kit-react-icon": "^3.9.0", - "@workday/canvas-kit-react-text-input": "^3.9.0", + "@workday/canvas-kit-labs-react-combobox": "^4.0.0", + "@workday/canvas-kit-labs-react-menu": "^4.0.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-kit-react-form-field": "^4.0.0", + "@workday/canvas-kit-react-icon": "^4.0.0", + "@workday/canvas-kit-react-text-input": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20", "chroma-js": "^2.1.0", "uuid": "^3.3.3" diff --git a/modules/_labs/header/react/spec/GlobalHeader.spec.tsx b/modules/_labs/header/react/spec/GlobalHeader.spec.tsx index 8e1ac376d4..b5283a7ecf 100644 --- a/modules/_labs/header/react/spec/GlobalHeader.spec.tsx +++ b/modules/_labs/header/react/spec/GlobalHeader.spec.tsx @@ -87,12 +87,12 @@ describe('GlobalHeader', () => { const propsHeader2 = { menuToggle: 'abcde', isCollapsed: false, + themeColor: HeaderTheme.White, }; const defaultProps = { brand: , variant: HeaderVariant.Global, children: undefined, - themeColor: HeaderTheme.White, }; const childPropsHeader1 = shallow() diff --git a/modules/_labs/header/react/spec/SearchBar.spec.tsx b/modules/_labs/header/react/spec/SearchBar.spec.tsx index a9adec0734..93aab728bb 100644 --- a/modules/_labs/header/react/spec/SearchBar.spec.tsx +++ b/modules/_labs/header/react/spec/SearchBar.spec.tsx @@ -34,7 +34,7 @@ describe('Header Search', () => { test('Searching with icon should call callback', async () => { const label = `submitLabel`; const {findByLabelText} = render( - + ); fireEvent.click(await findByLabelText(label)); @@ -83,8 +83,8 @@ describe('Header Search', () => { ); diff --git a/modules/_labs/header/react/stories/stories.tsx b/modules/_labs/header/react/stories/stories.tsx index a98aca9f74..8152299af6 100644 --- a/modules/_labs/header/react/stories/stories.tsx +++ b/modules/_labs/header/react/stories/stories.tsx @@ -10,8 +10,8 @@ import chroma from 'chroma-js'; import {notificationsIcon, inboxIcon} from '@workday/canvas-system-icons-web'; -import {AvatarButton} from '../../../../avatar/react'; -import {colors, spacing} from '../../../../core/react'; +import {Avatar} from '../../../../avatar/react'; +import {colors, spacing, gradients} from '../../../../core/react'; import {Button, IconButton} from '../../../../button/react'; import {MenuItem} from '../../../menu/react'; import { @@ -47,15 +47,15 @@ const backgroundStyle = { // Simulate a React Router link const Link = styled('a')<{to: string}>({}); -const handleMenuClickTest = (e: React.SyntheticEvent) => { +const handleMenuClickTest = (e: React.MouseEvent) => { action(`Menu clicked! ${e.target}`)(); }; -const handleAvatarClickTest = (e: React.SyntheticEvent) => { +const handleAvatarClickTest = (e: React.MouseEvent) => { action(`Avatar clicked! ${e.target}`); }; -const handleSearchSubmitTest = (e: React.SyntheticEvent) => { +const handleSearchSubmitTest = (e: React.MouseEvent) => { const formInputValue = (e.target as HTMLFormElement).getElementsByTagName('input')[0].value; action(`search submitted ${formInputValue}`)(); }; @@ -137,7 +137,7 @@ storiesOf('Labs|Header/React', module) } menuToggle={ - @@ -163,7 +163,7 @@ storiesOf('Labs|Header/React', module) title="Inbox" aria-label="Inbox" /> - } - menuToggle={} + menuToggle={} leftSlot={ - +
@@ -220,7 +220,7 @@ storiesOf('Labs|Header/React', module) title="Inbox" aria-label="Inbox" /> - +
@@ -265,7 +265,7 @@ storiesOf('Labs|Header/React', module) title="Inbox" aria-label="Inbox" /> - +
@@ -286,7 +286,7 @@ storiesOf('Labs|Header/React', module) title="Notifications" aria-label="Notifications" /> - + @@ -300,7 +300,7 @@ storiesOf('Labs|Header/React', module) } brandUrl="#" @@ -456,7 +456,7 @@ storiesOf('Labs|Header/React', module) title="Notifications" aria-label="Notifications" /> - + @@ -476,7 +476,7 @@ storiesOf('Labs|Header/React', module) title="Notifications" aria-label="Notifications" /> - +
@@ -495,7 +495,7 @@ storiesOf('Labs|Header/React', module) title="Notifications" aria-label="Notifications" /> - +
diff --git a/modules/_labs/menu/react/README.md b/modules/_labs/menu/react/README.md index 758484ea80..03bf6ced19 100644 --- a/modules/_labs/menu/react/README.md +++ b/modules/_labs/menu/react/README.md @@ -172,7 +172,7 @@ Default: `false` ### Optional -#### `onClick: (event: React.SyntheticEvent) => void` +#### `onClick: (event: React.MouseEvent) => void` > This is the primary action to take when a menu item is clicked. If the item is a child of the Menu > component, this callback will be decorated with the onSelect and onClose Menu callbacks. This diff --git a/modules/_labs/menu/react/lib/Menu.tsx b/modules/_labs/menu/react/lib/Menu.tsx index c57c102f2c..c8793c39dc 100644 --- a/modules/_labs/menu/react/lib/Menu.tsx +++ b/modules/_labs/menu/react/lib/Menu.tsx @@ -39,7 +39,7 @@ export interface MenuProps extends GrowthBehavior, React.HTMLAttributes { - static defaultProps = { - isOpen: true, - }; - private id = uuid(); private menuRef: React.RefObject; @@ -106,10 +102,10 @@ export default class Menu extends React.Component { public render() { // TODO: Standardize on prop spread location (see #150) const { - children, id = this.id, - isOpen, - labeledBy, + isOpen = true, + children, + 'aria-labelledby': ariaLabelledby, grow, width, onSelect, @@ -130,7 +126,7 @@ export default class Menu extends React.Component { role="menu" tabIndex={0} id={id} - aria-labelledby={labeledBy} + aria-labelledby={ariaLabelledby} aria-activedescendant={`${id}-${selectedItemIndex}`} onKeyDown={this.handleKeyboardShortcuts} ref={this.menuRef} @@ -245,14 +241,17 @@ export default class Menu extends React.Component { } }; - private handleClick = (event: React.SyntheticEvent, menuItemProps: MenuItemProps): void => { + private handleClick = ( + event: React.MouseEvent | React.KeyboardEvent, + menuItemProps: MenuItemProps + ): void => { /* istanbul ignore next line for coverage */ if (menuItemProps.isDisabled) { // You should only hit this point if you are using a custom MenuItem implementation. return; } if (menuItemProps.onClick) { - menuItemProps.onClick(event); + menuItemProps.onClick(event as React.MouseEvent); } if (this.props.onSelect) { this.props.onSelect(); @@ -280,7 +279,7 @@ export default class Menu extends React.Component { private setFirstCharacters = (): void => { const getFirstCharacter = (child: React.ReactNode): string => { let character = ''; - if (child == null || typeof child === 'boolean' || child === {}) { + if (!child || typeof child === 'boolean' || child === {}) { character = ''; } else if (typeof child === 'string' || typeof child === 'number') { character = child diff --git a/modules/_labs/menu/react/lib/MenuItem.tsx b/modules/_labs/menu/react/lib/MenuItem.tsx index ca4fc4ab5b..97159189ef 100644 --- a/modules/_labs/menu/react/lib/MenuItem.tsx +++ b/modules/_labs/menu/react/lib/MenuItem.tsx @@ -15,7 +15,7 @@ export interface MenuItemProps extends React.LiHTMLAttributes { /** * The function called when the MenuItem is clicked. If the item is a child of the Menu component, this callback will be decorated with the onSelect and onClose Menu callbacks. This callback will not fire if the item is disabled (see below). */ - onClick?: (event: React.SyntheticEvent) => void; + onClick?: (event: React.MouseEvent) => void; /** * The unique id for the MenuItem used for ARIA attributes. If the item is a child of the `Menu` component, this property will be generated and overridden. */ @@ -47,7 +47,7 @@ export interface MenuItemProps extends React.LiHTMLAttributes { * The role of the MenuItem. Use this to override the role of the item (e.g. you can use this element as an option in a Combobox). * @default menuItem */ - role: string; + role?: string; /** * If true, allow the onClose Menu callback to be fired after the MenuItem has been clicked. * @default true @@ -176,11 +176,15 @@ const setIconProps = ( }; export default class MenuItem extends React.Component { + /** + * If we destructure props, shouldClose will be undefined because the value is only applied for the render method only. + * We have to use defaultProps so that the value of shouldClose is applied for every method and therefore references in the Menu component. + * For reference: https://github.com/Workday/canvas-kit/blob/f6d4d29e9bb2eb2af0b204e6f4ce2e5ed5a98e57/modules/_labs/menu/react/lib/Menu.tsx#L259, + */ static defaultProps = { shouldClose: true, role: 'menuitem', }; - render(): React.ReactNode { const { onClick, @@ -221,7 +225,7 @@ export default class MenuItem extends React.Component { ); } - private handleClick = (event: React.SyntheticEvent): void => { + private handleClick = (event: React.MouseEvent): void => { if (this.props.isDisabled) { return; } diff --git a/modules/_labs/menu/react/package.json b/modules/_labs/menu/react/package.json index a31bbe4e80..9365765d1a 100644 --- a/modules/_labs/menu/react/package.json +++ b/modules/_labs/menu/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-menu", - "version": "3.9.0", + "version": "4.0.0", "description": "A container for navigation or action items", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,16 +15,25 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -36,18 +45,19 @@ "menu" ], "devDependencies": { - "@material-ui/core": "^3.9.2", + "@material-ui/core": "^4.9.7", + "@workday/canvas-kit-react-popup": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20" }, "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", "@types/uuid": "^3.4.4", - "@workday/canvas-kit-labs-react-core": "^3.9.0", - "@workday/canvas-kit-react-card": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "@workday/canvas-kit-react-icon": "^3.9.0", + "@workday/canvas-kit-labs-react-core": "^4.0.0", + "@workday/canvas-kit-react-card": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-kit-react-icon": "^4.0.0", "@workday/design-assets-types": "^0.2.4", "uuid": "^3.3.3" }, diff --git a/modules/_labs/menu/react/spec/Menu.spec.tsx b/modules/_labs/menu/react/spec/Menu.spec.tsx index f0b96c0808..cc2556a8f9 100644 --- a/modules/_labs/menu/react/spec/Menu.spec.tsx +++ b/modules/_labs/menu/react/spec/Menu.spec.tsx @@ -220,7 +220,7 @@ describe('Menu Accessibility', () => { test('labeledBy menu should have aria-labelledby set', () => { const label: string = 'myLabel'; - const component = mount(); + const component = mount(); expect( component .find('ul') @@ -472,6 +472,7 @@ describe('Menu Keyboard Shortcuts', () => { const space = {keyCode: 32, key: ' '}; item.simulate('keydown', enter); item.simulate('keydown', space); + expect(cb.mock.calls.length).toBe(2); component.unmount(); }); diff --git a/modules/_labs/menu/react/stories/stories.tsx b/modules/_labs/menu/react/stories/stories.tsx index 2380a980bf..256101cdef 100644 --- a/modules/_labs/menu/react/stories/stories.tsx +++ b/modules/_labs/menu/react/stories/stories.tsx @@ -5,7 +5,7 @@ import {action} from '@storybook/addon-actions'; import withReadme from 'storybook-readme/with-readme'; import uuid from 'uuid/v4'; import {setupIcon, uploadCloudIcon, userIcon, extLinkIcon} from '@workday/canvas-system-icons-web'; -import Popper from '@material-ui/core/Popper'; +import {Popper} from '@workday/canvas-kit-react-popup'; import ClickAwayListener from '@material-ui/core/ClickAwayListener'; import {Button, ButtonProps} from '../../../../button/react'; @@ -110,20 +110,14 @@ class ControlledMenu extends React.Component<{}, ControlledMenuState> { > Open Menu - +
{createMenuItems().map(buildItem)} @@ -133,7 +127,7 @@ class ControlledMenu extends React.Component<{}, ControlledMenuState> { ); } - private handleClick = (event: React.SyntheticEvent) => { + private handleClick = (event: React.MouseEvent) => { const {currentTarget} = event; this.setState({ anchorEl: currentTarget, @@ -186,13 +180,7 @@ class ContextMenu extends React.Component<{}, ControlledMenuState> {
Right click on this text.
- +
{ ); } - private handleContext = (event: React.SyntheticEvent) => { + private handleContext = (event: React.MouseEvent) => { const {currentTarget} = event; this.setState({ anchorEl: currentTarget, diff --git a/modules/_labs/pagination/react/lib/Pages.tsx b/modules/_labs/pagination/react/lib/Pages.tsx index 377c3e110d..e7ac877444 100644 --- a/modules/_labs/pagination/react/lib/Pages.tsx +++ b/modules/_labs/pagination/react/lib/Pages.tsx @@ -1,5 +1,6 @@ /** @jsx jsx */ import {css, jsx} from '@emotion/core'; +import styled from '@emotion/styled'; import range from 'lodash/range'; import React from 'react'; @@ -15,23 +16,24 @@ interface PagesProps { pageButtonAriaLabel: (page: number, selected: boolean) => string; } -const noPointerEvents = css({ +const ellipsisStyle = css({ pointerEvents: 'none', -}); - -const ellipsisStyle = css(noPointerEvents, { width: canvas.spacing.l, textAlign: 'center', display: 'inline-block', }); -const noTransitions = css({ - '&:not(:hover)': {transition: 'none !important'}, -}); - -const activeStyling = css(noPointerEvents, noTransitions, { - color: canvas.colors.frenchVanilla100, -}); +const PageButton = styled(IconButton)<{current: boolean}>( + { + width: 'auto', + ...type.small, + }, + ({current}) => ({ + color: current ? canvas.colors.frenchVanilla100 : undefined, + pointerEvents: current ? 'none' : undefined, + '&:not(:hover)': {transition: current ? 'none !important' : undefined}, + }) +); /** * Given some information about the page, return a tuple of left and right number @@ -70,7 +72,7 @@ export function getPages(total: number, current: number, isMobile: boolean): [nu const Pages = ({total, current, onPageClick, isMobile, pageButtonAriaLabel}: PagesProps) => { const pageToButton = (page: number) => ( - onPageClick(page)} toggled={page === current} - css={page === current ? activeStyling : noTransitions} + current={page === current} > {page} - + ); const [left, right] = getPages(total, current, isMobile); diff --git a/modules/_labs/pagination/react/lib/Pagination.tsx b/modules/_labs/pagination/react/lib/Pagination.tsx index c2bd270e86..93ef1d6e71 100644 --- a/modules/_labs/pagination/react/lib/Pagination.tsx +++ b/modules/_labs/pagination/react/lib/Pagination.tsx @@ -19,23 +19,31 @@ export interface PaginationProps extends React.HTMLAttributes { /** Dispatch which is invoked when the page is changed. */ onPageChange: (page: number) => void; /** Shows a box adjacent to the pagination bar where a page can be entered and is submitted when 'Enter' key is pressed. */ - showGoTo: boolean; + showGoTo?: boolean; /** Shows a label below the pagination bar describing the items currently being viewed. */ - showLabel: boolean; + showLabel?: boolean; /** A function to build a custom label below the pagination bar. */ - customLabel: (from: number, to: number, total: number) => string; + customLabel?: (from: number, to: number, total: number) => string; /** Determines the label next to the Go To box. Only usable while showGoTo is set to true. */ - goToLabel: string; + goToLabel?: string; /** Customizes the aria label for the Pagination container div. */ - paginationContainerAriaLabel: string; + paginationContainerAriaLabel?: string; /** Customizes the aria label for the Previous Page Arrow. */ - previousPageAriaLabel: string; + previousPageAriaLabel?: string; /** Customizes the aria label for the Next Page Arrow. */ - nextPageAriaLabel: string; + nextPageAriaLabel?: string; /** Customizes the aria-label on each page button. */ - pageButtonAriaLabel: (page: number, selected: boolean) => string; + pageButtonAriaLabel?: (page: number, selected: boolean) => string; /** Optional width to pass to component. This is the width the container deems is available. You can use a measure component to get this. */ width?: number; + /** + * Announces page changes to screen readers using aria-live + * Note: Your application may already announce page changes to screen readers through + * other means like focus changes or other aria-live regions. Set this to `false` to remove + * redundant announcement to screen reader users. + * @default true + */ + announceLabelToScreenReaders?: boolean; } const StyledLabel = styled('div')({ @@ -72,18 +80,19 @@ const defaultPageButtonAriaLabel: PaginationProps['pageButtonAriaLabel'] = (page const Pagination = (props: PaginationProps) => { const { + showGoTo = false, + showLabel = false, + goToLabel = 'Go To', + paginationContainerAriaLabel = 'Pagination', + previousPageAriaLabel = 'Previous Page', + nextPageAriaLabel = 'Next Page', + pageButtonAriaLabel = defaultPageButtonAriaLabel, + customLabel = defaultCustomLabel, + announceLabelToScreenReaders = true, total, pageSize, currentPage, onPageChange, - customLabel, - showLabel, - showGoTo, - goToLabel, - paginationContainerAriaLabel, - previousPageAriaLabel, - nextPageAriaLabel, - pageButtonAriaLabel, width, ...elemProps } = props; @@ -123,21 +132,17 @@ const Pagination = (props: PaginationProps) => { /> {showGoTo && } - {showLabel && {customLabel(labelFrom, labelTo, total)}} + {showLabel && ( + + {customLabel(labelFrom, labelTo, total)} + + )} ); }; -Pagination.defaultProps = { - showGoTo: false, - showLabel: false, - goToLabel: 'Go To', - paginationContainerAriaLabel: 'Pagination', - previousPageAriaLabel: 'Previous Page', - nextPageAriaLabel: 'Next Page', - pageButtonAriaLabel: defaultPageButtonAriaLabel, - customLabel: defaultCustomLabel, -}; - export default Pagination; diff --git a/modules/_labs/pagination/react/package.json b/modules/_labs/pagination/react/package.json index b44163e1f1..f48f66f75e 100644 --- a/modules/_labs/pagination/react/package.json +++ b/modules/_labs/pagination/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-labs-react-pagination", - "version": "3.9.0", + "version": "4.0.0", "description": "contains a component for a pagination bar and dispatches for page changes", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,15 +15,24 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -40,10 +49,10 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-labs-react-core": "^3.9.0", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "@workday/canvas-kit-react-text-input": "^3.9.0", + "@workday/canvas-kit-labs-react-core": "^4.0.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-kit-react-text-input": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20", "lodash": "^4.17.14", "uuid": "^3.3.3" diff --git a/modules/_labs/select/react/README.md b/modules/_labs/select/react/README.md index 65a5c3f7f8..f83b8a828f 100644 --- a/modules/_labs/select/react/README.md +++ b/modules/_labs/select/react/README.md @@ -9,6 +9,8 @@ A Canvas-styled Select with a Canvas-styled menu. This is a Undocumented props (`disabled`, `name`, etc.) should behave similarly to how you would expect from a standard `)} + + + + +
+ ); +}; + +export const PortalTest = () => ( +
+ +

+ All gray-bordered containers on this page have their overflow set to hidden. Menus are + rendered using portals and should break out of their containers. +

+ + {controlComponent()} + +
+ +

+ Menus should flip upwards automatically if there isn't enough space in the viewport for them + to extend downwards. As you scroll down and space becomes available, the Menu will flip back + downwards. +

+ + {controlComponent()} + +
+ +

Menus should behave the same with left-labeled FormFields.

+ + {controlComponent()} + +
+ +

Menus should break out of Modals.

+ +
+ +

This Select is forced to display its menu upwards since it's at the bottom the page.

+ + {controlComponent( +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +

Are you sure you'd like to delete the item titled 'My Item'?

+ + Delete + + +
+ + ); +}; + +export const StackedModals = () => { + const modal1 = useModal(); + const modal2 = useModal(); + + return ( + <> + Delete Item + +

Are you sure you'd like to delete the item titled 'My Item'?

+ + Yes, Delete + + + +

Are you sure you'd like to delete the item titled 'My Item'?

+ { + modal1.closeModal(); + modal2.closeModal(); + }} + > + Yes, Really Delete + + +
+
+ + ); +}; + +export const ModalWithPopup = () => { + const modal = useModal(); + const popup = usePopup(); + + useCloseOnOutsideClick(popup.popperProps.ref, popup.closePopup); + useCloseOnEscape(popup.popperProps.ref, popup.closePopup); + + return ( + <> + Delete Item + +

Are you sure you'd like to delete the item titled 'My Item'?

+ + Yes, Delete + + + + +

Are you sure you'd like to delete the item titled 'My Item'?

+ { + modal.closeModal(); + popup.closePopup(); + }} + > + Yes, Really Delete + + +
+
+
+ + ); +}; diff --git a/modules/page-header/css/package.json b/modules/page-header/css/package.json index 4fc3bbd550..5ee044e2bd 100644 --- a/modules/page-header/css/package.json +++ b/modules/page-header/css/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-css-page-header", - "version": "3.8.0", + "version": "4.0.0", "description": "The blue header at the top of a page to indicate page title and other details", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -16,7 +16,7 @@ "url": "http://github.com/Workday/canvas-kit/tree/master/modules/page-header/css" }, "dependencies": { - "@workday/canvas-kit-css-core": "^3.8.0" + "@workday/canvas-kit-css-core": "^4.0.0" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/modules/page-header/react/lib/PageHeader.tsx b/modules/page-header/react/lib/PageHeader.tsx index d970d21c0e..556b78940e 100644 --- a/modules/page-header/react/lib/PageHeader.tsx +++ b/modules/page-header/react/lib/PageHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import styled from '@emotion/styled'; -import {colors, spacing, type} from '@workday/canvas-kit-react-core'; +import {colors, gradients, spacing, type} from '@workday/canvas-kit-react-core'; import {IconButton, IconButtonProps} from '@workday/canvas-kit-react-button'; export interface PageHeaderProps { @@ -12,7 +12,7 @@ export interface PageHeaderProps { * If true, center the PageHeader content and make the PageHeader responsive in all three breakpoints. Enable this for PageHeaders in non-product pages. * @default false */ - capWidth: boolean; + capWidth?: boolean; /** * The breakpoint (in `px`) at which the PageHeader's container spacing increases from size `s` to size `xl`. * @default 768 @@ -22,7 +22,7 @@ export interface PageHeaderProps { const Header = styled('header')({ height: 84, - backgroundImage: colors.gradients.blueberry, + backgroundImage: gradients.blueberry, color: colors.frenchVanilla100, WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', @@ -69,12 +69,6 @@ const IconList = styled('div')({ }); export default class PageHeader extends React.Component { - static defaultProps = { - title: '', - capWidth: false, - breakpoint: 768, - }; - private renderChildren(children: React.ReactNode): React.ReactNode { return React.Children.map(children, child => { if (!React.isValidElement(child)) { @@ -93,7 +87,7 @@ export default class PageHeader extends React.Component { public render() { // TODO: Standardize on prop spread location (see #150) - const {title, children, breakpoint, capWidth, ...elemProps} = this.props; + const {title = '', breakpoint = 768, capWidth = false, children, ...elemProps} = this.props; return (
diff --git a/modules/page-header/react/package.json b/modules/page-header/react/package.json index 641b2387fd..69592468b1 100644 --- a/modules/page-header/react/package.json +++ b/modules/page-header/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-react-page-header", - "version": "3.9.0", + "version": "4.0.0", "description": "Canvas page header component", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,16 +15,25 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -41,8 +50,8 @@ "dependencies": { "@emotion/core": "^10.0.28", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0" + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0" }, "devDependencies": { "@workday/canvas-system-icons-web": "^1.0.20" diff --git a/modules/page-header/react/spec/PageHeader.spec.tsx b/modules/page-header/react/spec/PageHeader.spec.tsx index 4c6f9cd4ff..0748cb6a84 100644 --- a/modules/page-header/react/spec/PageHeader.spec.tsx +++ b/modules/page-header/react/spec/PageHeader.spec.tsx @@ -46,7 +46,7 @@ describe('Page Header', () => { }); test('PageHeader should spread extra props', () => { - const component = mount(); + const component = mount(); const container = component .find('header') .childAt(0) // TODO: Standardize on prop spread location (see #150) diff --git a/modules/popup-stack/LICENSE b/modules/popup-stack/LICENSE new file mode 100644 index 0000000000..594eb66af8 --- /dev/null +++ b/modules/popup-stack/LICENSE @@ -0,0 +1,52 @@ +Apache License, Version 2.0 Apache License Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: +You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +©2020. Workday, Inc. All rights reserved. Workday and the Workday logo are registered trademarks of Workday, Inc. All other brand and product names are trademarks or registered trademarks of their respective holders. + + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/modules/popup-stack/README.md b/modules/popup-stack/README.md new file mode 100644 index 0000000000..f876bb9896 --- /dev/null +++ b/modules/popup-stack/README.md @@ -0,0 +1,185 @@ +# Canvas Kit Popup Stack + +Stack for managing popup UIs to coordinate global concerns like escape key handling and rendering +order. The popup stack is a FILO stack that is framework agnostic and is shared for the entire page. +This sharing is enforced. If the window stack variable (`window.workday.__popupStack`) has already +been declared, the first-instantiated instance will be used. There is no need to worry about +bootstrapping at a specific time. + +## What is Stacked UI? + +A Stacked UI (typically classified as "popups") component generally isn't statically positioned (not +in the normal flow of the page) and sits on top of other content - usually occluding other content. +A stacked UI element typically redirects a users attention temporarily. For example, a Modal appears +on top of all other content while it is active. A tooltip appears on top of other content when the +target is focused or hovered. + +Stacked components include: + +- Tooltips +- Modal dialogs +- Select menu dropdown +- Toast messages +- Popups +- Snackbars +- Dropdown menus +- Windows + +Stacked UI components are usually unbounded by the physical boundaries of a target element through a +technique of "portalling" the Popup to the bottommost element of the `body` element. This allows a +Tooltip to appear outside any overflowed content. This technique does not work outside the confines +of an iframe, however. + +## Why do I need a stack manager? + +Simple UIs may define z-index values globally per component type, so why do we need to manage +popups? Popups usually have accessibility requirements that the popup should close if the escape key +is pressed. The naive way of doing this is listening for the escape key in all components. This +works if only one Popup is on the page at a time, but often UIs get more complicated. A simple +example is a Modal dialog with a Select component inside. The user opens the Modal dialog and then +opens a Select menu inside the Modal. The user may hit the escape key which should close the Select +dropdown menu, but not the Modal dialog. A more painful example is the same scenario, but with click +outside detection. The user wants to select an item within the Select dropdown menu, but the Modal +dialog detects clicking inside the dropdown menu as an outside click because the dropdown menu isn't +a direct child component of the Modal dialog because of the "portalling" technique commonly used. + +A stack manager keeps track of all Stacked UI registered on the page as they are opened and closed. +A special Window component may be persistent and should be brought to the top of the stack when +clicked on. This is how operating system "windows" work. The behavior requires global coordination +to determine the order that these popups should be rendered. + +## Installation + +```sh +yarn add @workday/canvas-kit-popup-stack +``` + +## Usage + +> **Note**: This is a low-level module meant to be used as a foundation. The +> `@workday/canvas-kit-react-popup` module has provided React hooks that wrap this API for easier +> use with React. + +```tsx +import PopupStack from '@workday/canvas-kit-popup-stack'; + +const div = document.createElement('div'); + +// Add to the stack. This will force-set z-index on the element for proper rendering +PopupStack.add({element: div}); + +// Remove from the stack. This will force-set z-index of other elements in the stack +PopupStack.remove(div); + +// Bring current element to the top of the stack. This will force the UI to render the element on top of others +PopupStack.bringToTop(div); + +// Will return true if the element is currently the topmost item on the stack +PopupStack.isTopmost(div); + +// Returns an array of HTMLElements (note: _not_ the list of stack items) +PopupStack.getElements(); + +// +PopupStack.contains(element, eventTarget); +``` + +## API + +### PopupStackItem + +```tsx +export interface PopupStackItem { + /** + * All items in the stack are identified by their DOM element reference. A DOM element is + * framework agnostic. + */ + element: HTMLElement; + /** + * An owner is typically a trigger or anchor target. For example, it will be a HTMLButtonElement + * that opened a dropdown menu. If an owner is provided, _and_ that owner element is part of + * another stack item, it will be considered a "parent" of the provided stack item. This reference + * helps in the following ways: + * - Click outside detection typically will use `PopupStack.contains()` which includes this + * element. If you wish to close a popup when the target is clicked, add a click handler to do + * so. + * - `PopupStack.bringToTop()` will also bring children to top as well using the `owner` reference + * to map a "child" popup back to its parent. This is useful for "Window" or other persistent + * popups that are brought to the front when clicked. This will prevent the "Window" from + * rendering on top of child popups as they will be brought along also. + * - Synthetic event systems like in React will bubble events through "portals". This is + * inconsistent with DOM event bubbling. This reference helps normalize that behavior across + * different frameworks. + */ + owner?: HTMLElement; +} +``` + +### add + +Adds a PopupStackItem to the stack. This should only be called when the item is rendered to the +page. Z-indexes are set when the item is added to the stack. If your application requires popups to +be registered initially, but rendered when the user triggers some event, call this method when the +event triggers. + +```tsx +PopupStack.add(item: PopupStackItem): void +``` + +### remove + +Removes an item from the stack by its `HTMLElement` reference. This should be called when a popup is +"closed" or when the element is removed from the page entirely to ensure proper memory cleanup. This +will not automatically be called when the element is removed from the DOM. Will reset z-index values +of the stack + +```tsx +PopupStack.remove(element: HTMLElement): void +``` + +### isTopmost + +Returns true when the provided `element` is at the top of the stack. It will return false if it is +not the top of the stack or is not found in the stack. The `element` should be the same reference +that was passed to `add` + +```tsx +PopupStack.isTopmost(element: HTMLElement): boolean +``` + +### getElements + +Returns an array of elements defined by the `element` passed to `add`. + +```tsx +PopupStack.getElements(): HTMLElement[] +``` + +### bringToTop + +Bring the element to the top of the stack. This is useful for persistent popups to place them on top +of the stack when clicked. If an `owner` was provided to an item when it was added and that owner is +a DOM child of another item in the stack, that item will be considered a "parent" to this item. If +the previous are true, all "children" stack items will be brought to top as well and will be on top +of the element passed to `bringToTop`. This maintains stack item "hierarchy" so that stack items +like Popups and Tooltips don't get pushed behind elements they are supposed to be on top of. + +This does not need to be called when a popup is added since added popups are already place on the +top of the stack. + +```tsx +PopupStack.bringToTop(element: HTMLElement): void +``` + +### contains + +Compares a Popup by its element reference against the event target and the stack. An event target is +considered to be "contained" by an element under the following conditions: + +- The `eventTarget` is a DOM child of the popup element +- The `eventTarget` is the `owner` element passed when it was added to the stack +- The `eventTarget` is a DOM child of the `owner` element + +```tsx +PopupStack.contains(element: HTMLElement, eventTarget: HTMLElement): boolean +``` diff --git a/modules/popup-stack/index.ts b/modules/popup-stack/index.ts new file mode 100644 index 0000000000..401abbd4e3 --- /dev/null +++ b/modules/popup-stack/index.ts @@ -0,0 +1 @@ +export * from './lib/PopupStack'; diff --git a/modules/popup-stack/lib/PopupStack.ts b/modules/popup-stack/lib/PopupStack.ts new file mode 100644 index 0000000000..f3948a8ede --- /dev/null +++ b/modules/popup-stack/lib/PopupStack.ts @@ -0,0 +1,240 @@ +/** + * This type is purposely an interface so that it can be extended for a specific use-case. + */ +export interface PopupStackItem { + /** + * All items in the stack are identified by their DOM element reference. A DOM element is + * framework agnostic. + */ + element: HTMLElement; + /** + * An owner is typically a trigger or anchor target. For example, it will be a HTMLButtonElement + * that opened a dropdown menu. If an owner is provided, _and_ that owner element is part of + * another stack item, it will be considered a "parent" of the provided stack item. This reference + * helps in the following ways: + * - Click outside detection typically will use `PopupStack.contains()` which includes this + * element. If you wish to close a popup when the target is clicked, add a click handler to do + * so. + * - `PopupStack.bringToTop()` will also bring children to top as well using the `owner` reference + * to map a "child" popup back to its parent. This is useful for "Window" or other persistent + * popups that are brought to the front when clicked. This will prevent the "Window" from + * rendering on top of child popups as they will be brought along also. + * - Synthetic event systems like in React will bubble events through "portals". This is + * inconsistent with DOM event bubbling. This reference helps normalize that behavior across + * different frameworks. + */ + owner?: HTMLElement; +} + +function getLast(items: T[]): T | null { + if (items.length) { + return items[items.length - 1]; + } + return null; +} + +/** + * Calculate the zIndex value of a given index in the stack. The range is 20 where 30 is the minimum + * and 50 is the maximum. If there are more than 20 items in the stack, we'll have multiple zIndexes + * of 30 at the bottom of the stack since the user probably can't tell the difference with that many + * popups. + */ +export function getValue(index: number, length: number): number { + const {min, max} = stack.zIndex; + + if (length <= max - min) { + return index + min; + } + return Math.max(min, max - (length - index) + 1); +} + +// IE11 doesn't support Array.prototype.find, so we'll polyfill here +function find( + items: T[], + predicate: (value: T, index: number, obj: T[]) => boolean, + thisArg?: any +): T | undefined { + const length = items.length; + for (let i = 0; i < length; i++) { + if (predicate(items[i], i, items)) { + return items[i]; + } + } + return; +} + +/** + * Sets the z-index value of all elements in the stack according to the `getValue` function. This + * will be run any time the stack changes. + */ +function setZIndexOfElements(elements: HTMLElement[]): void { + const length = elements.length; + elements.forEach((element, index) => { + element.style.zIndex = String(getValue(index, length)); + }); +} + +/** + * Return the owning popup element reference given an owner reference passed when a Popup was added + * to the stack. + */ +function getOwnerPopup(element: HTMLElement, items: PopupStackItem[]): HTMLElement | undefined { + let parentEl: HTMLElement | null = element; + do { + const owner = find(items, el => el.element === parentEl); + if (owner) { + return owner.element; + } + } while ((parentEl = parentEl.parentElement)); + + return; +} + +/** + * Get all child popups associated with an item in the stack. This is used by + * `PopupStack.bringToTop` logic to bring child popups along with their parent, moving the whole + * hierarchy. + */ +function getChildPopups(item: PopupStackItem, items: PopupStackItem[]): PopupStackItem[] { + const owners = items + .filter(i => i.owner) + .map(i => ({element: i.element, parent: getOwnerPopup(i.owner!, items)})) + .filter(i => i.parent === item.element); + + return owners; +} + +interface Stack { + items: PopupStackItem[]; + zIndex: { + min: number; + max: number; + getValue: typeof getValue; + }; +} + +// We need to make sure only one stack is ever in use on the page - ever. If a stack is already +// defined on the page, we need to use that one. Never, ever, ever change this variable name on +// window +(window as any).workday = (window as any).workday || {}; +const stack: Stack = (window as any).workday.__popupStack || { + description: 'Global popup stack from @workday/canvas-kit-popup-stack', + items: [] as PopupStackItem[], + zIndex: {min: 30, max: 50, getValue: getValue}, +}; +(window as any).workday.__popupStack = stack; + +export const PopupStack = { + /** + * Adds a PopupStackItem to the stack. This should only be called when the item is rendered to the + * page. Z-indexes are set when the item is added to the stack. If your application requires + * popups to be registered initially, but rendered when the user triggers some event, call this + * method when the event triggers. + */ + add(item: PopupStackItem): void { + stack.items.push(item); + + setZIndexOfElements(PopupStack.getElements()); + }, + + /** + * Removes an item from the stack by its `HTMLElement` reference. This should be called when a + * popup is "closed" or when the element is removed from the page entirely to ensure proper memory + * cleanup. This will not automatically be called when the element is removed from the DOM. Will + * reset z-index values of the stack + */ + remove(element: HTMLElement): void { + stack.items = stack.items.filter(item => item.element !== element); + + setZIndexOfElements(PopupStack.getElements()); + }, + + /** + * Returns true when the provided `element` is at the top of the stack. It will return false if it + * is not the top of the stack or is not found in the stack. The `element` should be the same + * reference that was passed to `add` + */ + isTopmost(element: HTMLElement): boolean { + const last = getLast(stack.items); + + if (last) { + return last.element === element; + } + return false; + }, + + /** + * Returns an array of elements defined by the `element` passed to `add`. + */ + getElements(): HTMLElement[] { + return stack.items.map(i => i.element); + }, + + /** + * Bring the element to the top of the stack. This is useful for persistent popups to place them + * on top of the stack when clicked. If an `owner` was provided to an item when it was added and + * that owner is a DOM child of another item in the stack, that item will be considered a "parent" + * to this item. If the previous are true, all "children" stack items will be brought to top as + * well and will be on top of the element passed to `bringToTop`. This maintains stack item + * "hierarchy" so that stack items like Popups and Tooltips don't get pushed behind elements they + * are supposed to be on top of. + * + * This does not need to be called when a popup is added since added popups are already place on + * the top of the stack. + */ + bringToTop(element: HTMLElement): void { + const item = find(stack.items, i => i.element === element); + + if (item) { + stack.items = [...stack.items.filter(i => i !== item), item]; + + // Also bring children to top. There are a few cases where stacking might break otherwise: + // - Clicking a Popup calls `bringToTop`, but mouse is over a Tooltip so that Tooltip is now + // under the Popup + // - Clicking a button opens a new Popup, but that click bubbles up to a `bringToTop` call + // putting the new popup under an existing one + // Example: https://user-images.githubusercontent.com/338257/83924476-031af580-a742-11ea-8f68-0edabdf0fd6a.gif + getChildPopups(item, stack.items).forEach(popup => { + PopupStack.bringToTop(popup.element); + }); + + setZIndexOfElements(PopupStack.getElements()); + } else { + // not found + const e = new Error(); + console.warn('Could not find item', e.stack); + } + }, + + /** + * Compares a Popup by its element reference against the event target and the stack. An event + * target is considered to be "contained" by an element under the following conditions: + * - The `eventTarget` is a DOM child of the popup element + * - The `eventTarget` is the `owner` element passed when it was added to the stack + * - The `eventTarget` is a DOM child of the `owner` element + * + * This method should be used instead of `element.contains` so that clicking a popup target can + * opt-in to toggling. Otherwise there is no way to opt-out of toggle behavior (because the target + * is not inside `element`). + */ + contains(element: HTMLElement, eventTarget: HTMLElement): boolean { + const item = find(stack.items, i => i.element === element); + + if (item) { + return ( + eventTarget === item.owner || + item.owner?.contains(eventTarget) || + element.contains(eventTarget) + ); + } + return false; + }, +}; + +/** + * Reset all the items in the stack. This should only be used for testing or if the page doesn't + * properly tear down each item in the stack when switching views. + */ +export function resetStack() { + stack.items = []; +} diff --git a/modules/popup-stack/package.json b/modules/popup-stack/package.json new file mode 100644 index 0000000000..3e0e64c4e5 --- /dev/null +++ b/modules/popup-stack/package.json @@ -0,0 +1,50 @@ +{ + "name": "@workday/canvas-kit-popup-stack", + "version": "4.0.0", + "description": "Stack for managing popup UIs to coordinate global concerns like escape key handling and rendering order", + "author": "Workday, Inc. (https://www.workday.com)", + "license": "Apache-2.0", + "main": "dist/commonjs/index.js", + "module": "dist/es6/index.js", + "sideEffects": false, + "types": "dist/es6/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/Workday/canvas-kit/tree/master/modules/_labs/popup-stack/react" + }, + "files": [ + "dist/", + "lib/", + "index.ts", + "ts3.5/**/*" + ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, + "scripts": { + "watch": "yarn build:es6 -w", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:es6": "tsc -p tsconfig.es6.json", + "build:rebuild": "npm-run-all clean build", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", + "depcheck": "node ../../utils/check-dependencies-exist.js" + }, + "devDependencies": { + "@emotion/core": "^10.0.28", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-popup": "^4.0.0", + "@workday/canvas-kit-react-tooltip": "^4.0.0" + }, + "keywords": [ + "canvas", + "canvas-kit", + "workday", + "popup-stack" + ] +} diff --git a/modules/popup-stack/spec/PopupStack.spec.ts b/modules/popup-stack/spec/PopupStack.spec.ts new file mode 100644 index 0000000000..f131237e91 --- /dev/null +++ b/modules/popup-stack/spec/PopupStack.spec.ts @@ -0,0 +1,207 @@ +import {PopupStack, PopupStackItem, resetStack, getValue} from '../lib/PopupStack'; + +describe('PopupStack', () => { + afterEach(() => { + resetStack(); + }); + + it('add() should add items to the stack', () => { + const div = document.createElement('div'); + PopupStack.add({element: div}); + + expect(PopupStack.getElements()).toEqual([div]); + }); + + it('remove() should remove items from the stack', () => { + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + PopupStack.add({element: div1}); + PopupStack.add({element: div2}); + + PopupStack.remove(div1); + + expect(PopupStack.getElements()).toEqual([div2]); + }); + + describe('isTopmost()', () => { + describe('with elements', () => { + const elements = [1, 2, 3, 4].map(_ => document.createElement('div')); + + beforeEach(() => { + elements.forEach(element => PopupStack.add({element})); + }); + + it('should return false for non-top elements', () => { + expect(PopupStack.isTopmost(elements[0])).toEqual(false); + expect(PopupStack.isTopmost(elements[1])).toEqual(false); + expect(PopupStack.isTopmost(elements[2])).toEqual(false); + }); + + it('should return true for top element', () => { + expect(PopupStack.isTopmost(elements[3])).toEqual(true); + }); + + it('should return false if there are no items in the stack', () => { + expect(PopupStack.isTopmost(document.createElement('div'))).toEqual(false); + }); + + it('should return false if the element is not in the stack', () => { + const element = document.createElement('div'); + + expect(PopupStack.isTopmost(element)).toEqual(false); + }); + }); + + describe('without elements', () => { + const element = document.createElement('div'); + + expect(PopupStack.isTopmost(element)).toEqual(false); + }); + }); + + it('getElement() should return a list of element', () => { + const elements = [1, 2, 3, 4].map(_ => document.createElement('div')); + elements.forEach(element => PopupStack.add({element})); + + expect(PopupStack.getElements()).toEqual(elements); + }); + + describe('bringToTop()', () => { + it('should bring element to the top to the stack', () => { + const elements = [1, 2, 3, 4].map(_ => document.createElement('div')); + elements.forEach(element => document.body.appendChild(element)); + elements.forEach(element => PopupStack.add({element})); + + PopupStack.bringToTop(elements[0]); + + expect(PopupStack.isTopmost(elements[0])).toEqual(true); + }); + + it('should bring child popup elements to the top of the stack by owner reference', () => { + const elements = [1, 2, 3, 4].map(_ => document.createElement('div')); + elements.forEach(element => PopupStack.add({element})); + + const element = document.createElement('div'); + const owner = document.createElement('div'); + elements[0].appendChild(owner); // ensure we look up the DOM tree + PopupStack.add({element, owner}); + + PopupStack.bringToTop(elements[0]); + expect(PopupStack.isTopmost(element)).toEqual(true); + expect(PopupStack.getElements()).toEqual([ + elements[1], + elements[2], + elements[3], + elements[0], + element, + ]); + }); + }); + + describe('contains()', () => { + it('should return true when the eventTarget is a child of the stack element', () => { + const element = document.createElement('div'); + const eventTarget = document.createElement('div'); + element.appendChild(eventTarget); + + PopupStack.add({element}); + + expect(PopupStack.contains(element, eventTarget)).toEqual(true); + }); + + it('should return false when an eventTarget is outside the stack element', () => { + const element = document.createElement('div'); + const eventTarget = document.createElement('div'); + + PopupStack.add({element}); + + expect(PopupStack.contains(element, eventTarget)).toEqual(false); + }); + + it('should return true when the owning element is the eventTarget', () => { + const owner = document.createElement('div'); + const element = document.createElement('div'); + + PopupStack.add({element, owner}); + + expect(PopupStack.contains(element, owner)).toEqual(true); + }); + + it('should return true when the owning element contains the eventTarget', () => { + const owner = document.createElement('div'); + const element = document.createElement('div'); + const eventTarget = document.createElement('div'); + owner.appendChild(eventTarget); + + PopupStack.add({element, owner}); + + expect(PopupStack.contains(element, eventTarget)).toEqual(true); + }); + + it('should return false if the element is not in the stack', () => { + const element = document.createElement('div'); + const eventTarget = document.createElement('div'); + + expect(PopupStack.contains(element, eventTarget)).toEqual(false); + }); + }); + + describe('defaultGetValue()', () => { + [ + {index: 0, length: 20, output: 30}, + {index: 1, length: 20, output: 31}, + {index: 2, length: 20, output: 32}, + // If we have too many popups to fit in the range, stack at 30 rather than at 50 + {index: 0, length: 50, output: 30}, + {index: 1, length: 50, output: 30}, + {index: 2, length: 50, output: 30}, + {index: 19, length: 50, output: 30}, + {index: 20, length: 50, output: 30}, + {index: 21, length: 50, output: 30}, + {index: 29, length: 50, output: 30}, + {index: 30, length: 50, output: 31}, + {index: 48, length: 50, output: 49}, + {index: 49, length: 50, output: 50}, + {index: 0, length: 20, output: 30}, + {index: 19, length: 20, output: 49}, + {index: 0, length: 21, output: 30}, + {index: 20, length: 21, output: 50}, + ].forEach(({index, length, output}) => { + it(`should assign index of ${index} and a length of ${length} a z-index of ${output}`, () => { + expect(getValue(index, length)).toEqual(output); + }); + }); + }); + + describe('z-index', () => { + const elements = [1, 2].map(_ => document.createElement('div')); + + it('should set an element to a z-index of 30 when added', () => { + PopupStack.add({element: elements[0]}); + + expect(elements[0].style.zIndex).toEqual('30'); + }); + + it('should reset z-indexes when 2 elements are added', () => { + elements.forEach(element => PopupStack.add({element})); + + expect(elements[0].style.zIndex).toEqual('30'); + expect(elements[1].style.zIndex).toEqual('31'); + }); + + it('should reset z-indexes when an element is removed', () => { + elements.forEach(element => PopupStack.add({element})); + PopupStack.remove(elements[0]); + + expect(elements[1].style.zIndex).toEqual('30'); + }); + + it('should reset z-indexes when an element is brought to the top of the stack', () => { + elements.forEach(element => PopupStack.add({element})); + PopupStack.bringToTop(elements[0]); + + expect(elements[0].style.zIndex).toEqual('31'); + expect(elements[1].style.zIndex).toEqual('30'); + }); + }); +}); diff --git a/modules/popup-stack/stories/stories.tsx b/modules/popup-stack/stories/stories.tsx new file mode 100644 index 0000000000..afc3ebfe78 --- /dev/null +++ b/modules/popup-stack/stories/stories.tsx @@ -0,0 +1,57 @@ +/// + +import React from 'react'; + +import withReadme from 'storybook-readme/with-readme'; + +import README from '../README.md'; +import {PopupStack} from '../lib/PopupStack'; + +export default { + title: 'Components|Popups/Popup Stack', + decorators: [withReadme(README)], +}; + +export const Default = () => { + React.useEffect(() => { + document.querySelector('#open-button').addEventListener('click', event => { + const number = document.body.querySelectorAll('.wdc-card').length; + const div = document.createElement('div'); + div.className = 'example-popup'; + div.style.position = 'absolute'; + div.style.width = '200px'; + div.style.top = `${70 + 30 * number}px`; + div.style.left = `${60 + 30 * number}px`; + + div.innerHTML = ` +
+

Card Header

+
Contents of Stack UI
+
+
`; + document.body.appendChild(div); + PopupStack.add({element: div, owner: event.currentTarget as HTMLElement}); + + div.querySelector('.close-button').addEventListener('click', () => { + document.body.removeChild(div); + PopupStack.remove(div); + }); + }); + + return () => { + // manual cleanup + document.body.querySelectorAll('.example-popup').forEach(popup => { + PopupStack.remove(popup as HTMLElement); + document.body.removeChild(popup); + }); + }; + }, []); // do all DOM manipulation once + + return ( +
+ +
+ ); +}; diff --git a/modules/popup-stack/stories/stories_testing.tsx b/modules/popup-stack/stories/stories_testing.tsx new file mode 100644 index 0000000000..73d33c2354 --- /dev/null +++ b/modules/popup-stack/stories/stories_testing.tsx @@ -0,0 +1,113 @@ +/// +/** @jsx jsx */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import {css, jsx} from '@emotion/core'; + +import {Tooltip} from '@workday/canvas-kit-react-tooltip'; + +import { + Popup, + Popper, + usePopupStack, + useCloseOnOutsideClick, + useBringToTopOnClick, + useCloseOnEscape, +} from '@workday/canvas-kit-react-popup'; +import {DeleteButton, Button} from '@workday/canvas-kit-react-button'; + +export default { + title: 'Testing|React/Popups/Popup Stack', +}; + +interface WindowProps { + top: number; + left: number; + heading: string; + children: React.ReactNode; +} + +const styles = css({ + position: 'absolute', + width: 500, +}); + +const Window = ({children, heading, top, left}: WindowProps) => { + const ref = React.useRef(null); + + usePopupStack(ref); + useBringToTopOnClick(ref); + + return ReactDOM.createPortal( + + {children} + , + document.body + ); +}; + +const TempPopup = () => { + const [open, setOpen] = React.useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + const popupRef = React.useRef(null); + + const onClose = () => setOpen(false); + const onButtonClick = (event: React.MouseEvent) => { + setOpen(true); + setAnchorEl(event.currentTarget); + }; + + useCloseOnOutsideClick(popupRef, onClose); + useCloseOnEscape(popupRef, onClose); + + return ( +
+ Delete Item + + +
+ Are you sure you'd like to delete the item titled 'My Item'? +
+ + + Delete + + +
+
+
+ ); +}; + +export const MixedPopupTypes = () => { + return ( +
+ + + Contents of Window 1 + + + + + Contents of Window 2 + + + +
+ + Contents of Window 4 + +
+
+ +
+ + console.log('clicked')}>Contents of Window 3 + + +
+
+
+ ); +}; diff --git a/modules/popup-stack/tsconfig.cjs.json b/modules/popup-stack/tsconfig.cjs.json new file mode 100644 index 0000000000..261641fcc9 --- /dev/null +++ b/modules/popup-stack/tsconfig.cjs.json @@ -0,0 +1,11 @@ + +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "module": "commonjs", + "outDir": "dist/commonjs", + "skipLibCheck": true, + "tsBuildInfoFile": "./.build-info/tsconfig.cjs.tsbuildinfo" + } +} diff --git a/modules/popup-stack/tsconfig.es6.json b/modules/popup-stack/tsconfig.es6.json new file mode 100644 index 0000000000..cdf8edae25 --- /dev/null +++ b/modules/popup-stack/tsconfig.es6.json @@ -0,0 +1,9 @@ + +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist/es6", + "tsBuildInfoFile": "./.build-info/tsconfig.es6.tsbuildinfo" + } +} diff --git a/modules/popup-stack/tsconfig.json b/modules/popup-stack/tsconfig.json new file mode 100644 index 0000000000..cef00443a7 --- /dev/null +++ b/modules/popup-stack/tsconfig.json @@ -0,0 +1,5 @@ + +{ + "extends": "../../tsconfig.json", + "exclude": ["node_modules", "ts-tmp", "dist", "spec", "stories"] +} diff --git a/modules/popup/css/package.json b/modules/popup/css/package.json index c339066982..a440db3b0c 100644 --- a/modules/popup/css/package.json +++ b/modules/popup/css/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-css-popup", - "version": "3.8.0", + "version": "4.0.0", "description": "Popup CSS for Canvas kit CSS", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -16,8 +16,8 @@ "url": "http://github.com/Workday/canvas-kit/tree/master/modules/popup/css" }, "dependencies": { - "@workday/canvas-kit-css-card": "^3.8.0", - "@workday/canvas-kit-css-core": "^3.8.0" + "@workday/canvas-kit-css-card": "^4.0.0", + "@workday/canvas-kit-css-core": "^4.0.0" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/modules/popup/css/stories.tsx b/modules/popup/css/stories.tsx index 1754365689..21d45b4553 100644 --- a/modules/popup/css/stories.tsx +++ b/modules/popup/css/stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {storiesOf} from '@storybook/react'; import withReadme from 'storybook-readme/with-readme'; import README from './README.md'; -import {beta_Button as Button} from '@workday/canvas-kit-react-button'; +import '@workday/canvas-kit-css-button/index.scss'; import './index.scss'; // @ts-ignore import initializeIcons from '../../icon/css/lib/canvas-kit-css-icon'; @@ -34,9 +34,9 @@ class PopupWrapper extends React.Component<{}, PopupWrapperState> { position: 'relative', }} > - + {open ? (
{
Are you sure you'd like to delete the item titled 'My Item'?
- - + +
) : null}
@@ -93,7 +93,7 @@ class PopupWrapper extends React.Component<{}, PopupWrapperState> { }); }; - private onOpenPopupClick = (e: React.SyntheticEvent) => { + private onOpenPopupClick = () => { this.setState({ open: !this.state.open, }); diff --git a/modules/popup/react/README.md b/modules/popup/react/README.md index 78c053e6b7..46dbe90e9d 100644 --- a/modules/popup/react/README.md +++ b/modules/popup/react/README.md @@ -1,10 +1,21 @@ -# Canvas Kit Popup +# Canvas Kit Popups -A Popup component that allows you to render content above another. +A "popup" is a classification for a type of stacked UI element that appears "on top" of statically +positioned content. Tooltips, Modals, Dropdown menus, etc are all examples of "popups". Canvas Kit +has a "stack manager" system for managing these stacked UIs. Different types of popups have +different requirements of behavior for UX and accessibility - we can call them capabilities, +behaviors, or traits. Canvas Kit comes with a number of [behaviors](#hooks) in the form of React +Hooks. -Note: This popup does not include a positioning engined. In our example we use Material UIs popper -component to wrap our Popup component and position it, which is a wrapper to Popper.js. For -reference: https://material-ui.com/api/popper/ +If you are building your own custom stacked UI components, use the [Popper](#popper) component along +with our [hooks](#hooks). The `Popper` component and hooks work with the stack management system for +correct rendering and accessibility behavior. If you cannot use `Popper`, use the +[usePopupStack](#usepoupstack) hook to properly register and deregister the popup at the correct +time. If you cannot use our hooks, consider upgrading your component to use Hooks. If you cannot do +that, you'll have to look up the `PopupStack` package for the direct API and have a look at the +source code for our hooks into the `PopupStack` API. + +This package comes with everything you need to build Popup UIs. ## Installation @@ -18,27 +29,150 @@ or yarn add @workday/canvas-kit-react-popup ``` -## Usage +## Popper + +A thin wrapper component around the Popper.js positioning engine. For reference: +https://popper.js.org/. `Popper` also automatically works with the `PopupStack` system. `Popper` has +no UI and will render any children to the `body` element and position around a provided +`anchorElement`. + +### Usage + +```tsx +import * as React from 'react'; +import {Button} from '@workday/canvas-kit-react-button'; +import {Popper} from '@workday/canvas-kit-react-popup'; + +const MyPopper = () => { + const [open, setOpen] = React.useState(false); + const buttonRef = React.useRef(null) + + return ( +
+
+ +
+ ); +}; +``` + +If you need access to the `placement` that was chosen by PopperJS, `children` can also be a +[render prop](https://reactjs.org/docs/render-props.html). + +```tsx +import * as React from 'react'; +import {Button} from '@workday/canvas-kit-react-button'; +import {Popper} from '@workday/canvas-kit-react-popup'; + +const MyPopper = () => { + const [open, setOpen] = React.useState(false); + const buttonRef = React.useRef(null) + + return ( +
+
+ ) + }} +
+ + ); +}; +``` + +### Popper Required Props + +#### `anchorElement: RefObject | Element | null` + +> The reference element used to position the Popper. Popper content will try to follow the +> `anchorElement` if it moves and will reposition itself if there is no longer room in the window. + +#### `children: ((props: {placement: Placement}) => React.ReactNode) | React.ReactNode` + +> The content of the Popper. If a function is provided, it will be treated as a Render Prop and pass +> the `placement` chosen by PopperJS. This `placement` value is useful if your popup needs to +> animate and that animation depends on the direction of the content in relation to the +> `anchorElement`. + +### Popper Optional Props + +#### `containerElement: Element | null` + +> The element that contains the portal children when `portal` is true. It is best to not define this +> unless you know what you're doing. Popper works with a PopupStack and in order for z-indexes to +> work correctly, all Popups on your page should live on the same root element otherwise you risk +> running into rendering issues: +> https://philipwalton.com/articles/what-no-one-told-you-about-z-index/. + +Default: `document.body` + +#### `open: boolean` + +> Determines if `Popper` content should be rendered. The content only exists in the DOM when `open` +> is `true` + +Default: `true` + +#### `placement: PopperJS.Placement` + +> The placement of the `Popper` contents relative to the `anchorElement`. Accepts `auto`, `top`, +> `right`, `bottom`, or `left`. Each placement can also be modified using any of the following +> variations: `-start` or `-end`. + +Default: `'bottom'` + +#### `popperOptions: Partial` + +> The additional options passed to the Popper's `popper.js` instance. + +#### `portal: boolean` + +> If true, attach the Popper to the `containerElement`. If false, render the Popper within the DOM +> hierarchy of its parent. A non-portal Popper will constrained by the parent container overflows. +> If you set this to `false`, you may experience issues where you content gets cut off by scrollbars +> or `overflow: hidden` + +Default: `true` + +## Popup + +### Popup Usage ```tsx import * as React from 'react'; -import Popper from '@material-ui/core/Popper'; -import {Popup} from '@workday/canvas-kit-react-popup'; - -// We use Popper from Material UI for our positioning - - - {this.props.children} - -; +import {Button} from '@workday/canvas-kit-react-button'; +import {Popup, Popper, usePopup, use} from '@workday/canvas-kit-react-popup'; + +const MyPopup = () => { + const { targetProps, closePopup, popperProps } = usePopup() + + // Add some behaviors + useCloseOnOutsideClick(popperProps.ref, closePopup); + useCloseOnEscape(popperProps.ref, closePopup); + + return ( + + + Popup Contents + + + ); +}; ``` -## Static Properties +### Popup Static Properties #### `Padding: PopupPadding` @@ -46,17 +180,15 @@ import {Popup} from '@workday/canvas-kit-react-popup'; {this.props.children} ``` -## Component Props - -### Required +### Popup Required Props > None --- -### Optional +### Popup Optional Props -### `padding: PopupPadding` +#### `padding: PopupPadding` > You can choose between zero, s, l for your padding @@ -125,3 +257,90 @@ Default: > A ref to the underlying popup container element. Use this to check click targets against when > closing a popup. + +## Hooks + +### usePopupStack + +```ts +usePopupStack(ref: React.RefObject): void +``` + +This hook will add a `ref` element to the Popup stack on mount and remove on unmount. If you use +`Popper`, the popper `ref` is automatically added/removed from the Popup stack. The Popup stack is +required for proper z-index values to ensure Popups are rendered correct. It is also required for +global listeners like click outside or escape key closing a popup. Without the Popup stack, all +popups will close rather than only the topmost one. + +This should be used by all stacked UIs unless using the `Popper` component. + +## useAssistiveHideSiblings + +```ts +useAssistiveHideSiblings(ref: React.RefObject): void +``` + +This hook will hide all sibling elements from assistive technology. Very useful for modal dialogs. +This will set `aria-hidden` for sibling elements of the provided `ref` element and restore the +previous `aria-hidden` to each component when the component is unmounted. For example, if added to a +Modal component, all children of `document.body` will have an `aria-hidden=true` applied _except_ +for the provided `ref` element (the Modal). This will effectively hide all content outside the Modal +from assistive technology including Web Rotor for VoiceOver for example. + +**Note**: The provided `ref` element should be root element of your component so that other elements +_outside_ your component will be hidden rather than elements _inside_ your component. + +This should be used on stacked UI elements that need to hide content. Like Modals. + +## useBringToTopOnClick + +```ts +useBringToTopOnClick(ref: React.RefObject): void +``` + +This hook will bring an element to the top of the stack when any element inside the provided `ref` +element is clicked. If `Popper` was used or `PopupStack.add` provided an `owner`, all "child" popups +will also be brought to the top. A "child" popup is a Popup that was opened from another Popup. +Usually this is a Tooltip or Select component inside something like a Modal. + +This should be used on stacked UI elements that are meant to persist, like Windows. + +## useCloseOnEscape + +```ts +useCloseOnEscape(ref: React.RefObject, onClose: () => void): void +``` + +Registers global detection of the Escape key. It will only call the `onClose` callback if the +provided `ref` element is the topmost in the stack. The `ref` should be the same as the one passed +to `usePopupStack` or the `Popper` component since `Popper` uses `usePopupStack` internally. + +This should be used stacked UI elements that are dismissible like Tooltips, Modals, non-modal +dialogs, dropdown menus, etc. + +## useCloseOnOutsideClick + +```ts +useCloseOnOutsideClick(ref: React.RefObject, onClose: () => void): void +``` + +Registers global listener for all clicks. It will only call the `onClose` callback if the click +happened outside the `ref` element and its children _and_ the provided `ref` element is the topmost +in the stack. The `ref` should be the same as the one passed to `usePopupStack` or the `Popper` +component since `Popper` uses `usePopupStack` internally. + +This should be used stacked UI elements that are dismissible like Tooltips, Modals, non-modal +dialogs, dropdown menus, etc. + +## useFocusTrap + +```ts +useFocusTrap(ref: React.RefObject): void +``` + +"Trap" or "loop" focus within a provided `ref` element. This is required for accessibility on +modals. If a keyboard users hits the Tab or Shift + Tab, this will force "looping" of focus. It +effectively "hides" outside content from keyboard users. Use an overlay to hide content from mouse +users and `useAssistiveHideSiblings` to hide content from assistive technology users. + +This should be used on stacked UI elements that need to hide content. Like Modals. diff --git a/modules/popup/react/index.ts b/modules/popup/react/index.ts index d966fe5d20..bbd1b09090 100644 --- a/modules/popup/react/index.ts +++ b/modules/popup/react/index.ts @@ -3,3 +3,11 @@ import Popup from './lib/Popup'; export default Popup; export {Popup}; export * from './lib/Popup'; +export * from './lib/Popper'; +export * from './lib/useCloseOnEscape'; +export * from './lib/useFocusTrap'; +export * from './lib/useCloseOnOutsideClick'; +export * from './lib/usePopupStack'; +export * from './lib/useAssistiveHideSiblings'; +export * from './lib/useBringToTopOnClick'; +export * from './lib/getTransformFromPlacement'; diff --git a/modules/popup/react/lib/Popper.tsx b/modules/popup/react/lib/Popper.tsx new file mode 100644 index 0000000000..91be9ff448 --- /dev/null +++ b/modules/popup/react/lib/Popper.tsx @@ -0,0 +1,169 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as PopperJS from '@popperjs/core'; + +export type Placement = PopperJS.Placement; +export type PopperOptions = PopperJS.Options; + +import {usePopupStack} from '../'; + +export interface PopperProps extends React.HTMLAttributes { + /** + * The reference element used to position the Popper. Popper content will try to follow the + * `anchorElement` if it moves and will reposition itself if there is no longer room in the + * window. + */ + anchorElement: React.RefObject | Element | null; + /** + * The content of the Popper. If a function is provided, it will be treated as a Render Prop and + * pass the `placement` chosen by PopperJS. This `placement` value is useful if your popup needs + * to animate and that animation depends on the direction of the content in relation to the + * `anchorElement`. + */ + children: ((props: {placement: Placement}) => React.ReactNode) | React.ReactNode; + /** + * The element that contains the portal children when `portal` is true. It is best to not define + * this unless you know what you're doing. Popper works with a PopupStack and in order for + * z-indexes to work correctly, all Popups on your page should live on the same root element + * otherwise you risk running into rendering issues: + * https://philipwalton.com/articles/what-no-one-told-you-about-z-index/ + */ + containerElement?: Element | null; + /** + * Determines if `Popper` content should be rendered. The content only exists in the DOM when + * `open` is `true` + * @default true + */ + open?: boolean; + /** + * The placement of the `Popper` contents relative to the `anchorElement`. Accepts `auto`, `top`, + * `right`, `bottom`, or `left`. Each placement can also be modified using any of the following + * variations: `-start` or `-end`. + * @default bottom + */ + placement?: Placement; + /** + * The additional options passed to the Popper's `popper.js` instance. + */ + popperOptions?: Partial; + /** + * If true, attach the Popper to the `containerElement`. If false, render the Popper within the + * DOM hierarchy of its parent. A non-portal Popper will constrained by the parent container + * overflows. If you set this to `false`, you may experience issues where you content gets cut off + * by scrollbars or `overflow: hidden` + * @default true + */ + portal?: boolean; +} + +export const Popper = React.forwardRef( + ( + { + placement, + popperOptions, + portal = true, + open = true, + anchorElement, + children, + containerElement, + ...elemProps + }: PopperProps, + ref + ) => { + if (!open) { + return null; + } + + const contents = ( + + ); + + if (!portal) { + return contents; + } + + return ReactDOM.createPortal(contents, containerElement || document.body); + } +); + +const getElementFromRefOrElement = ( + input: React.RefObject | Element | null +): Element | undefined => { + if (input === null) { + return undefined; + } else if ('current' in input) { + return input.current || undefined; + } else { + return input; + } +}; + +// Popper bails early if `open` is false and React hooks cannot be called conditionally, +// so we're breaking out the open version into another component. +const OpenPopper = React.forwardRef( + ( + { + anchorElement, + popperOptions = {}, + placement: popperPlacement = 'bottom', + children, + ...elemProps + }, + forwardRef: React.RefObject + ) => { + const localRef = React.useRef(null); + const ref = (forwardRef || localRef) as React.RefObject; + const [placement, setPlacement] = React.useState(popperPlacement); + usePopupStack(ref, getElementFromRefOrElement(anchorElement) as HTMLElement | undefined); + + // useLayoutEffect prevents flashing of the popup before position is determined + React.useLayoutEffect(() => { + const anchorEl = getElementFromRefOrElement(anchorElement); + if (!anchorEl) { + console.warn( + `Popper: anchorElement was not defined. A valid anchorElement must be provided to render a Popper` + ); + return undefined; + } + + if (ref.current) { + const popper = PopperJS.createPopper(anchorEl, ref.current, { + placement: popperPlacement, + ...popperOptions, + onFirstUpdate: data => { + if (data.placement) { + setPlacement(data.placement); + } + if (popperOptions.onFirstUpdate) { + popperOptions.onFirstUpdate(data); + } + }, + }); + + return () => { + popper.destroy(); + }; + } + + return undefined; + }, [anchorElement]); + + return ( +
+ {isRenderProp(children) ? children({placement}) : children} +
+ ); + } +); + +// Typescript threw an error about non-callable signatures. Using typeof as a 'function' returns +// a type of `Function` which isn't descriptive enough for Typescript. We don't do any detection +// against the _type_ of function that gets passed, but we'll assume it is a render prop for now... +function isRenderProp( + children: any +): children is (props: {placement: Placement}) => React.ReactNode { + if (typeof children === 'function') { + return true; + } + return false; +} diff --git a/modules/popup/react/lib/Popup.tsx b/modules/popup/react/lib/Popup.tsx index 3b5c03b95b..15bd8764ff 100644 --- a/modules/popup/react/lib/Popup.tsx +++ b/modules/popup/react/lib/Popup.tsx @@ -5,9 +5,13 @@ import isPropValid from '@emotion/is-prop-valid'; import uuid from 'uuid/v4'; import Card from '@workday/canvas-kit-react-card'; -import {IconButton, IconButtonSize} from '@workday/canvas-kit-react-button'; -import {CanvasDepthValue, spacing} from '@workday/canvas-kit-react-core'; -import {TransformOrigin, getTranslateFromOrigin} from '@workday/canvas-kit-react-common'; +import {IconButton} from '@workday/canvas-kit-react-button'; +import {CanvasDepthValue, depth as depthValues, spacing} from '@workday/canvas-kit-react-core'; +import { + TransformOrigin, + getTranslateFromOrigin, + PickRequired, +} from '@workday/canvas-kit-react-common'; import {xIcon} from '@workday/canvas-system-icons-web'; export enum PopupPadding { @@ -17,21 +21,25 @@ export enum PopupPadding { } export interface PopupProps extends React.HTMLAttributes { + /** + * Aria label will override aria-labelledby, it is used if there is no heading or we need custom label for popup + */ + ariaLabel?: string; /** * The padding of the Popup. Accepts `zero`, `s`, or `l`. * @default PopupPadding.l */ - padding: PopupPadding; + padding?: PopupPadding; /** * The origin from which the Popup will animate. * @default {horizontal: 'center', vertical: 'top'} */ - transformOrigin: TransformOrigin | null; + transformOrigin?: TransformOrigin | null; /** - * The size of the Popup close button. Accepts `Small` or `Medium`. - * @default IconButtonSize.Medium + * The size of the Popup close button. Accepts `small` or `medium`. + * @default 'medium' */ - closeIconSize: IconButtonSize; + closeIconSize?: 'small' | 'medium'; /** * The ref to the underlying popup container element. Use this to check click targets against when closing the Popup. */ @@ -57,7 +65,7 @@ export interface PopupProps extends React.HTMLAttributes { * The `aria-label` for the Popup close button. * @default Close */ - closeLabel: string; + closeButtonAriaLabel?: string; } const closeIconSpacing = spacing.xs; @@ -80,7 +88,7 @@ const popupAnimation = (transformOrigin: TransformOrigin) => { const Container = styled('div', { shouldForwardProp: prop => isPropValid(prop) && prop !== 'width', -})>( +})>( { position: 'relative', maxWidth: `calc(100vw - ${spacing.l})`, @@ -99,6 +107,21 @@ const Container = styled('div', { } ); +const getHeadingId = (heading: React.ReactNode, id: string) => (heading ? id : undefined); + +const getAriaLabel = ( + ariaLabel: string | undefined, + headingId: string | undefined +): object | undefined => { + if (ariaLabel) { + return {['aria-label']: ariaLabel}; + } + if (headingId) { + return {['aria-labelledby']: headingId}; + } + return undefined; +}; + const CloseIconContainer = styled('div')>( { position: 'absolute', @@ -112,65 +135,83 @@ const CloseIconContainer = styled('div')>( export default class Popup extends React.Component { static Padding = PopupPadding; - static defaultProps = { - padding: Popup.Padding.l, - closeIconSize: IconButton.Size.Medium, - closeLabel: 'Close', - transformOrigin: { - horizontal: 'center', - vertical: 'top', - }, - }; - private id = uuid(); private closeButtonRef = React.createRef(); public render() { const { + padding = Popup.Padding.l, + closeIconSize = 'medium', + closeButtonAriaLabel = 'Close', + transformOrigin = { + horizontal: 'center', + vertical: 'top', + } as const, + depth = depthValues[2], handleClose, - padding, width, heading, - depth, - closeIconSize, - transformOrigin, popupRef, - closeLabel, + ariaLabel, ...elemProps } = this.props; - + const headingId = getHeadingId(heading, this.id); return ( {handleClose && ( )} - + {this.props.children} ); } } + +/** + * Convenience hook around popups. Most popups are non-modal dialogs with a single target and toggle + * when the target button is clicked. Additional popup features like `useCloseOnOutsideClick` need + * to be added manually. + */ +export const usePopup = () => { + const [open, setOpen] = React.useState(false); + const [anchorElement, setAnchorElement] = React.useState(null); + const ref = React.useRef(null); + + const togglePopup = (event: React.MouseEvent) => { + setAnchorElement(event.currentTarget as T); + setOpen(!open); + }; + + return { + targetProps: { + onClick: togglePopup, + }, + closePopup() { + setOpen(false); + }, + popperProps: { + open, + anchorElement, + ref, + }, + }; +}; diff --git a/modules/popup/react/lib/getTransformFromPlacement.ts b/modules/popup/react/lib/getTransformFromPlacement.ts new file mode 100644 index 0000000000..a6cc54b988 --- /dev/null +++ b/modules/popup/react/lib/getTransformFromPlacement.ts @@ -0,0 +1,33 @@ +import {TransformOrigin} from '@workday/canvas-kit-react-common'; +import {Placement} from '@popperjs/core'; + +export const getTransformFromPlacement = (placement: Placement): TransformOrigin => { + const [first, second] = placement.split('-'); + + const vertical = + first === 'top' + ? 'bottom' + : first === 'bottom' + ? 'top' + : second === 'end' + ? 'top' + : second === 'start' + ? 'bottom' + : 'center'; + + const horizontal = + first === 'left' + ? 'right' + : first === 'right' + ? 'left' + : second === 'end' + ? 'left' + : second === 'start' + ? 'right' + : 'center'; + + return { + vertical, + horizontal, + }; +}; diff --git a/modules/popup/react/lib/useAssistiveHideSiblings.ts b/modules/popup/react/lib/useAssistiveHideSiblings.ts new file mode 100644 index 0000000000..2d3d4f1e7d --- /dev/null +++ b/modules/popup/react/lib/useAssistiveHideSiblings.ts @@ -0,0 +1,37 @@ +import React from 'react'; + +/** + * This hook will hide all sibling elements from assistive technology. Very useful for modal dialogs. + * This will set `aria-hidden` for sibling elements of the provided `ref` element and restore the + * previous `aria-hidden` to each component when the component is unmounted. For example, if added to a + * Modal component, all children of `document.body` will have an `aria-hidden=true` applied _except_ + * for the provided `ref` element (the Modal). This will effectively hide all content outside the Modal + * from assistive technology including Web Rotor for VoiceOver for example. + * + * **Note**: The provided `ref` element should be root element of your component so that other elements + * _outside_ your component will be hidden rather than elements _inside_ your component + * + * @param ref The ref element where siblings will be hidden. + */ +export const useAssistiveHideSiblings = (ref: React.RefObject) => { + React.useEffect(() => { + const siblings = [...((ref.current?.parentElement?.children as any) as HTMLElement[])].filter( + el => el !== ref.current + ); + const prevAriaHidden = siblings.map(el => el.getAttribute('aria-hidden')); + siblings.forEach(el => { + el.setAttribute('aria-hidden', 'true'); + }); + + return () => { + siblings.forEach((el, index) => { + const prev = prevAriaHidden[index]; + if (prev) { + el.setAttribute('aria-hidden', prev); + } else { + el.removeAttribute('aria-hidden'); + } + }); + }; + }, [ref]); +}; diff --git a/modules/popup/react/lib/useBringToTopOnClick.ts b/modules/popup/react/lib/useBringToTopOnClick.ts new file mode 100644 index 0000000000..feabc6678b --- /dev/null +++ b/modules/popup/react/lib/useBringToTopOnClick.ts @@ -0,0 +1,28 @@ +import React from 'react'; +import {PopupStack} from '@workday/canvas-kit-popup-stack'; + +/** + * This hook will bring an element to the top of the stack when any element inside the provided + * `ref` element is clicked. If `Popper` was used or `PopupStack.add` provided an `owner`, all + * "child" popups will also be brought to the top. A "child" popup is a Popup that was opened from + * another Popup. Usually this is a Tooltip or Select component inside something like a Modal. + */ +export const useBringToTopOnClick = (ref: React.RefObject) => { + let timer: number; + const onClick = (event: MouseEvent) => { + // requestAnimationFrame is used to control timing of when `bringToTop` is called. + // https://github.com/Workday/canvas-kit/pull/670#discussion_r436158184 + timer = requestAnimationFrame(() => { + if (ref.current?.contains(event.target as Node)) { + PopupStack.bringToTop(ref.current!); + } + }); + }; + React.useLayoutEffect(() => { + document.addEventListener('click', onClick); + return () => { + cancelAnimationFrame(timer); + document.removeEventListener('click', onClick); + }; + }); +}; diff --git a/modules/popup/react/lib/useCloseOnEscape.ts b/modules/popup/react/lib/useCloseOnEscape.ts new file mode 100644 index 0000000000..3d3864fa40 --- /dev/null +++ b/modules/popup/react/lib/useCloseOnEscape.ts @@ -0,0 +1,29 @@ +import React from 'react'; + +import {PopupStack} from '@workday/canvas-kit-popup-stack'; + +/** + * Registers global detection of the Escape key. It will only call the `onClose` callback if the + * provided `ref` element is the topmost in the stack. The `ref` should be the same as the one passed + * to `usePopupStack` or the `Popper` component since `Popper` uses `usePopupStack` internally. + * @param ref + * @param onClose + */ +export const useCloseOnEscape = ( + ref: React.RefObject, + onClose: () => void +) => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.key === 'Esc' || event.key === 'Escape') && PopupStack.isTopmost(ref.current!)) { + onClose(); + } + }; + + // `useLayoutEffect` for automation + React.useLayoutEffect(() => { + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [onClose]); +}; diff --git a/modules/popup/react/lib/useCloseOnOutsideClick.ts b/modules/popup/react/lib/useCloseOnOutsideClick.ts new file mode 100644 index 0000000000..6abbd7f160 --- /dev/null +++ b/modules/popup/react/lib/useCloseOnOutsideClick.ts @@ -0,0 +1,35 @@ +import React from 'react'; + +import {PopupStack} from '@workday/canvas-kit-popup-stack'; + +/** + * Registers global listener for all clicks. It will only call the `onClose` callback if the click + * happened outside the `ref` element and its children _and_ the provided `ref` element is the + * topmost in the stack. The `ref` should be the same as the one passed to `usePopupStack` or the + * `Popper` component since `Popper` uses `usePopupStack` internally. + * @param ref + * @param onClose + */ +export const useCloseOnOutsideClick = ( + ref: React.RefObject, + onClose: () => void +) => { + const onClick = (event: MouseEvent) => { + if ( + PopupStack.isTopmost(ref.current!) && + // Use `PopupStack.contains` instead of `ref.current.contains` so that the application can + // decide if clicking the target should toggle the popup rather than it toggling implicitly + // because the target is outside `ref.current` + !PopupStack.contains(ref.current!, event.target as HTMLElement) + ) { + onClose(); + } + }; + React.useLayoutEffect(() => { + document.addEventListener('click', onClick); + + return () => { + document.removeEventListener('click', onClick); + }; + }); +}; diff --git a/modules/popup/react/lib/useFocusTrap.ts b/modules/popup/react/lib/useFocusTrap.ts new file mode 100644 index 0000000000..0c0655cf8a --- /dev/null +++ b/modules/popup/react/lib/useFocusTrap.ts @@ -0,0 +1,29 @@ +import React from 'react'; +import tabTrappingKey from 'focus-trap-js'; + +const useKeyDownListener = (onKeyDown: EventListenerOrEventListenerObject) => { + // `useLayoutEffect` for automation + React.useLayoutEffect(() => { + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [onKeyDown]); +}; + +/** + * "Trap" or "loop" focus within a provided `ref` element. This is required for accessibility on + * modals. If a keyboard users hits the Tab or Shift + Tab, this will force "looping" of focus. It + * effectively "hides" outside content from keyboard users. Use an overlay to hide content from mouse + * users and `useAssistiveHideSiblings` to hide content from assistive technology users. + * @param ref The element ref you wish to trap focus into + */ +export const useFocusTrap = (ref: React.RefObject) => { + const handleKeydown = (event: KeyboardEvent) => { + if (ref.current) { + tabTrappingKey(event, ref.current); + } + }; + + useKeyDownListener(handleKeydown); +}; diff --git a/modules/popup/react/lib/usePopupStack.ts b/modules/popup/react/lib/usePopupStack.ts new file mode 100644 index 0000000000..63db86bf2e --- /dev/null +++ b/modules/popup/react/lib/usePopupStack.ts @@ -0,0 +1,30 @@ +import React from 'react'; + +import {PopupStack} from '@workday/canvas-kit-popup-stack'; + +/** + * This hook will add a `ref` element to the Popup stack on mount and remove on unmount. If you use + * `Popper`, the popper `ref` is automatically added/removed from the Popup stack. The Popup stack is + * required for proper z-index values to ensure Popups are rendered correct. It is also required for + * global listeners like click outside or escape key closing a popup. Without the Popup stack, all + * popups will close rather than only the topmost one. + * @param ref Ref of the element that is considered the container for a Popup. Usually the `Popup` component + * @param target Usually the trigger of a popup. This will fix `bringToTop` and should be provided by all + * ephemeral-type popups (like Tooltips, Select menus, etc). It will also add in clickOutside detection + */ +export const usePopupStack = ( + ref: React.RefObject, + target?: HTMLElement +): void => { + // We useLayoutEffect to ensure proper timing of registration + // of the element to the popup stack. Without this, the timing is unpredictable + // when mixed with other frameworks. Other frameworks should also register as soon + // as the element is available + React.useLayoutEffect(() => { + PopupStack.add({element: ref.current!, owner: target}); + + return () => { + PopupStack.remove(ref.current!); + }; + }, [ref]); +}; diff --git a/modules/popup/react/package.json b/modules/popup/react/package.json index 91ea6584ff..a5fffafb7d 100644 --- a/modules/popup/react/package.json +++ b/modules/popup/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-react-popup", - "version": "3.9.0", + "version": "4.0.0", "description": "Popup to display some content on top of another", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,16 +15,25 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -35,20 +44,24 @@ "workday", "popup" ], - "devDependencies": { - "@workday/canvas-kit-labs-react-core": "^3.9.0" - }, "dependencies": { "@emotion/core": "^10.0.28", "@emotion/is-prop-valid": "^0.8.2", "@emotion/styled": "^10.0.27", - "@workday/canvas-kit-react-button": "^3.9.0", - "@workday/canvas-kit-react-card": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", + "@popperjs/core": "^2.1.1", + "@workday/canvas-kit-popup-stack": "^4.0.0", + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-card": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", "@workday/canvas-system-icons-web": "^1.0.20", + "focus-trap-js": "1.0.8", "uuid": "^3.3.3" }, + "devDependencies": { + "@workday/canvas-kit-labs-react-core": "^4.0.0", + "lodash": "^4.17.14" + }, "peerDependencies": { "react": ">= 16.8 < 17" } diff --git a/modules/popup/react/spec/Popper.spec.tsx b/modules/popup/react/spec/Popper.spec.tsx new file mode 100644 index 0000000000..0f242543a0 --- /dev/null +++ b/modules/popup/react/spec/Popper.spec.tsx @@ -0,0 +1,79 @@ +/// + +import React from 'react'; +import {render, getByTestId} from '@testing-library/react'; + +import {Popper} from '../'; + +describe('Popper', () => { + it('should portal the popper content', () => { + render(
Anchor
); + const anchorElement = getByTestId(document.body, 'anchor'); + + const {container} = render( + + Contents + + ); + + const popper = getByTestId(document.body, 'popper'); + + expect(container).not.toContainElement(popper); + }); + + it('should not portal the popper when `portal` is set to `false`', () => { + render(
Anchor
); + const anchorElement = getByTestId(document.body, 'anchor'); + + const {container} = render( + + Contents + + ); + + const popper = getByTestId(document.body, 'popper'); + + expect(container).toContainElement(popper); + }); + + it('should render children', () => { + const {getByTestId} = render( + + Contents + + ); + + expect(getByTestId('popper')).toContainHTML('Contents'); + }); + + it('should render children as a render prop', () => { + const {getByTestId} = render( + + {() => 'Contents'} + + ); + + expect(getByTestId('popper')).toContainHTML('Contents'); + }); + + it('should call the children render prop with the placement', () => { + const renderProp = jest.fn(); + render( + + {renderProp} + + ); + + expect(renderProp).toBeCalledWith({placement: 'bottom'}); + }); + + it('should forward extra properties to the containing element', () => { + const {getByTestId} = render( + + Contents + + ); + + expect(getByTestId('popper')).toHaveAttribute('data-extra', 'test'); + }); +}); diff --git a/modules/popup/react/spec/Popup.spec.tsx b/modules/popup/react/spec/Popup.spec.tsx index b17ee72669..56890f6b63 100644 --- a/modules/popup/react/spec/Popup.spec.tsx +++ b/modules/popup/react/spec/Popup.spec.tsx @@ -26,7 +26,7 @@ describe('Popup', () => { it('should render popup with a close button', () => { const closeButtonAriaLabel = 'close'; const {getByRole} = render( - +
Are you sure you'd like to delete the item titled 'My Item'?
@@ -42,7 +42,7 @@ describe('Popup', () => { it('should call the handleClose callback when clicked', () => { const closeButtonAriaLabel = 'close'; const {getByRole} = render( - +
Are you sure you'd like to delete the item titled 'My Item'?
diff --git a/modules/popup/react/spec/getTransformFromPopper.spec.ts b/modules/popup/react/spec/getTransformFromPopper.spec.ts new file mode 100644 index 0000000000..55d988ee2b --- /dev/null +++ b/modules/popup/react/spec/getTransformFromPopper.spec.ts @@ -0,0 +1,62 @@ +import {getTransformFromPlacement} from '../lib/getTransformFromPlacement'; +import each from 'lodash/each'; +import {Placement} from '@popperjs/core'; + +describe('getTransformFromPlacement', () => { + const io = { + bottom: { + vertical: 'top', + horizontal: 'center', + }, + 'bottom-end': { + vertical: 'top', + horizontal: 'left', + }, + 'bottom-start': { + vertical: 'top', + horizontal: 'right', + }, + left: { + vertical: 'center', + horizontal: 'right', + }, + 'left-end': { + vertical: 'top', + horizontal: 'right', + }, + 'left-start': { + vertical: 'bottom', + horizontal: 'right', + }, + right: { + vertical: 'center', + horizontal: 'left', + }, + 'right-end': { + vertical: 'top', + horizontal: 'left', + }, + 'right-start': { + vertical: 'bottom', + horizontal: 'left', + }, + top: { + vertical: 'bottom', + horizontal: 'center', + }, + 'top-end': { + vertical: 'bottom', + horizontal: 'left', + }, + 'top-start': { + vertical: 'bottom', + horizontal: 'right', + }, + } as const; + + each(io, (expected, placement: Placement) => { + it(`given a placement of '${placement}' should return vertical of '${expected.vertical}' and horizontal of '${expected.horizontal}'`, () => { + expect(getTransformFromPlacement(placement)).toEqual(expected); + }); + }); +}); diff --git a/modules/popup/react/stories/stories.tsx b/modules/popup/react/stories/stories.tsx index bf26f4e79c..f52f1e1815 100644 --- a/modules/popup/react/stories/stories.tsx +++ b/modules/popup/react/stories/stories.tsx @@ -1,107 +1,65 @@ /// import * as React from 'react'; -import {storiesOf} from '@storybook/react'; import withReadme from 'storybook-readme/with-readme'; -import {Button} from '@workday/canvas-kit-react-button'; -import {Popper} from '@workday/canvas-kit-react-common'; -import {Popup} from '../index'; -import README from '../README.md'; - -interface PopupWrapperState { - open: boolean; - anchorEl: HTMLElement | null; -} +import {Button, DeleteButton} from '@workday/canvas-kit-react-button'; +import { + Popper, + Popup, + usePopup, + useCloseOnEscape, + useCloseOnOutsideClick, +} from '@workday/canvas-kit-react-popup'; -class PopupWrapper extends React.Component<{}, PopupWrapperState> { - state = { - open: false, - anchorEl: null, - }; - - public render() { - const {anchorEl, open} = this.state; - return ( -
- - - -
- Are you sure you'd like to delete the item titled 'My Item'? -
+import README from '../README.md'; - - -
-
-
- ); - } +export default { + title: 'Components|Popups/Popup/React', + component: Popup, + decorators: [withReadme(README)], +}; - private handleClose = () => { - this.setState({ - open: false, - }); - }; +export const Default = () => { + const {targetProps, closePopup, popperProps} = usePopup(); - private handleSubmit = () => { - this.setState({ - open: false, - }); - }; + useCloseOnOutsideClick(popperProps.ref, closePopup); + useCloseOnEscape(popperProps.ref, closePopup); - private handleClick = (e: React.SyntheticEvent) => { - const {currentTarget} = e; - this.setState({ - anchorEl: currentTarget, - open: !this.state.open, - }); - }; -} + return ( +
+ Delete Item + + +
+ Are you sure you'd like to delete the item titled 'My Item'? +
-storiesOf('Components|Popups/Popup/React', module) - .addParameters({component: Popup}) - .addDecorator(withReadme(README)) - .add('Default', () => ( -
- + + Delete + + + +
- )) - .add( - 'Open', - () => ( - null}> -
- Are you sure you'd like to delete the item titled 'My Item'? -
+ ); +}; + +export const Open = () => { + return ( + null}> +
+ Are you sure you'd like to delete the item titled 'My Item'? +
- - -
- ), - { - chromatic: { - viewports: [320], - pauseAnimationAtEnd: true, - }, - } + null}> + Delete + + +
); +}; diff --git a/modules/popup/react/stories/stories_Popper.tsx b/modules/popup/react/stories/stories_Popper.tsx new file mode 100644 index 0000000000..8e05890ac7 --- /dev/null +++ b/modules/popup/react/stories/stories_Popper.tsx @@ -0,0 +1,48 @@ +/// +import * as React from 'react'; +import withReadme from 'storybook-readme/with-readme'; + +import {Button} from '@workday/canvas-kit-react-button'; +import { + Popup, + Popper, + useCloseOnEscape, + useCloseOnOutsideClick, +} from '@workday/canvas-kit-react-popup'; + +import README from '../README.md'; + +export default { + title: 'Components|Popups/Popper/React', + component: Popper, + decorators: [withReadme(README)], +}; + +export const PopperStory = () => { + const [open, setOpen] = React.useState(false); + const buttonRef = React.useRef(null); + const popupRef = React.useRef(null); + + const onClickButton = () => setOpen(!open); + const onClose = () => setOpen(false); + + useCloseOnOutsideClick(popupRef, onClose); + useCloseOnEscape(popupRef, onClose); + + return ( +
+ + + +

Welcome to your popup positioned by Popper!

+
+
+
+ ); +}; + +PopperStory.story = { + name: 'Popper', +}; diff --git a/modules/popup/react/stories/stories_testing.tsx b/modules/popup/react/stories/stories_testing.tsx new file mode 100644 index 0000000000..76d8d62483 --- /dev/null +++ b/modules/popup/react/stories/stories_testing.tsx @@ -0,0 +1,35 @@ +/// +import * as React from 'react'; + +import {Button} from '@workday/canvas-kit-react-button'; +import {Popper, Popup, usePopup, useCloseOnOutsideClick} from '@workday/canvas-kit-react-popup'; + +export default { + title: 'Testing|React/Popups/Popup', + component: Popup, +}; + +export const MultiplePopups = () => { + const popup1 = usePopup(); + const popup2 = usePopup(); + + useCloseOnOutsideClick(popup1.popperProps.ref, popup1.closePopup); + useCloseOnOutsideClick(popup2.popperProps.ref, popup2.closePopup); + + return ( + <> + + + + Contents of Popup 1 + + + + + + Contents of Popup 2 + + + + ); +}; diff --git a/modules/radio/css/package.json b/modules/radio/css/package.json index 0bc564fc6a..c6e85e8b36 100644 --- a/modules/radio/css/package.json +++ b/modules/radio/css/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-css-radio", - "version": "3.8.0", + "version": "4.0.0", "description": "The radio css for canvas-kit-css", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "workday" ], "dependencies": { - "@workday/canvas-kit-css-common": "^3.8.0", - "@workday/canvas-kit-css-core": "^3.8.0" + "@workday/canvas-kit-css-common": "^4.0.0", + "@workday/canvas-kit-css-core": "^4.0.0" } } diff --git a/modules/radio/react/README.md b/modules/radio/react/README.md index 1d804892db..b41f1417da 100644 --- a/modules/radio/react/README.md +++ b/modules/radio/react/README.md @@ -97,7 +97,7 @@ Default: A uniquely generated id --- -#### `onChange: (e: React.SyntheticEvent) => void` +#### `onChange: (e: React.ChangeEvent) => void` > A callback that gets called everytime the radio input state changes. diff --git a/modules/radio/react/lib/Radio.tsx b/modules/radio/react/lib/Radio.tsx index e688d80405..8c7c455e25 100644 --- a/modules/radio/react/lib/Radio.tsx +++ b/modules/radio/react/lib/Radio.tsx @@ -1,20 +1,24 @@ import * as React from 'react'; -import {styled, Themeable} from '@workday/canvas-kit-labs-react-core'; -import {themedFocusRing, mouseFocusBehavior} from '@workday/canvas-kit-react-common'; +import { + focusRing, + mouseFocusBehavior, + styled, + Themeable, + uniqueId, +} from '@workday/canvas-kit-react-common'; import canvas, { borderRadius, colors, inputColors, spacingNumbers as spacing, } from '@workday/canvas-kit-react-core'; -import uuid from 'uuid/v4'; export interface RadioProps extends Themeable, React.InputHTMLAttributes { /** * If true, set the Radio button to the checked state. * @default false */ - checked: boolean; + checked?: boolean; /** * If true, set the Radio button to the disabled state. * @default false @@ -22,7 +26,7 @@ export interface RadioProps extends Themeable, React.InputHTMLAttributes void; + onChange?: (e: React.ChangeEvent) => void; /** * The value of the Radio button. */ @@ -100,7 +104,18 @@ const RadioInput = styled('input')( outline: 'none', }, }, - ({checked, disabled, theme}) => ({ + ({ + checked, + disabled, + theme: { + canvas: { + palette: { + primary: themePrimary, + common: {focusOutline: themeFocusOutline}, + }, + }, + }, + }) => ({ cursor: disabled ? undefined : 'pointer', /** * These selectors are targetting various sibling elements (~) here because @@ -121,12 +136,12 @@ const RadioInput = styled('input')( // input (which is visually hidden) '&:hover ~ div:first-of-type': { backgroundColor: checked - ? theme.palette.primary.main + ? themePrimary.main : disabled ? inputColors.disabled.background : 'white', borderColor: checked - ? theme.palette.primary.main + ? themePrimary.main : disabled ? inputColors.disabled.border : inputColors.hoverBorder, @@ -134,21 +149,21 @@ const RadioInput = styled('input')( }, '&:focus, &focus:hover': { '& ~ div:first-of-type': { - borderColor: checked ? theme.palette.primary.main : theme.palette.common.focusOutline, + borderColor: checked ? themePrimary.main : themeFocusOutline, borderWidth: '2px', }, }, '&:checked:focus ~ div:first-of-type': { - ...themedFocusRing(theme, {width: 2, separation: 2}), + ...focusRing({separation: 2, outerColor: themeFocusOutline}), }, ...mouseFocusBehavior({ '&:focus ~ div:first-of-type': { - ...themedFocusRing(theme, {width: 0}), + ...focusRing({width: 0, outerColor: themeFocusOutline}), borderWidth: '1px', - borderColor: checked ? theme.palette.primary.main : inputColors.border, + borderColor: checked ? themePrimary.main : inputColors.border, }, '&:focus:hover ~ div:first-of-type, &:focus:active ~ div:first-of-type': { - borderColor: checked ? theme.palette.primary.main : inputColors.hoverBorder, + borderColor: checked ? themePrimary.main : inputColors.hoverBorder, }, }), }) @@ -171,14 +186,22 @@ const RadioBackground = styled('div')( transition: 'border 200ms ease, background 200ms', width: radioWidth, }, - ({checked, disabled, theme}) => ({ + ({ + checked, + disabled, + theme: { + canvas: { + palette: {primary: themePrimary}, + }, + }, + }) => ({ borderColor: checked - ? theme.palette.primary.main + ? themePrimary.main : disabled ? inputColors.disabled.border : inputColors.border, backgroundColor: checked - ? theme.palette.primary.main + ? themePrimary.main : disabled ? inputColors.disabled.background : 'white', @@ -187,7 +210,6 @@ const RadioBackground = styled('div')( const RadioCheck = styled('div')>( { - backgroundColor: colors.frenchVanilla100, borderRadius: radioBorderRadius, display: 'flex', flexDirection: 'column', @@ -196,6 +218,9 @@ const RadioCheck = styled('div')>( transition: 'transform 200ms ease, opacity 200ms ease', width: radioDot, }, + ({theme}) => ({ + backgroundColor: theme.canvas.palette.primary.contrast, + }), ({checked}) => ({ opacity: checked ? 1 : 0, transform: checked ? 'scale(1)' : 'scale(0.5)', @@ -210,54 +235,42 @@ const RadioLabel = styled('label')<{disabled?: boolean}>( ({disabled}) => (disabled ? {color: inputColors.disabled.text} : {cursor: 'pointer'}) ); -export default class Radio extends React.Component { - public static defaultProps = { - checked: false, - label: '', - }; - - private id = uuid(); - - public render() { - // TODO: Standardize on prop spread location (see #150) - const { - checked, - disabled, - id = this.id, - inputRef, - label, - name, - onChange, - value, - ...elemProps - } = this.props; +export const Radio = ({ + checked = false, + id = uniqueId(), + label = '', + disabled, + inputRef, + name, + onChange, + value, + ...elemProps +}: RadioProps) => ( + + + + + + + + + {label && ( + + {label} + + )} + +); - return ( - - - - - - - - - {label && ( - - {label} - - )} - - ); - } -} +export default Radio; diff --git a/modules/radio/react/lib/RadioGroup.tsx b/modules/radio/react/lib/RadioGroup.tsx index cedc72e396..9feae41923 100644 --- a/modules/radio/react/lib/RadioGroup.tsx +++ b/modules/radio/react/lib/RadioGroup.tsx @@ -1,8 +1,13 @@ import * as React from 'react'; -import {styled, Themeable} from '@workday/canvas-kit-labs-react-core'; import Radio, {RadioProps} from './Radio'; import {borderRadius, spacing} from '@workday/canvas-kit-react-core'; -import {ErrorType, GrowthBehavior, getErrorColors} from '@workday/canvas-kit-react-common'; +import { + ErrorType, + GrowthBehavior, + getErrorColors, + styled, + Themeable, +} from '@workday/canvas-kit-react-common'; export interface RadioGroupProps extends Themeable, GrowthBehavior { /** @@ -61,26 +66,23 @@ const Container = styled('div') { static ErrorType = ErrorType; - static defaultProps = { - value: 0, - }; - render(): React.ReactNode { - const {children, error, onChange, value, grow, ...elemProps} = this.props; + const {value = 0, children, error, onChange, grow, ...elemProps} = this.props; return ( - {React.Children.map(children, this.renderChild)} + {React.Children.map(children, (child, index) => this.renderChild(child, index, value))} ); } - private renderChild = (child: React.ReactElement, index: number): React.ReactNode => { + private renderChild = ( + child: React.ReactElement, + index: number, + value: string | number + ): React.ReactNode => { if (typeof child.type === typeof Radio) { const childProps = child.props; - const checked = - typeof this.props.value === 'number' - ? index === this.props.value - : childProps.value === this.props.value; + const checked = typeof value === 'number' ? index === value : childProps.value === value; const name = this.props.name ? this.props.name : childProps.name; return React.cloneElement(child, { @@ -94,9 +96,9 @@ export default class RadioGroup extends React.Component { }; private onRadioChange = ( - existingOnChange: (e: React.SyntheticEvent) => void | undefined, + existingOnChange: (e: React.ChangeEvent) => void | undefined, index: number, - event: React.MouseEvent + event: React.ChangeEvent ): void => { if (existingOnChange) { existingOnChange(event); diff --git a/modules/radio/react/package.json b/modules/radio/react/package.json index f8a2a3daab..85c39ebcb5 100644 --- a/modules/radio/react/package.json +++ b/modules/radio/react/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-react-radio", - "version": "3.9.0", + "version": "4.0.0", "description": "A Canvas-styled radio", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -15,16 +15,25 @@ "files": [ "dist/", "lib/", - "index.ts" + "index.ts", + "ts3.5/**/*" ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, "scripts": { "watch": "yarn build:es6 -w", "test": "echo \"Error: no test specified\" && exit 1", - "clean": "rimraf dist && rimraf .build-info && mkdirp dist", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", "build:cjs": "tsc -p tsconfig.cjs.json", "build:es6": "tsc -p tsconfig.es6.json", "build:rebuild": "npm-run-all clean build", - "build": "npm-run-all --parallel build:cjs build:es6", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", "depcheck": "node ../../../utils/check-dependencies-exist.js" }, "keywords": [ @@ -39,9 +48,7 @@ "react": ">= 16.8 < 17" }, "dependencies": { - "@workday/canvas-kit-labs-react-core": "^3.9.0", - "@workday/canvas-kit-react-common": "^3.9.0", - "@workday/canvas-kit-react-core": "^3.9.0", - "uuid": "^3.3.3" + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0" } } diff --git a/modules/segmented-control/react/LICENSE b/modules/segmented-control/react/LICENSE new file mode 100644 index 0000000000..0edd43cffc --- /dev/null +++ b/modules/segmented-control/react/LICENSE @@ -0,0 +1,51 @@ +Apache License, Version 2.0 Apache License Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: +You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +©2019. Workday, Inc. All rights reserved. Workday and the Workday logo are registered trademarks of Workday, Inc. All other brand and product names are trademarks or registered trademarks of their respective holders. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/modules/segmented-control/react/README.md b/modules/segmented-control/react/README.md new file mode 100644 index 0000000000..af3f901947 --- /dev/null +++ b/modules/segmented-control/react/README.md @@ -0,0 +1,59 @@ +# Canvas Kit React Segmented Control + +A linear set of two or more segments, each of which functions as a mutually exclusive button. This +is a + +> [_controlled_](https://reactjs.org/docs/forms.html#controlled-components) component. + +## Installation + +```sh +yarn add @workday/canvas-kit-react-segmented-control +``` + +## Usage + +```tsx +import * as React from 'react'; +import {SegmentedControl} from '@workday/canvas-kit-labs-react-segmented-control'; +import {IconButton} from '@workday/canvas-kit-react-button'; +import {listViewIcon, worksheetsIcon} from '@workday/canvas-system-icons-web'; + + + + +; +``` + +**Note:** while managing state using a unique `value` for each `IconButton` child is encouraged, you +can also use indexes and omit the `value` field. It is strongly recommended to not mix these two +methods. + +## Static Properties + +> None + +## Component Props + +### Required + +#### `children: React.ReactElement[]` + +> Icon buttons to toggle between. + +--- + +### Optional + +#### `value: string | number` + +> Identify which item is selected (toggled=true). If a string is passed, the IconButton with the +> corresponding value will be selected. If a number is passed, the IconButton with the corresponding +> index will be selected. + +--- + +#### `onChange: (value:string | number)=> void` + +> Callback function when a toggle button is selected. The value (if defined) or the index of the +> button will be returned. diff --git a/modules/segmented-control/react/index.ts b/modules/segmented-control/react/index.ts new file mode 100644 index 0000000000..cb3f4cbd24 --- /dev/null +++ b/modules/segmented-control/react/index.ts @@ -0,0 +1,5 @@ +import SegmentedControl from './lib/SegmentedControl'; + +export default SegmentedControl; +export {SegmentedControl}; +export * from './lib/SegmentedControl'; diff --git a/modules/segmented-control/react/lib/SegmentedControl.tsx b/modules/segmented-control/react/lib/SegmentedControl.tsx new file mode 100644 index 0000000000..6ade205018 --- /dev/null +++ b/modules/segmented-control/react/lib/SegmentedControl.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import {borderRadius, colors} from '@workday/canvas-kit-react-core'; +import {IconButton, IconButtonProps} from '@workday/canvas-kit-react-button'; +import {mouseFocusBehavior, styled} from '@workday/canvas-kit-react-common'; + +export interface SegmentedControlProps { + /** + * The IconButton children of the SegmentedControl (must be at least two). + * TODO: Add support for text children + */ + children: React.ReactElement[]; + + /** + * The value or index of the IconButton that the SegmentedControl should be toggled on to. + * If a string is provided, the IconButton with the corresponding value will be selected. + * If a number is provided, the IconButton with the corresponding index will be selected. + * @default 0 + */ + value?: string | number; + + /** + * The function called when a button in the SegmentedControl is toggled. + * If the selected button has a value, that value will be passed to the callback function; + * otherwise, the index of the button will be passed. + */ + onChange?: (value: string | number) => void; +} + +const SegmentedControlContainer = styled('div')( + { + '& button': { + borderRadius: borderRadius.zero, + border: `1px solid ${colors.soap500}`, + borderLeft: 'none', + '&[aria-pressed="true"]': { + borderColor: colors.blueberry400, + '&:hover, &:focus:hover': { + background: colors.blueberry400, + }, + }, + '&:first-of-type': { + borderRadius: `${borderRadius.m} 0 0 ${borderRadius.m}`, + borderLeft: `1px solid ${colors.soap500}`, + }, + '&:last-of-type': { + borderRadius: `0 ${borderRadius.m} ${borderRadius.m} 0`, + }, + '&:focus': { + borderRadius: borderRadius.m, + zIndex: 1, + animation: 'none', // reset focusRing animation + transition: 'all 120ms, border-radius 1ms', + ...mouseFocusBehavior({ + '&': { + borderRadius: borderRadius.zero, + '&:first-of-type': { + borderRadius: `${borderRadius.m} 0 0 ${borderRadius.m}`, + }, + '&:last-of-type': { + borderRadius: `0 ${borderRadius.m} ${borderRadius.m} 0`, + }, + }, + }), + }, + }, + }, + ({theme}) => ({ + '& button[aria-pressed="true"]': { + borderColor: theme.canvas.palette.primary.main, + '&:hover, &:focus:hover': { + background: theme.canvas.palette.primary.main, + }, + }, + }) +); + +const onButtonClick = ( + existingOnClick: (e: React.SyntheticEvent) => void | undefined, + onChange: (value: string | number) => void, + index: number, + event: React.MouseEvent +): void => { + if (existingOnClick) { + existingOnClick(event); + } + + const target = event.currentTarget; + if (target && onChange) { + if (target.value) { + onChange(target.value); + } else { + onChange(index); + } + } +}; + +const SegmentedControl = ({value = 0, children, onChange, ...elemProps}: SegmentedControlProps) => ( + + {React.Children.map(children, (child: React.ReactElement, index: number) => { + if (typeof child.type === typeof IconButton) { + return React.cloneElement(child, { + toggled: typeof value === 'number' ? index === value : child.props.value === value, + variant: IconButton.Variant.SquareFilled, + onClick: onButtonClick.bind(undefined, child.props.onClick, onChange, index), + }); + } + + return child; + })} + +); + +export default SegmentedControl; diff --git a/modules/segmented-control/react/package.json b/modules/segmented-control/react/package.json new file mode 100644 index 0000000000..6606b09521 --- /dev/null +++ b/modules/segmented-control/react/package.json @@ -0,0 +1,58 @@ +{ + "name": "@workday/canvas-kit-react-segmented-control", + "version": "4.0.0", + "description": "A linear set of two or more segments, each of which functions as a mutually exclusive button", + "author": "Workday, Inc. (https://www.workday.com)", + "license": "Apache-2.0", + "main": "dist/commonjs/index.js", + "module": "dist/es6/index.js", + "sideEffects": false, + "types": "dist/es6/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/Workday/canvas-kit/tree/master/modules/segmented-control/react" + }, + "files": [ + "dist/", + "lib/", + "index.ts", + "ts3.5/**/*" + ], + "typesVersions": { + "<=3.5": { + "*": [ + "ts3.5/*" + ] + } + }, + "scripts": { + "watch": "yarn build:es6 -w", + "clean": "rimraf dist && rimraf ts3.5 && rimraf .build-info && mkdirp dist && mkdirp ts3.5/dist", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:es6": "tsc -p tsconfig.es6.json", + "build:rebuild": "npm-run-all clean build", + "build:downlevel-dts": "yarn run downlevel-dts dist ts3.5/dist", + "build": "npm-run-all --parallel build:cjs build:es6 --sequential build:downlevel-dts", + "depcheck": "node ../../../utils/check-dependencies-exist.js" + }, + "keywords": [ + "canvas", + "canvas-kit", + "react", + "components", + "workday", + "segmented-control" + ], + "peerDependencies": { + "react": ">= 16.8 < 17" + }, + "dependencies": { + "@workday/canvas-kit-react-button": "^4.0.0", + "@workday/canvas-kit-react-common": "^4.0.0", + "@workday/canvas-kit-react-core": "^4.0.0", + "@workday/canvas-system-icons-web": "^1.0.20" + }, + "devDependencies": { + "@workday/canvas-kit-labs-react-core": "^4.0.0" + } +} diff --git a/modules/segmented-control/react/spec/SegmentedControl.spec.tsx b/modules/segmented-control/react/spec/SegmentedControl.spec.tsx new file mode 100644 index 0000000000..60a68b49be --- /dev/null +++ b/modules/segmented-control/react/spec/SegmentedControl.spec.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import {render, fireEvent} from '@testing-library/react'; +import SegmentedControl from '../lib/SegmentedControl'; +import {IconButton} from '@workday/canvas-kit-react-button'; +import {listViewIcon, worksheetsIcon} from '@workday/canvas-system-icons-web'; + +describe('Segmented Control', () => { + const cb = jest.fn(); + afterEach(() => { + cb.mockReset(); + }); + + describe('when rendered', () => { + it('should render two icon buttons', () => { + const {getAllByRole} = render( + + + + + ); + expect(getAllByRole('button').length).toEqual(2); + }); + }); + + describe('when clicked', () => { + it('should call SegmentedControl onChange callback', () => { + const {getAllByRole} = render( + + + + {/* ensure random elements don't break anything */} + + ); + fireEvent.click(getAllByRole('button')[1]); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toBe('table-view'); + }); + + it('should preserve existing IconButton onClick callbacks', () => { + const existingCb = jest.fn(); + const {getAllByRole} = render( + + + + + ); + fireEvent.click(getAllByRole('button')[1]); + + expect(cb).toHaveBeenCalledTimes(1); + expect(existingCb).toHaveBeenCalledTimes(1); + }); + + it('disabled buttons should trigger callback', () => { + const {getAllByRole} = render( + + + + + ); + fireEvent.click(getAllByRole('button')[0]); + + expect(cb).toHaveBeenCalledTimes(0); + + fireEvent.click(getAllByRole('button')[1]); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toBe('table-view'); + }); + }); + + describe('when clicked without value', () => { + it('should call callback with index', () => { + const {getAllByRole} = render( + + + + + ); + + fireEvent.click(getAllByRole('button')[1]); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toBe(1); + }); + }); +}); diff --git a/modules/segmented-control/react/stories/stories.tsx b/modules/segmented-control/react/stories/stories.tsx new file mode 100644 index 0000000000..bf449a39c3 --- /dev/null +++ b/modules/segmented-control/react/stories/stories.tsx @@ -0,0 +1,51 @@ +/// +import * as React from 'react'; +import {storiesOf} from '@storybook/react'; +import {action} from '@storybook/addon-actions'; +import withReadme from 'storybook-readme/with-readme'; + +import { + listViewIcon, + worksheetsIcon, + deviceTabletIcon, + percentageIcon, +} from '@workday/canvas-system-icons-web'; + +import {IconButton} from '@workday/canvas-kit-react-button'; +import {SegmentedControl} from '../index'; + +import README from '../README.md'; + +export default { + title: 'Components|Buttons/Segmented Control/React', + component: SegmentedControl, + decorators: [withReadme(README)], +}; + +export const Default = () => { + const [value, setValue] = React.useState(); + const handleToggle = (selectedValue: string | number) => { + setValue(selectedValue); + action('Segmented Control selection change')(selectedValue); + }; + + return ( + + action('Existing IconButton onClick callback')(e)} + /> + + + + + ); +}; diff --git a/modules/segmented-control/react/stories/stories_VisualTesting.tsx b/modules/segmented-control/react/stories/stories_VisualTesting.tsx new file mode 100644 index 0000000000..2d1316d656 --- /dev/null +++ b/modules/segmented-control/react/stories/stories_VisualTesting.tsx @@ -0,0 +1,74 @@ +/// +import * as React from 'react'; +import {storiesOf} from '@storybook/react'; +import {action} from '@storybook/addon-actions'; +import withReadme from 'storybook-readme/with-readme'; +import {StaticStates} from '@workday/canvas-kit-labs-react-core'; +import { + ComponentStatesTable, + permutateProps, + withSnapshotsEnabled, +} from '../../../../utils/storybook'; + +import { + listViewIcon, + worksheetsIcon, + deviceTabletIcon, + percentageIcon, +} from '@workday/canvas-system-icons-web'; + +import {IconButton} from '@workday/canvas-kit-react-button'; +import {SegmentedControl} from '../index'; + +import README from '../README.md'; + +export default withSnapshotsEnabled({ + title: 'Testing|React/Buttons/Segmented Control', + component: SegmentedControl, + decorators: [withReadme(README)], +}); + +export const SegmentedControlStates = () => ( + + + {props => ( + + + + + + + )} + + +); diff --git a/modules/segmented-control/react/tsconfig.cjs.json b/modules/segmented-control/react/tsconfig.cjs.json new file mode 100644 index 0000000000..261641fcc9 --- /dev/null +++ b/modules/segmented-control/react/tsconfig.cjs.json @@ -0,0 +1,11 @@ + +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "module": "commonjs", + "outDir": "dist/commonjs", + "skipLibCheck": true, + "tsBuildInfoFile": "./.build-info/tsconfig.cjs.tsbuildinfo" + } +} diff --git a/modules/segmented-control/react/tsconfig.es6.json b/modules/segmented-control/react/tsconfig.es6.json new file mode 100644 index 0000000000..cdf8edae25 --- /dev/null +++ b/modules/segmented-control/react/tsconfig.es6.json @@ -0,0 +1,9 @@ + +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist/es6", + "tsBuildInfoFile": "./.build-info/tsconfig.es6.tsbuildinfo" + } +} diff --git a/modules/segmented-control/react/tsconfig.json b/modules/segmented-control/react/tsconfig.json new file mode 100644 index 0000000000..cbd523c703 --- /dev/null +++ b/modules/segmented-control/react/tsconfig.json @@ -0,0 +1,5 @@ + +{ + "extends": "../../../tsconfig.json", + "exclude": ["node_modules", "ts-tmp", "dist", "spec", "stories"] +} diff --git a/modules/select/css/package.json b/modules/select/css/package.json index c46fd959c9..3ead456e45 100644 --- a/modules/select/css/package.json +++ b/modules/select/css/package.json @@ -1,6 +1,6 @@ { "name": "@workday/canvas-kit-css-select", - "version": "3.8.0", + "version": "4.0.0", "description": "The select css for canvas-kit-css", "author": "Workday, Inc. (https://www.workday.com)", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "workday" ], "dependencies": { - "@workday/canvas-kit-css-common": "^3.8.0", - "@workday/canvas-kit-css-core": "^3.8.0" + "@workday/canvas-kit-css-common": "^4.0.0", + "@workday/canvas-kit-css-core": "^4.0.0" } } diff --git a/modules/select/react/lib/Select.tsx b/modules/select/react/lib/Select.tsx index 0196f6391f..11ef5435d0 100644 --- a/modules/select/react/lib/Select.tsx +++ b/modules/select/react/lib/Select.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; -import {styled, Themeable} from '@workday/canvas-kit-labs-react-core'; -import {GrowthBehavior, ErrorType, errorRing} from '@workday/canvas-kit-react-common'; +import { + GrowthBehavior, + ErrorType, + errorRing, + styled, + Themeable, +} from '@workday/canvas-kit-react-common'; import { colors, borderRadius, @@ -33,7 +38,7 @@ export interface SelectProps /** * The function called when the Select state changes. */ - onChange?: React.ChangeEventHandler; + onChange?: (e: React.ChangeEvent) => void; /** * The value of the Select. */ @@ -115,13 +120,9 @@ const SelectWrapper = styled('div')>( export default class Select extends React.Component { static ErrorType = ErrorType; - static defaultProps = { - disabled: false, - }; - public render() { // TODO: Standardize on prop spread location (see #150) - const {error, disabled, grow, children, value, onChange, ...elemProps} = this.props; + const {disabled = false, error, grow, children, value, onChange, ...elemProps} = this.props; return ( diff --git a/modules/select/react/lib/SelectOption.tsx b/modules/select/react/lib/SelectOption.tsx index f5ba6b9137..f0e9610130 100644 --- a/modules/select/react/lib/SelectOption.tsx +++ b/modules/select/react/lib/SelectOption.tsx @@ -17,16 +17,12 @@ export interface SelectOptionProps extends React.OptionHTMLAttributes { - static defaultProps = { - disabled: false, - }; - public render() { - const {value, label, disabled, ...elemProps} = this.props; + const {disabled = false, value, label, ...elemProps} = this.props; return (