diff --git a/.github/workflows/octuple-publish-alerts.yml b/.github/workflows/octuple-publish-alerts.yml index 612cb8b22..a49d53166 100644 --- a/.github/workflows/octuple-publish-alerts.yml +++ b/.github/workflows/octuple-publish-alerts.yml @@ -7,6 +7,8 @@ on: types: - completed +env: + NODE_VERSION: 16.14.2 jobs: build: runs-on: ubuntu-latest @@ -25,8 +27,21 @@ jobs: - name: Get version from tag id: tag_name run: | - echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} + echo ::set-output name=current_version::${{ github.event.workflow_run.head_commit.tag_name }} shell: bash + - name: Prerelease check + id: prerelease_check + if: contains(steps.tag_name.outputs.current_version, '-') + run: | + echo ::set-output name=prerelease::true + - name: Checkout code + uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org/ + - name: Run Yarn + run: yarn - name: Get Changelog Entry id: changelog_reader uses: artlaman/conventional-changelog-reader-action@v1.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d31e7472..d88cc1481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,91 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [2.33.0](https://github.com/EightfoldAI/octuple/compare/v2.32.0...v2.33.0) (2023-03-23) + +### Features + +- cascadingmenu: add cascading menu component ([#566](https://github.com/EightfoldAI/octuple/issues/566)) ([4f7ca8c](https://github.com/EightfoldAI/octuple/commits/4f7ca8c93722b4bc244bbdc8fe30d119fb5c126c)) +- menuitem: adds align icon prop and fixes list item rendering bug ([#563](https://github.com/EightfoldAI/octuple/issues/563)) ([65dde9a](https://github.com/EightfoldAI/octuple/commits/65dde9adfa93fc11375da78c45275a5826688131)) + +### Bug Fixes + +- carousel: only handle wheel event when not using touchpad ([#568](https://github.com/EightfoldAI/octuple/issues/568)) ([045ba0e](https://github.com/EightfoldAI/octuple/commits/045ba0ee413cc9ab9a2099a408d6a19066ce2582)) +- no scrolling buttons when body does not have scroll bar ([#571](https://github.com/EightfoldAI/octuple/issues/571)) ([a64a582](https://github.com/EightfoldAI/octuple/commits/a64a582006ef0eb36be9da4d1e10e8c9145c0b88)) +- panel: body padding prop on panel does not work ([#569](https://github.com/EightfoldAI/octuple/issues/569)) ([f3ddff5](https://github.com/EightfoldAI/octuple/commits/f3ddff5bb484feff92abaea33a21e6fddc7d5eb7)) +- select: options change not reacting properly when value is an object ([#570](https://github.com/EightfoldAI/octuple/issues/570)) ([1cba3fd](https://github.com/EightfoldAI/octuple/commits/1cba3fd140e9c776f6f92d05d57fdafa15516c4b)) +- status item styles, add option to place status icon before status text ([#567](https://github.com/EightfoldAI/octuple/issues/567)) ([cdf866c](https://github.com/EightfoldAI/octuple/commits/cdf866c4fb7678e012212f14600bcc57dba5e8fb)) +- tooltip: fixes pixel position rounding error ([#572](https://github.com/EightfoldAI/octuple/issues/572)) ([4a5a70f](https://github.com/EightfoldAI/octuple/commits/4a5a70f19713abead765443c7d86f0f5f4c23b3d)) + +## [2.32.0](https://github.com/EightfoldAI/octuple/compare/v2.31.0...v2.32.0) (2023-03-15) + +### Bug Fixes + +- adjust navbar colors to match design system ([#561](https://github.com/EightfoldAI/octuple/issues/561)) ([4774be8](https://github.com/EightfoldAI/octuple/commits/4774be8f6e23ee688904736afc99a9d9a3703ef7)) +- upload: adds deferred api story and exports type ([#559](https://github.com/EightfoldAI/octuple/issues/559)) ([921bf1c](https://github.com/EightfoldAI/octuple/commits/921bf1c4f1673d1e52fc4f1ab9ebab30918b3a02)) + +## [2.31.0](https://github.com/EightfoldAI/octuple/compare/v2.29.0...v2.31.0) (2023-03-07) + +### Features + +- configprovider: update context then add locale story and unit tests ([#555](https://github.com/EightfoldAI/octuple/issues/555)) ([2e42f02](https://github.com/EightfoldAI/octuple/commits/2e42f02666cddbc4db78fab81acf60eda0916e67)) +- popup: adds popup component ([#543](https://github.com/EightfoldAI/octuple/issues/543)) ([5912141](https://github.com/EightfoldAI/octuple/commits/5912141e32582cec817837e9b0ff28d8b5dc8b5b)) +- Spaced avatar group style ([#550](https://github.com/EightfoldAI/octuple/issues/550)) ([993d98a](https://github.com/EightfoldAI/octuple/commits/993d98a92ffd9a99614722c16efd60dab4959bd0)) +- table: show scroller on table rows ([#556](https://github.com/EightfoldAI/octuple/issues/556)) ([e5c685c](https://github.com/EightfoldAI/octuple/commits/e5c685c372e0881fc74825200118e845b62532fa)) + +### Bug Fixes + +- carousel: update wheel handler ([#557](https://github.com/EightfoldAI/octuple/issues/557)) ([f2655aa](https://github.com/EightfoldAI/octuple/commits/f2655aa4cb821d009d1a18fca8d10c6634fc39d8)) +- infobar: buttons should have transparent background and inherit color ([#548](https://github.com/EightfoldAI/octuple/issues/548)) ([2e0da40](https://github.com/EightfoldAI/octuple/commits/2e0da401e285af9ff1f9ee0caa08b60628fe1a3d)) +- popup: do not toggle when no content or disabled ([#553](https://github.com/EightfoldAI/octuple/issues/553)) ([12a2e88](https://github.com/EightfoldAI/octuple/commits/12a2e88ee68215f396a4418c3673f5a1ea7bb2e0)) +- select: improves select by adding props and enabling default value array ([#545](https://github.com/EightfoldAI/octuple/issues/545)) ([1b13981](https://github.com/EightfoldAI/octuple/commits/1b1398132026f03ae9a6f8d838f76c1f2bdcfc2c)) +- stepper: Handle decimal scrollLeft value for scroll buttons ([#546](https://github.com/EightfoldAI/octuple/issues/546)) ([49340dd](https://github.com/EightfoldAI/octuple/commits/49340ddaeb8646ae1c011f136dd9dce2e832da25)) + +## [2.30.0](https://github.com/EightfoldAI/octuple/compare/v2.29.0...v2.30.0) (2023-02-24) + +### Features + +- popup: adds popup component ([#543](https://github.com/EightfoldAI/octuple/issues/543)) ([5912141](https://github.com/EightfoldAI/octuple/commits/5912141e32582cec817837e9b0ff28d8b5dc8b5b)) + +### Bug Fixes + +- infobar: buttons should have transparent background and inherit color ([#548](https://github.com/EightfoldAI/octuple/issues/548)) ([2e0da40](https://github.com/EightfoldAI/octuple/commits/2e0da401e285af9ff1f9ee0caa08b60628fe1a3d)) +- popup: do not toggle when no content or disabled ([#553](https://github.com/EightfoldAI/octuple/issues/553)) ([12a2e88](https://github.com/EightfoldAI/octuple/commits/12a2e88ee68215f396a4418c3673f5a1ea7bb2e0)) +- select: improves select by adding props and enabling default value array ([#545](https://github.com/EightfoldAI/octuple/issues/545)) ([1b13981](https://github.com/EightfoldAI/octuple/commits/1b1398132026f03ae9a6f8d838f76c1f2bdcfc2c)) +- stepper: Handle decimal scrollLeft value for scroll buttons ([#546](https://github.com/EightfoldAI/octuple/issues/546)) ([49340dd](https://github.com/EightfoldAI/octuple/commits/49340ddaeb8646ae1c011f136dd9dce2e832da25)) + +## [2.29.0](https://github.com/EightfoldAI/octuple/compare/v2.28.1...v2.29.0) (2023-02-23) + +### Features + +- Avatar status icons ([#539](https://github.com/EightfoldAI/octuple/issues/539)) ([76304f3](https://github.com/EightfoldAI/octuple/commits/76304f3a36f32d41317db5af00d2add4f0f8dc6d)) +- carousel: add single prop and update api to support single item scroll ([#537](https://github.com/EightfoldAI/octuple/issues/537)) ([5ca8835](https://github.com/EightfoldAI/octuple/commits/5ca8835a28c6f18731d3a18b11c0f32d6c7dd47f)) + +### Bug Fixes + +- menulinks: fix font weight ([#551](https://github.com/EightfoldAI/octuple/issues/551)) ([17037dc](https://github.com/EightfoldAI/octuple/commits/17037dc3b90ed289854ced573931f992bfb38b41)) +- nudge: removes unnecessary border calculations in css ([#547](https://github.com/EightfoldAI/octuple/issues/547)) ([a0c01c7](https://github.com/EightfoldAI/octuple/commits/a0c01c7bd7a0b9cd187ceee773555603628c2bb4)) +- skeleton: ensure wave overflow is hidden in safari browser ([#544](https://github.com/EightfoldAI/octuple/issues/544)) ([25b084e](https://github.com/EightfoldAI/octuple/commits/25b084eb82225f89666346494d2e2ef64890e826)) + +### [2.28.1](https://github.com/EightfoldAI/octuple/compare/v2.28.0...v2.28.1) (2023-02-14) + +### Bug Fixes + +- link: isolate css display inline block to variants other than default ([#541](https://github.com/EightfoldAI/octuple/issues/541)) ([0fafb06](https://github.com/EightfoldAI/octuple/commits/0fafb062407c40a5fe419a9b2efdb515a9ed1543)) + +## [2.28.0](https://github.com/EightfoldAI/octuple/compare/v2.27.0...v2.28.0) (2023-02-14) + +### Features + +- add secondary button in menu item ([#540](https://github.com/EightfoldAI/octuple/issues/540)) ([e75f047](https://github.com/EightfoldAI/octuple/commits/e75f0476810a6246f302b94396060cc333ab8f58)) +- link: adds variants with var theme styles and improves api ([#535](https://github.com/EightfoldAI/octuple/issues/535)) ([9539e78](https://github.com/EightfoldAI/octuple/commits/9539e78d7fd33cd0f24fd82264b26ee5717ccbbc)) +- slider: enable basic data comparison slider implementations ([#529](https://github.com/EightfoldAI/octuple/issues/529)) ([7c6bae2](https://github.com/EightfoldAI/octuple/commits/7c6bae277bc7d3e2302122bb4b6c78be218d1dfd)) + +### Bug Fixes + +- do not provide default colors for ConfigProvider ([#538](https://github.com/EightfoldAI/octuple/issues/538)) ([4242cc9](https://github.com/EightfoldAI/octuple/commits/4242cc913ca26d61260e32ceb899c22f1d5885f8)) +- input: ensure id passed from props does not append uuid ([#532](https://github.com/EightfoldAI/octuple/issues/532)) ([3921c72](https://github.com/EightfoldAI/octuple/commits/3921c728963c7afa2890588179e86f67f6501358)) + ## [2.27.0](https://github.com/EightfoldAI/octuple/compare/v2.26.0...v2.27.0) (2023-02-03) ## [2.26.0](https://github.com/EightfoldAI/octuple/compare/v2.25.0...v2.26.0) (2023-02-03) diff --git a/package.json b/package.json index bef37d936..271e0be31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eightfold.ai/octuple", - "version": "2.27.0", + "version": "2.33.0", "license": "MIT", "main": "lib/octuple.js", "types": "lib/octuple.d.ts", @@ -51,8 +51,7 @@ ] }, "dependencies": { - "@floating-ui/react-dom": "0.6.0", - "@floating-ui/react-dom-interactions": "0.6.3", + "@floating-ui/react": "0.20.1", "@mdi/react": "1.6.1", "@ngard/tiny-isequal": "1.1.0", "@types/react-is": "17.0.3", diff --git a/public/assets/GetHelp.png b/public/assets/GetHelp.png index 4579fb6fa..09aee57ae 100644 Binary files a/public/assets/GetHelp.png and b/public/assets/GetHelp.png differ diff --git a/public/assets/NewIssue.png b/public/assets/NewIssue.png index a24b3fa89..873b822c9 100644 Binary files a/public/assets/NewIssue.png and b/public/assets/NewIssue.png differ diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index 9fba7f39f..d79c66daa 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -1,8 +1,16 @@ import React from 'react'; import { Stories } from '@storybook/addon-docs'; -import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; import { IconName } from '../Icon'; -import { Avatar } from './'; +import { + Avatar, + AvatarProps, + getStatusItemSizeAndPadding, + StatusItemIconAlign, + StatusItemsPosition, +} from './'; +import { Stack } from '../Stack'; +import { TooltipTheme } from '../Tooltip'; export default { title: 'Avatar', @@ -52,7 +60,7 @@ const Avatar_Icon_Story: ComponentStory = (args) => ( export const Avatar_Icon = Avatar_Icon_Story.bind({}); const Avatar_Round_Story: ComponentStory = (args) => ( - + ); export const Avatar_Round = Avatar_Round_Story.bind({}); @@ -75,6 +83,163 @@ const Avatar_Fallback_Hashing_Story: ComponentStory = (args) => ( export const Avatar_Fallback_Hashing = Avatar_Fallback_Hashing_Story.bind({}); +const Avatar_StatusItem_Story: ComponentStory = (args) => { + const avatarSize = 100; + const [statusItemSize] = getStatusItemSizeAndPadding(avatarSize); + args.size = `${avatarSize}px`; + + const statusItemProps = { + backgroundColor: 'var(--blue-color-100)', + path: IconName.mdiPencil, + size: `${statusItemSize}px`, + type: 'round', + }; + + const examples: AvatarProps[] = [ + { + children: 'A', + fontSize: '18px', + hashingFunction: () => 0, + size: '32px', + outline: { + // outlineColor: 'var(--green-color-60)', + outlineOffset: '1px', + // outlineStyle: 'solid', + outlineWidth: '2px', + }, + statusItems: { + [StatusItemsPosition.Bottom]: { + ...statusItemProps, + ariaLabel: 'Clock icon', + backgroundColor: 'var(--green-color-20)', + color: 'var(--green-color-70)', + wrapperStyle: { padding: '2px' }, + path: IconName.mdiHome, + size: '6px', + }, + [StatusItemsPosition.TopRight]: { + ...statusItemProps, + ariaLabel: 'Pencil icon', + backgroundColor: 'var(--green-color-20)', + color: 'var(--green-color-70)', + wrapperStyle: { padding: '2px' }, + path: IconName.mdiPencil, + size: '6px', + text: '20', + }, + }, + }, + { + children: 'AB', + fontSize: '48px', + hashingFunction: () => 0, + outline: { + outlineColor: 'var(--blue-color-60)', + outlineOffset: '2px', + outlineStyle: 'solid', + outlineWidth: '4px', + }, + statusItems: { + [StatusItemsPosition.TopRight]: { + ...statusItemProps, + ariaLabel: 'Pencil icon', + backgroundColor: 'var(--red-color-20)', + color: 'var(--red-color-70)', + onClick: () => alert('Clicked pencil icon'), + outline: { + outlineColor: 'var(--red-color-60)', + outlineOffset: '0px', + outlineStyle: 'solid', + outlineWidth: '2px', + }, + }, + [StatusItemsPosition.Bottom]: { + ...statusItemProps, + ariaLabel: 'Clock icon', + backgroundColor: 'var(--grey-color-10)', + color: 'var(--grey-color-70)', + onClick: () => alert('Clicked clock icon'), + outline: {}, + path: IconName.mdiClock, + }, + }, + }, + { + iconProps: { + path: IconName.mdiAccount, + size: '80px', + }, + style: { + backgroundColor: 'var(--blue-color-50)', + }, + statusItems: { + [StatusItemsPosition.BottomRight]: { + ...statusItemProps, + ariaLabel: 'Pencil icon', + onClick: () => alert('Clicked pencil icon'), + }, + [StatusItemsPosition.BottomLeft]: { + ...statusItemProps, + ariaLabel: 'Clock icon', + onClick: () => alert('Clicked clock icon'), + path: IconName.mdiClock, + }, + }, + }, + { + alt: imageProps.alt, + src: imageProps.src, + statusItems: { + [StatusItemsPosition.Left]: { + ...statusItemProps, + ariaLabel: 'Magnify icon', + backgroundColor: 'var(--blue-color-20)', + onClick: () => alert('Clicked magnify icon'), + path: IconName.mdiMagnify, + }, + [StatusItemsPosition.TopLeft]: { + ...statusItemProps, + ariaLabel: 'Clock icon', + backgroundColor: 'var(--red-color-30)', + onClick: () => alert('Clicked clock icon'), + path: IconName.mdiClock, + text: '3000', + }, + [StatusItemsPosition.Top]: { + ...statusItemProps, + backgroundColor: 'var(--red-color-30)', + path: IconName.mdiBell, + text: '4', + textMaxLength: 2, + }, + [StatusItemsPosition.Right]: { + ...statusItemProps, + backgroundColor: 'var(--blue-color-20)', + path: IconName.mdiCalendar, + text: '20', + alignIcon: StatusItemIconAlign.Left, + }, + }, + }, + ]; + + return ( + + {examples.map((value: AvatarProps, index) => { + return ; + })} + + ); +}; + +export const Avatar_StatusItem = Avatar_StatusItem_Story.bind({}); + +const Avatar_Tooltip_Story: ComponentStory = (args) => ( + +); + +export const Avatar_Tooltip = Avatar_Tooltip_Story.bind({}); + const avatarArgs: Object = { children: 'JD', classNames: 'my-avatar-class', @@ -91,6 +256,11 @@ Avatar_Default.args = { alt: imageProps.alt, }; +Avatar_StatusItem.args = { + ...avatarArgs, + type: 'round', +}; + Avatar_Icon.args = { ...avatarArgs, iconProps: { @@ -130,3 +300,13 @@ Avatar_Fallback_Hashing.args = { children: 'HF', type: 'round', }; + +Avatar_Tooltip.args = { + ...avatarArgs, + children: 'A', + type: 'round', + tooltipProps: { + content: 'Tooltip text', + theme: TooltipTheme.dark, + }, +}; diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 04326787c..e5758efb4 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -1,10 +1,21 @@ -import React, { Ref, FC, useMemo } from 'react'; +import React, { FC, Ref, useEffect, useMemo, useRef, useState } from 'react'; +import { + AvatarFallbackProps, + AvatarIconProps, + AvatarOutlineProps, + AvatarProps, + BaseAvatarProps, + StatusItemIconAlign, + StatusItemsPosition, + StatusItemsProps, +} from './'; +import { Icon } from '../Icon'; +import { Popup } from '../Popup'; +import { Tooltip } from '../Tooltip'; +import { useCanvasDirection } from '../../hooks/useCanvasDirection'; +import { ConditionalWrapper, mergeClasses } from '../../shared/utilities'; -// Styles: import styles from './avatar.module.scss'; -import { AvatarProps, AvatarFallbackProps, AvatarIconProps } from './'; -import { mergeClasses } from '../../shared/utilities'; -import { Icon } from '../Icon'; export const AVATAR_THEME_SET = [ styles.red, @@ -21,9 +32,229 @@ export const AVATAR_THEME_SET = [ styles.grey, ]; +export const getStatusItemSizeAndPadding = ( + avatarSize: number +): [number, number] => { + // Returns: [status item size, status item padding] + const statusItemSize: number = (avatarSize * 16) / 100; + return [statusItemSize, (statusItemSize * 6) / 16]; +}; + +// 0.06 factor is chosen based on design +const StatusItemWrapperPaddingFactor: number = 0.06; +const DefaultStatusItemMaxTextLength: number = 3; +const MinStatusItemFontSize: number = 12; +const StatusItemFontDiff: string = '2px'; + +const StatusItemOutlineDefaults: React.CSSProperties = { + outlineColor: 'var(--grey-color-80)', + outlineOffset: '0px', + outlineStyle: 'solid', + outlineWidth: '2px', +}; + +const AvatarOutlineDefaults: React.CSSProperties = { + outlineColor: 'var(--green-color-60)', + outlineOffset: '2px', + outlineStyle: 'solid', + outlineWidth: '4px', +}; + +const statusItemPositionRtlMap: { + [key in StatusItemsPosition]: StatusItemsPosition; +} = { + [StatusItemsPosition.Top]: StatusItemsPosition.Top, + [StatusItemsPosition.Bottom]: StatusItemsPosition.Bottom, + [StatusItemsPosition.Left]: StatusItemsPosition.Right, + [StatusItemsPosition.Right]: StatusItemsPosition.Left, + [StatusItemsPosition.TopRight]: StatusItemsPosition.TopLeft, + [StatusItemsPosition.TopLeft]: StatusItemsPosition.TopRight, + [StatusItemsPosition.BottomRight]: StatusItemsPosition.BottomLeft, + [StatusItemsPosition.BottomLeft]: StatusItemsPosition.BottomRight, +}; + +const AvatarStatusItems: FC = React.forwardRef( + ({ outline, size, statusItems }, ref: Ref) => { + const statusItemsRef = useRef<{ + [key in StatusItemsPosition]?: HTMLSpanElement; + }>({}); + const [showStatusItemsText, setShowStatusItemsText] = useState<{ + [key in StatusItemsPosition]?: boolean; + }>({}); + const htmlDir: string = useCanvasDirection(); + + const updateStatusItemTextVisibility = (position: StatusItemsPosition) => { + const value = statusItemsRef.current[position]; + if (value === undefined) { + return; + } + const styles = getComputedStyle(value); // getComputedStyle always outputs a pixel value + // We do slice to remove "px" + setShowStatusItemsText((prevState) => ({ + ...prevState, + [position]: + parseInt(styles.fontSize.slice(0, -2)) >= MinStatusItemFontSize, + })); + }; + + useEffect(() => { + updateStatusItemTextVisibility(StatusItemsPosition.Top); + updateStatusItemTextVisibility(StatusItemsPosition.Bottom); + updateStatusItemTextVisibility(StatusItemsPosition.Left); + updateStatusItemTextVisibility(StatusItemsPosition.Right); + updateStatusItemTextVisibility(StatusItemsPosition.TopRight); + updateStatusItemTextVisibility(StatusItemsPosition.TopLeft); + updateStatusItemTextVisibility(StatusItemsPosition.BottomRight); + updateStatusItemTextVisibility(StatusItemsPosition.BottomLeft); + }, [statusItemsRef.current]); + + const getStatusItemPositionStyle = ( + itemPos: StatusItemsPosition + ): React.CSSProperties => { + const outlineWidth: string = outline?.outlineWidth ?? '0px'; // Avatar outline width + const outlineOffset: string = outline?.outlineOffset ?? '0px'; // Avatar outline offset + const radius: string = `calc((${size} + ${outlineWidth} + ${outlineOffset}) / 2)`; + + switch (htmlDir === 'rtl' ? statusItemPositionRtlMap[itemPos] : itemPos) { + case StatusItemsPosition.TopRight: + return { + transform: `rotate(${-45}deg) translate(${radius}) rotate(${45}deg)`, + }; + case StatusItemsPosition.TopLeft: + return { + transform: `rotate(${-135}deg) translate(${radius}) rotate(${135}deg)`, + }; + case StatusItemsPosition.BottomRight: + return { + transform: `rotate(${45}deg) translate(${radius}) rotate(${-45}deg)`, + }; + case StatusItemsPosition.BottomLeft: + return { + transform: `rotate(${135}deg) translate(${radius}) rotate(${-135}deg)`, + }; + case StatusItemsPosition.Left: + return { + transform: `rotate(${180}deg) translate(${radius}) rotate(${-180}deg)`, + }; + case StatusItemsPosition.Right: + return { transform: `translate(${radius})` }; + case StatusItemsPosition.Top: + return { + transform: `rotate(${-90}deg) translate(${radius}) rotate(${90}deg)`, + }; + case StatusItemsPosition.Bottom: + default: + return { + transform: `rotate(${90}deg) translate(${radius}) rotate(${-90}deg)`, + }; + } + }; + + return ( + <> + {Object.keys(statusItems).map((position: StatusItemsPosition) => { + const statusItemProps: StatusItemsProps = statusItems[position]; + const alignIcon: StatusItemIconAlign = + statusItemProps.alignIcon ?? StatusItemIconAlign.Right; + const showStatusItemText: boolean = + statusItemProps.text && + statusItemProps.text.length <= + (statusItemProps.textMaxLength ?? DefaultStatusItemMaxTextLength); + const wrapperPadding: string | number = + statusItemProps?.wrapperStyle?.padding ?? + `(${size} * ${StatusItemWrapperPaddingFactor})`; + const statusItemTextClasses = mergeClasses([ + styles.avatarStatusItemText, + { [styles.avatarStatusItemTextRtl]: htmlDir === 'rtl' }, + { + [styles.textMarginRight]: alignIcon == StatusItemIconAlign.Right, + }, + { [styles.textMarginLeft]: alignIcon == StatusItemIconAlign.Left }, + ]); + const statusItemIconElement = ( + + ); + return ( +
+ {alignIcon == StatusItemIconAlign.Left && statusItemIconElement} + {showStatusItemText && (showStatusItemsText[position] ?? true) && ( + (statusItemsRef.current[position] = el)} + style={{ + color: statusItemProps.color, + fontSize: `calc(${statusItemProps.size} + ${StatusItemFontDiff})`, + lineHeight: statusItemProps.size, + }} + className={statusItemTextClasses} + > + {statusItemProps.text} + + )} + {alignIcon == StatusItemIconAlign.Right && statusItemIconElement} +
+ ); + })} + + ); + } +); + const AvatarFallback: FC = React.forwardRef( ( - { children, classNames, style, hashingFunction, theme, randomiseTheme }, + { + children, + classNames, + hashingFunction, + onClick, + onKeyDown, + onMouseEnter, + onMouseLeave, + randomiseTheme, + style, + theme, + }, ref: Ref ) => { const colorSetIndex: number = useMemo(() => { @@ -38,7 +269,6 @@ const AvatarFallback: FC = React.forwardRef( const avatarClasses: string = mergeClasses([ styles.wrapperStyle, - styles.avatar, classNames, { [styles.red]: theme === 'red' }, { [styles.redOrange]: theme === 'redOrange' }, @@ -56,7 +286,16 @@ const AvatarFallback: FC = React.forwardRef( ]); return ( -
+
{children}
); @@ -64,16 +303,38 @@ const AvatarFallback: FC = React.forwardRef( ); const AvatarIcon: FC = React.forwardRef( - ({ iconProps, fontSize, classNames, style }, ref: Ref) => { + ( + { + children, + classNames, + fontSize, + iconProps, + onClick, + onKeyDown, + onMouseEnter, + onMouseLeave, + style, + }, + ref: Ref + ) => { const wrapperClasses: string = mergeClasses([ styles.wrapperStyle, - styles.avatar, classNames, ]); return ( -
+
+ {children}
); } @@ -82,25 +343,74 @@ const AvatarIcon: FC = React.forwardRef( export const Avatar: FC = React.forwardRef( ( { - classNames, - src, alt, - size = '32px', - type = 'square', - style = {}, - fontSize = '18px', - iconProps, children, + classNames, + fontSize = '18px', hashingFunction, - theme, + key, + iconProps, + onClick, + onKeyDown, + onMouseEnter, + onMouseLeave, + outline, + popupProps = undefined, randomiseTheme, + size = '32px', + src, + statusItems = {}, + style = {}, + theme, + tooltipProps = undefined, + type = 'square', }, ref: Ref ) => { - const imageClasses: string = mergeClasses([ + const htmlDir: string = useCanvasDirection(); + + const [popupTriggerSize, setPopupTriggerSize] = useState( + parseInt(size, 10) + ); + const [popupVisible, setPopupVisibility] = useState(false); + + let calculatedOutline: AvatarOutlineProps = undefined; + if (outline !== undefined) { + calculatedOutline = { + outlineColor: + outline?.outlineColor ?? AvatarOutlineDefaults.outlineColor, + outlineOffset: + outline?.outlineOffset ?? + AvatarOutlineDefaults.outlineOffset.toString(), + outlineStyle: + outline?.outlineStyle ?? AvatarOutlineDefaults.outlineStyle, + outlineWidth: + outline?.outlineWidth ?? + AvatarOutlineDefaults.outlineWidth.toString(), + }; + } + + useEffect(() => { + setPopupTriggerSize(parseInt(size, 10)); + }, [size]); + + const popupClassNames: string = mergeClasses([ + { [styles.avatarPopup]: popupProps && !!popupVisible }, + { [styles.avatarPopupVisible]: popupProps && !!popupVisible }, + { [styles.avatarPopupHidden]: popupProps && !popupVisible }, + { [styles.round]: type === 'round' }, + ]); + + const imageClassNames: string = mergeClasses([ styles.avatar, styles.imageStyle, - { [styles.roundImage]: type === 'round' }, + popupClassNames, + ]); + + const wrapperContainerClassNames: string = mergeClasses([ + styles.avatarImgWrapper, + popupClassNames, + classNames, ]); const wrapperContainerStyle: React.CSSProperties = { @@ -110,47 +420,181 @@ export const Avatar: FC = React.forwardRef( minHeight: size, fontSize: fontSize, ...style, + ...(Object.keys(statusItems).length > 0 ? { position: 'relative' } : {}), }; + const getPopup = (children: React.ReactNode): JSX.Element => ( + setPopupVisibility(isVisible)} + placement="bottom-start" + popupStyle={{ + margin: + htmlDir === 'rtl' + ? `0 ${Math.floor(popupTriggerSize / 4)}px` + : `0 -${Math.floor(popupTriggerSize / 4)}px`, + padding: `${Math.floor(popupTriggerSize / 3)}px`, + paddingTop: `${Math.floor(popupTriggerSize / 1.5)}px`, + borderRadius: `${Math.floor(popupTriggerSize / 4)}px`, + }} + portal + tabIndex={-1} // Prevent focus on the reference wrapper, defer to Avatar + triggerAbove + visibleArrow={false} + width={Math.floor(popupTriggerSize * 4)} + {...popupProps} + > + {children} + + ); + if (src) { return ( -
- {alt} -
+ ( + <> + {tooltipProps && popupProps === undefined && ( + + {children} + + )} + {popupProps && getPopup(children)} + + )} + > +
+ {alt} + +
+
); } - const wrapperClasses: string = mergeClasses([classNames, imageClasses]); + const wrapperClassNames: string = mergeClasses([ + imageClassNames, + classNames, + ]); + + const wrapperChildClassNames: string = mergeClasses([ + popupClassNames, + classNames, + ]); if (iconProps) { return ( - + ( + <> + {tooltipProps && popupProps === undefined && ( + +
{children}
+
+ )} + {popupProps && + getPopup( +
{children}
+ )} + + )} + > + + + +
); } return ( - ( + <> + {tooltipProps && popupProps === undefined && ( + +
{children}
+
+ )} + {popupProps && + getPopup( +
{children}
+ )} + + )} > - {children} -
+ + {children} + + + ); } ); diff --git a/src/components/Avatar/Avatar.types.ts b/src/components/Avatar/Avatar.types.ts index 34f8358d0..a2c8ff446 100644 --- a/src/components/Avatar/Avatar.types.ts +++ b/src/components/Avatar/Avatar.types.ts @@ -4,17 +4,133 @@ import { IconProps } from '../Icon'; import { ListProps } from '../List'; import { TooltipProps } from '../Tooltip'; import { OcBaseProps } from '../OcBase'; +import { PopupProps } from '../Popup'; -interface BaseAvatarProps extends OcBaseProps { +export type Key = React.Key; + +export type StatusItemsMap = { + [key in StatusItemsPosition]?: StatusItemsProps; +}; + +export enum StatusItemsPosition { + Top = 'top', + Bottom = 'bottom', + Left = 'left', + Right = 'right', + TopRight = 'topRight', + TopLeft = 'topLeft', + BottomRight = 'bottomRight', + BottomLeft = 'bottomLeft', +} + +export enum StatusItemIconAlign { + Left = 'left', + Right = 'right', +} + +export interface AvatarOutlineProps { + /** + * Outline color + */ + outlineColor?: string; + /** + * Outline offset + */ + outlineOffset?: string; + /** + * Outline style + */ + outlineStyle?: string; + /** + * Outline width + */ + outlineWidth?: string; +} + +export interface StatusItemsProps extends IconProps { + /** + * Interactive element label + */ + ariaLabel?: string; + /** + * Background color + * @default 'var(--white-color)' + */ + backgroundColor?: string; + /** + * Icon onClick event handler. + */ + onClick?: React.MouseEventHandler; + /** + * Status item outline + * + * Defaults when `outline` is truthy are `{ outlineColor: 'var(--grey-color-80)', outlineOffset: '0px', outlineStyle: 'solid', outlineWidth: '2px' }` + */ + outline?: AvatarOutlineProps; + /** + * Style for status item wrapper + */ + wrapperStyle?: React.CSSProperties; + /** + * Class for status item wrapper + */ + wrapperClassName?: string; + /** + * Status item icon alignment + * @default StatusItemIconAlign.Right + */ + alignIcon?: StatusItemIconAlign; + /** + * Text present with icon + */ + text?: string; + /** + * Text having length larger than this will not be shown + * @default 3 + */ + textMaxLength?: number; +} + +export interface BaseAvatarProps extends OcBaseProps { /** * Avatar fallback font size * @default '18px' */ fontSize?: string; /** - * Function that returns avatar index + * Function that returns Avatar index */ hashingFunction?: () => number; + /** + * Unique key of the Avatar + */ + key?: Key; + /** + * Callback called on Avatar click + */ + onClick?: React.MouseEventHandler; + /** + * Callback called on Avatar keydown + */ + onKeyDown?: React.KeyboardEventHandler; + /** + * Callback called on Avatar mouse enter + */ + onMouseEnter?: React.MouseEventHandler; + /** + * Callback called on Avatar mouse leave + */ + onMouseLeave?: React.MouseEventHandler; + /** + * Avatar outline + * + * Defaults when `outline` is truthy are `{ outlineColor: 'var(--green-color-60)', outlineOffset: '2px', outlineStyle: 'solid', outlineWidth: '4px' }` + */ + outline?: AvatarOutlineProps; + /** + * Avatar Popup props. + */ + popupProps?: AvatarPopupProps; /** * Should randomise theme * @default false @@ -29,6 +145,11 @@ interface BaseAvatarProps extends OcBaseProps { * @default '32px' */ size?: string; + /** + * Status icons which are to be placed on top of the avatar + * @default {} + */ + statusItems?: StatusItemsMap; /** * theme of the fallback avatar * @default '' @@ -41,6 +162,8 @@ interface BaseAvatarProps extends OcBaseProps { type?: 'round' | 'square'; } +export interface AvatarPopupProps extends PopupProps {} + export interface AvatarIconProps extends BaseAvatarProps { /** * Icon Props @@ -61,6 +184,10 @@ export interface AvatarProps * Image alt text */ alt?: string; + /** + * Hover tooltip + */ + tooltipProps?: TooltipProps; } interface MaxAvatarProps extends BaseAvatarProps { @@ -97,7 +224,17 @@ interface MaxAvatarProps extends BaseAvatarProps { interface AvatarListProps extends Omit, 'footer' | 'header' | 'layout'> {} +export enum AvatarGroupVariant { + Overlapped = 'overlapped', + Spaced = 'spaced', +} + export interface AvatarGroupProps extends OcBaseProps { + /** + * The Avatars should animate on hover. + * @default false + */ + animateOnHover?: boolean; /** * Avatar group List props. */ @@ -111,6 +248,11 @@ export interface AvatarGroupProps extends OcBaseProps { * @default '18px' */ fontSize?: string; + /** + * Avatar grouping variant + * @default AvatarGroupVariant.Overlapped + */ + groupVariant?: AvatarGroupVariant; /** * Avatar group max props. */ diff --git a/src/components/Avatar/AvatarGroup.stories.tsx b/src/components/Avatar/AvatarGroup.stories.tsx index 4b3ed220b..5ddfbda8a 100644 --- a/src/components/Avatar/AvatarGroup.stories.tsx +++ b/src/components/Avatar/AvatarGroup.stories.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { Stories } from '@storybook/addon-docs'; -import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { Avatar, AvatarGroup } from '.'; -import { Tooltip, TooltipSize, TooltipTheme } from '../Tooltip'; +import { action } from '@storybook/addon-actions'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Avatar, AvatarGroup, AvatarGroupVariant, AvatarPopupProps } from '.'; +import { TooltipSize, TooltipTheme } from '../Tooltip'; export default { title: 'Avatar Group', @@ -47,6 +48,7 @@ interface User { img: string; key: string; name: string; + popupProps: AvatarPopupProps; randomiseTheme: boolean; // This should be replaced by users profile settings chosen theme. } @@ -60,50 +62,99 @@ const sampleList: User[] = [ img: i === 1 ? imageProps.src : null, key: `key-${i}`, name: `User ${i}`, + popupProps: { + closeOnReferenceClick: false, + content: `User ${i}`, + trigger: 'hover', + }, randomiseTheme: true, })); const Basic_Story: ComponentStory = (args) => ( 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} src={imageProps.src} size={args.size} + tabIndex={0} theme={'blue'} + tooltipProps={{ + content: 'User 1', + theme: TooltipTheme.dark, + }} type={args.type} /> 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} size={args.size} + tabIndex={0} theme={'green'} + tooltipProps={{ + content: 'User 2', + theme: TooltipTheme.dark, + }} type={args.type} > AB - - 3} - size={args.size} - theme={'redOrange'} - type={args.type} - > - CD - - 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} size={args.size} + tabIndex={0} + theme={'redOrange'} + tooltipProps={{ + content: 'User 3', + theme: TooltipTheme.dark, + }} + type={args.type} + > + CD + + 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} + size={args.size} + tabIndex={0} theme={'blueViolet'} + tooltipProps={{ + content: 'User 4', + theme: TooltipTheme.dark, + }} type={args.type} > EF @@ -111,8 +162,17 @@ const Basic_Story: ComponentStory = (args) => ( 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} size={args.size} + tabIndex={0} theme={'yellowGreen'} + tooltipProps={{ + content: 'User 5', + theme: TooltipTheme.dark, + }} type={args.type} > GH @@ -120,8 +180,17 @@ const Basic_Story: ComponentStory = (args) => ( 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} size={args.size} + tabIndex={0} theme={'violetRed'} + tooltipProps={{ + content: 'User 6', + theme: TooltipTheme.dark, + }} type={args.type} > IJ @@ -131,9 +200,12 @@ const Basic_Story: ComponentStory = (args) => ( export const Basic = Basic_Story.bind({}); +export const Basic_Spaced = Basic_Story.bind({}); + const List_Story: ComponentStory = (args) => ( ( @@ -143,6 +215,11 @@ const List_Story: ComponentStory = (args) => ( data-test-id={item['data-test-id']} fontSize={args.fontSize} hashingFunction={() => 3} + onClick={action('avatar-click')} + onKeyDown={action('avatar-keydown')} + onMouseEnter={action('avatar-mouseenter')} + onMouseLeave={action('avatar-mouseleave')} + popupProps={item.popupProps} randomiseTheme={item.randomiseTheme} size={args.size} src={item.img} @@ -154,6 +231,10 @@ const List_Story: ComponentStory = (args) => ( }} maxProps={{ count: 4, + onClick: action('maxcount-click'), + onKeyDown: action('maxcount-keydown'), + onMouseEnter: action('maxcount-mouseenter'), + onMouseLeave: action('maxcount-mouseleave'), tooltipProps: { content: 'This is a tooltip.', size: TooltipSize.Large, @@ -165,6 +246,8 @@ const List_Story: ComponentStory = (args) => ( export const List_Group = List_Story.bind({}); +export const List_Group_Spaced = List_Story.bind({}); + const avatarGroupArgs: Object = { classNames: 'my-avatar-group-class', 'data-test-id': 'my-avatar-group-test-id', @@ -179,6 +262,16 @@ Basic.args = { ...avatarGroupArgs, }; +Basic_Spaced.args = { + ...avatarGroupArgs, + groupVariant: AvatarGroupVariant.Spaced, +}; + List_Group.args = { ...avatarGroupArgs, }; + +List_Group_Spaced.args = { + ...avatarGroupArgs, + groupVariant: AvatarGroupVariant.Spaced, +}; diff --git a/src/components/Avatar/AvatarGroup.tsx b/src/components/Avatar/AvatarGroup.tsx index 99b99dfee..3b783f2cc 100644 --- a/src/components/Avatar/AvatarGroup.tsx +++ b/src/components/Avatar/AvatarGroup.tsx @@ -1,5 +1,5 @@ import React, { Ref } from 'react'; -import { Avatar, AvatarGroupProps } from '.'; +import { Avatar, AvatarGroupProps, AvatarGroupVariant } from '.'; import { List } from '../List'; import { Tooltip } from '../Tooltip'; import { useCanvasDirection } from '../../hooks/useCanvasDirection'; @@ -15,9 +15,11 @@ import styles from './avatar.module.scss'; export const AvatarGroup: React.FC = React.forwardRef( ( { + animateOnHover = false, avatarListProps, children, classNames, + groupVariant = AvatarGroupVariant.Overlapped, maxProps, size, style, @@ -31,6 +33,8 @@ export const AvatarGroup: React.FC = React.forwardRef( const avatarGroupClassNames: string = mergeClasses([ styles.avatarGroup, + { [styles.animate]: !!animateOnHover }, + { [styles.spaced]: groupVariant === AvatarGroupVariant.Spaced }, { [styles.avatarGroupRtl]: htmlDir === 'rtl' }, classNames, ]); @@ -65,14 +69,16 @@ export const AvatarGroup: React.FC = React.forwardRef( size={size} type={type} fontSize={styles.maxCountFontSize} - {...maxProps} + {...{ ...maxProps, tooltipProps: undefined }} classNames={mergeClasses([ styles.avatarGroupMaxCount, maxProps?.classNames, ])} > {!!maxProps?.value && maxProps?.value} - {!maxProps?.value && `+${numChildren - maxCount}`} + {!maxProps?.value && avatarListProps + ? `+${numChildren - maxCount}` + : `+${maxCount}`} ); @@ -121,6 +127,7 @@ export const AvatarGroup: React.FC = React.forwardRef( renderItem={avatarListProps?.renderItem} renderAdditionalItem={maxCountItem} style={style} + tabIndex={-1} /> ); } diff --git a/src/components/Avatar/Styles/group.scss b/src/components/Avatar/Styles/group.scss index 2ad8cce56..b1cc9c838 100644 --- a/src/components/Avatar/Styles/group.scss +++ b/src/components/Avatar/Styles/group.scss @@ -1,12 +1,38 @@ .avatar-group { display: inline-flex; - :not(:first-child):not(.avatar-group-tooltip) { + :not(:first-child):not(.avatar-tooltip):not(.avatar-group-tooltip):not(.avatar-status-item):not(.avatar-status-item-text):not(.avatar-status-item-icon) { margin-left: -$space-xs; } + &.spaced { + :not(:first-child):not(.avatar-group-tooltip):not(.avatar-status-item):not(.avatar-status-item-text):not(.avatar-status-item-icon) { + margin-left: -$space-xxxs; + } + } + .avatar { border: $space-xxxs solid var(--background-color); + position: relative; + + &:hover { + z-index: $z-index-500; + } + } + + &.animate { + .avatar { + transition: transform $motion-duration-extra-fast $motion-easing-easeout; + + &:hover:not(.avatar-popup-hidden):not(.avatar-popup-visible):not(:focus-visible) { + transform: translateY(-$motion-movement-s); + } + + &.avatar-popup-visible { + animation: nudgeAvatar $motion-duration-extra-fast + $motion-easing-easeout; + } + } } &-tooltip { @@ -18,3 +44,15 @@ color: var(--text-secondary-color); } } + +@keyframes nudgeAvatar { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-$motion-movement-s); + } + 100% { + transform: translateY(0); + } +} diff --git a/src/components/Avatar/Styles/rtl.scss b/src/components/Avatar/Styles/rtl.scss index 8fa5b66bd..123d77ea0 100644 --- a/src/components/Avatar/Styles/rtl.scss +++ b/src/components/Avatar/Styles/rtl.scss @@ -1,8 +1,27 @@ .avatar-group { &-rtl { - :not(:first-child):not(.avatar-group-tooltip) { + :not(:first-child):not(.avatar-tooltip):not(.avatar-group-tooltip):not(.avatar-status-item):not(.avatar-status-item-text):not(.avatar-status-item-icon) { margin-right: -$space-xs; margin-left: 0; } + + &.spaced { + :not(:first-child):not(.avatar-group-spaced-tooltip):not(.avatar-status-item):not(.avatar-status-item-text):not(.avatar-status-item-icon) { + margin-right: -$space-xxxs; + margin-left: 0; + } + } + } +} + +.avatar-status-item-text-rtl { + &.text-margin-right { + margin-right: 0; + margin-left: 2px; + } + + &.text-margin-left { + margin-left: 0; + margin-right: 2px; } } diff --git a/src/components/Avatar/__snapshots__/Avatar.test.tsx.snap b/src/components/Avatar/__snapshots__/Avatar.test.tsx.snap index 01683af99..d18d4c479 100644 --- a/src/components/Avatar/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/Avatar/__snapshots__/Avatar.test.tsx.snap @@ -4,8 +4,9 @@ exports[`Avatar Avatar size is 40px 1`] = ` LoadedCheerio { "0": Node { "attribs": Object { - "class": "wrapper-style avatar avatar image-style", + "class": "wrapper-style avatar image-style", "style": "width: 40px; height: 40px; min-width: 40px; min-height: 40px; font-size: 18px;", + "tabindex": "0", }, "children": Array [ Node { @@ -34,10 +35,12 @@ LoadedCheerio { "x-attribsNamespace": Object { "class": undefined, "style": undefined, + "tabindex": undefined, }, "x-attribsPrefix": Object { "class": undefined, "style": undefined, + "tabindex": undefined, }, }, "_root": LoadedCheerio { @@ -129,8 +132,9 @@ exports[`Avatar Avatar type is round 1`] = ` LoadedCheerio { "0": Node { "attribs": Object { - "class": "wrapper-style avatar avatar image-style round-image", + "class": "wrapper-style avatar image-style round", "style": "width: 32px; height: 32px; min-width: 32px; min-height: 32px; font-size: 18px;", + "tabindex": "0", }, "children": Array [ Node { @@ -159,10 +163,12 @@ LoadedCheerio { "x-attribsNamespace": Object { "class": undefined, "style": undefined, + "tabindex": undefined, }, "x-attribsPrefix": Object { "class": undefined, "style": undefined, + "tabindex": undefined, }, }, "_root": LoadedCheerio { @@ -254,8 +260,9 @@ exports[`Avatar Avatar type square is default 1`] = ` LoadedCheerio { "0": Node { "attribs": Object { - "class": "wrapper-style avatar avatar image-style", + "class": "wrapper-style avatar image-style", "style": "width: 32px; height: 32px; min-width: 32px; min-height: 32px; font-size: 18px;", + "tabindex": "0", }, "children": Array [ Node { @@ -284,10 +291,12 @@ LoadedCheerio { "x-attribsNamespace": Object { "class": undefined, "style": undefined, + "tabindex": undefined, }, "x-attribsPrefix": Object { "class": undefined, "style": undefined, + "tabindex": undefined, }, }, "_root": LoadedCheerio { diff --git a/src/components/Avatar/avatar.module.scss b/src/components/Avatar/avatar.module.scss index 0ed082611..c19323789 100644 --- a/src/components/Avatar/avatar.module.scss +++ b/src/components/Avatar/avatar.module.scss @@ -5,11 +5,10 @@ .wrapper-style { align-items: center; + color: var(--text-inverse-color); display: flex; font-family: var(--avatar-font-family); justify-content: center; - border-radius: var(--avatar-border-radius); - color: var(--text-inverse-color); &.red { background-color: var(--red-color-60); @@ -60,15 +59,83 @@ } } -.round-image { - border-radius: var(--avatar-round-border-radius); -} - .image-style { height: 100%; width: 100%; object-fit: cover; } +.avatar-status-item { + display: flex; + position: absolute; + border-radius: var(--avatar-status-item-border-radius); + + .clickable { + cursor: pointer; + } +} + +.avatar-status-item-text { + font-weight: 600; + + &.text-margin-right { + margin-right: 2px; + } + + &.text-margin-left { + margin-left: 2px; + } +} + +.avatar-status-item-icon { + margin: 0; +} + +.avatar-img-wrapper { + align-items: center; + display: flex; + font-family: var(--avatar-font-family); + justify-content: center; +} + +.avatar-popup { + z-index: $z-index-500; +} + +.avatar-group-max-count, +.avatar-popup, +.avatar-popup-hidden, +.avatar-popup-visible, +.wrapper-style, +.image-style, +.avatar-img-wrapper { + border-radius: var(--avatar-border-radius); + position: relative; + + &.round { + border-radius: var(--avatar-round-border-radius); + } + + // Hides the browser default keyboard focus-visible styles. + // Use the ConfigProvider instead. + &:focus-visible { + outline: none; + } +} + +:global(.focus-visible) { + .avatar-group-max-count, + .avatar-popup, + .avatar-popup-hidden, + .avatar-popup-visible, + .avatar-img-wrapper, + .image-style, + .wrapper-style { + &:focus-visible { + box-shadow: var(--focus-visible-box-shadow); + } + } +} + @import './Styles/group'; @import './Styles/rtl'; diff --git a/src/components/Button/button.module.scss b/src/components/Button/button.module.scss index 476e3fa2c..ab0459e00 100644 --- a/src/components/Button/button.module.scss +++ b/src/components/Button/button.module.scss @@ -506,9 +506,6 @@ border-width: var(--button-primary-border-width); border-style: var(--button-primary-border-style); border-color: var(--button-primary-border-color); - height: calc(100% + calc(var(--button-primary-border-width) * 2)); - margin: calc(var(--button-primary-border-width) * -1); - width: calc(100% + calc(var(--button-primary-border-width) * 2)); } } } @@ -587,9 +584,6 @@ border-width: var(--button-secondary-border-width); border-style: var(--button-secondary-border-style); border-color: var(--button-secondary-border-color); - height: calc(100% + calc(var(--button-secondary-border-width) * 2)); - margin: calc(var(--button-secondary-border-width) * -1); - width: calc(100% + calc(var(--button-secondary-border-width) * 2)); } } } @@ -698,9 +692,6 @@ border-width: var(--button-default-border-width); border-style: var(--button-default-border-style); border-color: var(--button-default-border-color); - height: calc(100% + calc(var(--button-default-border-width) * 2)); - margin: calc(var(--button-default-border-width) * -1); - width: calc(100% + calc(var(--button-default-border-width) * 2)); } } } @@ -1182,9 +1173,6 @@ border-width: var(--button-secondary-border-width); border-style: var(--button-secondary-border-style); border-color: var(--button-secondary-border-color); - height: calc(100% + calc(var(--button-secondary-border-width) * 2)); - margin: calc(var(--button-secondary-border-width) * -1); - width: calc(100% + calc(var(--button-secondary-border-width) * 2)); } } } diff --git a/src/components/Carousel/Carousel.stories.tsx b/src/components/Carousel/Carousel.stories.tsx index 7b1b907b7..d22b0f4ea 100644 --- a/src/components/Carousel/Carousel.stories.tsx +++ b/src/components/Carousel/Carousel.stories.tsx @@ -96,6 +96,7 @@ const Scroll_Story: ComponentStory = (args) => ( export const Slider = Slide_Story.bind({}); export const Scroller = Scroll_Story.bind({}); +export const Scroller_Single = Scroll_Story.bind({}); const carouselArgs: Object = { classNames: 'my-carousel', @@ -112,6 +113,7 @@ Slider.args = { interval: 5000, loop: true, pause: 'hover', + single: false, transition: 'push', type: 'slide', }; @@ -143,3 +145,32 @@ Scroller.args = { id: 'myCarouselScrollId', type: 'scroll', }; + +Scroller_Single.args = { + ...carouselArgs, + carouselScrollMenuProps: { + children: sampleList.map((item: SampleItem) => ( +
+ {item.name} +
+ )), + containerPadding: 8, + gap: 24, + }, + id: 'myCarouselScrollId', + single: true, + type: 'scroll', +}; diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx index 2bd9b5d66..6b7d8c192 100644 --- a/src/components/Carousel/Carousel.tsx +++ b/src/components/Carousel/Carousel.tsx @@ -6,12 +6,18 @@ import React, { useState, useEffect, useRef, + useLayoutEffect, } from 'react'; import { CarouselContext, CarouselProps, CustomScrollBehavior, DataType, + DEFAULT_GAP_WIDTH, + IntersectionObserverItem, + ItemOrElement, + OCCLUSION_AVOIDANCE_BUFFER, + ScrollMenuProps, scrollToItemOptions, } from './Carousel.types'; import { ScrollMenu, VisibilityContext } from './ScrollMenu/ScrollMenu'; @@ -22,6 +28,7 @@ import { PaginationLayoutOptions, PaginationLocale, } from '../Pagination'; +import { ResizeObserver } from '../../shared/ResizeObserver/ResizeObserver'; import { useForkedRef } from '../../hooks/useForkedRef'; import { useCanvasDirection } from '../../hooks/useCanvasDirection'; import LocaleReceiver, { @@ -34,6 +41,8 @@ import styles from './carousel.module.scss'; type scrollVisibilityApiType = React.ContextType; +const SCROLL_LOCK_WAIT_IN_MILLISECONDS: number = 40; + const isVisible = (element: HTMLDivElement): boolean => { const rect: DOMRect = element.getBoundingClientRect(); return ( @@ -65,6 +74,7 @@ export const Carousel: FC = React.forwardRef( pagination = true, pause = 'hover', previousIconButtonAriaLabel: defaultPreviousIconButtonAriaLabel, + single = false, style, transition = 'push', type = 'slide', @@ -85,6 +95,8 @@ export const Carousel: FC = React.forwardRef( useRef(null); const previousButtonRef: React.MutableRefObject = useRef(null); + const scrollMenuRef: React.MutableRefObject = + useRef(null); const forkedRef: (node: any) => void = useForkedRef(ref, carouselRef); const data: DataType = useRef({}).current; const [active, setActive] = useState(activeIndex); @@ -93,6 +105,11 @@ export const Carousel: FC = React.forwardRef( const [direction, setDirection] = useState('next'); const [itemsNumber, setItemsNumber] = useState(0); const [visible, setVisible] = useState(); + const [mouseEnter, setMouseEnter] = useState(false); + const [scrollLock, setScrollLock] = useState(false); + const timerRef = useRef(); + const [_single, setSingle] = useState(single); + const [_visibleElements, setVisibleElements] = useState(0); // ============================ Strings =========================== const [paginationLocale] = useLocaleReceiver('Pagination'); @@ -141,13 +158,28 @@ export const Carousel: FC = React.forwardRef( }, [animating]); useEffect(() => { - window.addEventListener('scroll', handleScroll); + window.addEventListener('scroll', handleScroll, { passive: true }); return () => { window.removeEventListener('scroll', handleScroll); }; }); + useEffect(() => { + if (scrollMenuRef?.current) { + // passive: false, to ensure prevent default + scrollMenuRef.current.addEventListener('wheel', preventYScroll, { + passive: false, + }); + } + + return () => { + if (scrollMenuRef?.current) { + scrollMenuRef.current.removeEventListener('wheel', preventYScroll); + } + }; + }); + const carouselClassNames: string = mergeClasses( styles.carousel, { [styles.carouselRtl]: htmlDir === 'rtl' }, @@ -244,27 +276,131 @@ export const Carousel: FC = React.forwardRef( } }; + const isTouchpadVerticalScroll = async ( + event: React.WheelEvent | WheelEvent + ): Promise => { + const { deltaY } = event; + if (deltaY && !Number.isInteger(deltaY)) { + return false; + } + return true; + }; + + const isTouchpadHorizontalScroll = async ( + event: React.WheelEvent | WheelEvent + ): Promise => { + const { deltaX } = event; + if (deltaX && !Number.isInteger(deltaX)) { + return false; + } + return true; + }; + + const handleGroupScrollOnWheel = async ( + apiObj: scrollVisibilityApiType, + event: React.WheelEvent + ): Promise => { + const touchpadHorizontal: boolean = await isTouchpadHorizontalScroll( + event + ); + const touchpadVertical: boolean = await isTouchpadVerticalScroll(event); + if (event.deltaY < 0 || event.deltaX < 0) { + // When not touchpad, handle the scroll + if (!touchpadHorizontal || !touchpadVertical) { + apiObj.scrollNextGroup(); + } + } else if (event.deltaY > 0 || event.deltaX > 0) { + // When not touchpad, handle the scroll + if (!touchpadHorizontal || !touchpadVertical) { + apiObj.scrollPrevGroup(); + } + } + }; + + const handleSingleItemScrollOnWheel = async ( + apiObj: scrollVisibilityApiType, + event: React.WheelEvent + ): Promise => { + const touchpadHorizontal: boolean = await isTouchpadHorizontalScroll( + event + ); + const touchpadVertical: boolean = await isTouchpadVerticalScroll(event); + if (event.deltaY < 0 || event.deltaX < 0) { + // When not touchpad, handle the scroll + if (!touchpadHorizontal || !touchpadVertical) { + apiObj.scrollBySingleItem( + apiObj.getNextElement(), + 'smooth', + htmlDir === 'rtl' ? 'previous' : 'next', + !!props.carouselScrollMenuProps?.gap + ? props.carouselScrollMenuProps?.gap + : DEFAULT_GAP_WIDTH, + apiObj.isFirstItemVisible + ? -DEFAULT_GAP_WIDTH + : OCCLUSION_AVOIDANCE_BUFFER + ); + } + } else if (event.deltaY > 0 || event.deltaX > 0) { + // When not touchpad, handle the scroll + if (!touchpadHorizontal || !touchpadVertical) { + const gapWidth: number = !!props.carouselScrollMenuProps?.gap + ? props.carouselScrollMenuProps?.gap + : DEFAULT_GAP_WIDTH; + apiObj.scrollBySingleItem( + apiObj.getPrevElement(), + 'smooth', + htmlDir === 'rtl' ? 'next' : 'previous', + gapWidth, + apiObj.isLastItemVisible ? gapWidth : OCCLUSION_AVOIDANCE_BUFFER + ); + } + } + }; + const handleOnWheel = ( apiObj: scrollVisibilityApiType, - ev: React.WheelEvent + event: React.WheelEvent ): void => { - const isTouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15; - - if (isTouchpad) { - ev.stopPropagation(); - return; + // Prevent spamming of scroll. + clearTimeout(timerRef.current); + timerRef.current = setTimeout( + () => setScrollLock(false), + SCROLL_LOCK_WAIT_IN_MILLISECONDS + ); + if (!scrollLock) { + setScrollLock(true); + if (_single) { + handleSingleItemScrollOnWheel(apiObj, event); + } else { + handleGroupScrollOnWheel(apiObj, event); + } } + }; - if (ev.deltaY < 0) { - apiObj.scrollNext(); - } else if (ev.deltaY > 0) { - apiObj.scrollPrev(); + const preventYScroll = async (event: WheelEvent): Promise => { + const touchpadVertical: boolean = await isTouchpadVerticalScroll(event); + // Prevent document scroll only when hovering over a carousel that may be scrolled and when not using touchpad. + if ( + mouseEnter && + (previousButtonRef.current || nextButtonRef.current) && + !touchpadVertical + ) { + event.preventDefault(); } }; const nextButton = ( + getNextElement?: () => IntersectionObserverItem, + isFirstItemVisible?: boolean, nextDisabled?: boolean, - scrollNext?: ( + scrollBySingleItem?: ( + target?: ItemOrElement, + behavior?: CustomScrollBehavior, + direction?: string, + gap?: number, + offset?: number + ) => void | T | Promise, + scrollNextGroup?: ( behavior?: CustomScrollBehavior, inline?: ScrollLogicalPosition, block?: ScrollLogicalPosition, @@ -301,7 +437,19 @@ export const Carousel: FC = React.forwardRef( key="carousel-next" onClick={() => props.type === 'scroll' - ? scrollNext() + ? _single + ? scrollBySingleItem( + getNextElement(), + 'smooth', + htmlDir === 'rtl' ? 'previous' : 'next', + !!props.carouselScrollMenuProps?.gap + ? props.carouselScrollMenuProps?.gap + : DEFAULT_GAP_WIDTH, + isFirstItemVisible + ? -DEFAULT_GAP_WIDTH + : OCCLUSION_AVOIDANCE_BUFFER + ) + : scrollNextGroup() : handleControlClick('next') } ref={nextButtonRef} @@ -315,14 +463,26 @@ export const Carousel: FC = React.forwardRef( }; const previousButton = ( + getPrevElement?: () => IntersectionObserverItem, + isLastItemVisible?: boolean, previousDisabled?: boolean, - scrollPrev?: ( + scrollBySingleItem?: ( + target?: ItemOrElement, + behavior?: CustomScrollBehavior, + direction?: string, + gap?: number, + offset?: number + ) => void | T | Promise, + scrollPrevGroup?: ( behavior?: CustomScrollBehavior, inline?: ScrollLogicalPosition, block?: ScrollLogicalPosition, { duration, ease, boundary }?: scrollToItemOptions ) => unknown ): JSX.Element => { + const gapWidth: number = !!props.carouselScrollMenuProps?.gap + ? props.carouselScrollMenuProps?.gap + : DEFAULT_GAP_WIDTH; return ( <> {!previousDisabled && ( @@ -351,7 +511,17 @@ export const Carousel: FC = React.forwardRef( key="carousel-previous" onClick={() => props.type === 'scroll' - ? scrollPrev() + ? _single + ? scrollBySingleItem( + getPrevElement(), + 'smooth', + htmlDir === 'rtl' ? 'next' : 'previous', + gapWidth, + isLastItemVisible + ? gapWidth + : OCCLUSION_AVOIDANCE_BUFFER + ) + : scrollPrevGroup() : handleControlClick('previous') } ref={previousButtonRef} @@ -366,11 +536,14 @@ export const Carousel: FC = React.forwardRef( const autoScrollButton = (direction?: string): JSX.Element => { const { + getNextElement, + getPrevElement, initComplete, isFirstItemVisible, isLastItemVisible, - scrollNext, - scrollPrev, + scrollBySingleItem, + scrollNextGroup, + scrollPrevGroup, visibleElements, } = React.useContext(VisibilityContext); @@ -387,6 +560,7 @@ export const Carousel: FC = React.forwardRef( if (initComplete && visibleElements?.length) { setPreviousDisabled(isFirstItemVisible); setNextDisabled(isLastItemVisible); + setVisibleElements(visibleElements.length); } }, [ initComplete, @@ -396,12 +570,42 @@ export const Carousel: FC = React.forwardRef( ]); if (direction === 'next') { - return nextButton(nextDisabled, scrollNext); + return nextButton( + getNextElement, + isFirstItemVisible, + nextDisabled, + scrollBySingleItem, + scrollNextGroup + ); } - return previousButton(previousDisabled, scrollPrev); + return previousButton( + getPrevElement, + isLastItemVisible, + previousDisabled, + scrollBySingleItem, + scrollPrevGroup + ); }; + const updateScrollMode = (): void => { + if (type !== 'scroll') { + return; + } + + if (!single) { + // If the number of visible elements is less than 3, swap to single scroll. + setSingle(_visibleElements < 3); + } + }; + + useLayoutEffect(() => { + if (type !== 'scroll') { + return; + } + updateScrollMode(); + }, [_single, _visibleElements]); + return ( {(_contextLocale: PaginationLocale) => { @@ -409,8 +613,12 @@ export const Carousel: FC = React.forwardRef(
setMouseEnter(true) + } + onMouseLeave={ + type === 'slide' ? handleCycle : () => setMouseEnter(false) + } {...rest} ref={forkedRef} > @@ -434,7 +642,8 @@ export const Carousel: FC = React.forwardRef( onCurrentChange={(currentPage: number) => handleIndicatorClick(currentPage - 1) } - pageSizes={[1]} + restrictPageSizesPropToSizesLayout + pageSize={1} total={itemsNumber} /> )} @@ -454,15 +663,18 @@ export const Carousel: FC = React.forwardRef( return null; })} {type === 'scroll' && ( - autoScrollButton('next')} - onWheel={handleOnWheel} - previousButton={() => autoScrollButton('previous')} - rtl={htmlDir === 'rtl'} - {...carouselScrollMenuProps} - > - {carouselScrollMenuProps?.children} - + + autoScrollButton('next')} + onWheel={handleOnWheel} + previousButton={() => autoScrollButton('previous')} + rtl={htmlDir === 'rtl'} + {...carouselScrollMenuProps} + ref={scrollMenuRef} + > + {carouselScrollMenuProps?.children} + + )}
{controls && type === 'slide' && ( diff --git a/src/components/Carousel/Carousel.types.ts b/src/components/Carousel/Carousel.types.ts index f6956a144..9e9a4e1e5 100644 --- a/src/components/Carousel/Carousel.types.ts +++ b/src/components/Carousel/Carousel.types.ts @@ -12,6 +12,9 @@ import { autoScrollApiType } from './autoScrollApi'; import { OcBaseProps } from '../OcBase'; import { PaginationLocale } from '../Pagination'; +export const DEFAULT_GAP_WIDTH: number = 4; +export const OCCLUSION_AVOIDANCE_BUFFER: number = 72; + export type CarouselTransition = 'push' | 'crossfade'; export type CarouselType = 'slide' | 'scroll'; export type CustomScrollBehavior = @@ -136,6 +139,12 @@ export interface CarouselProps * @default 'Previous' */ previousIconButtonAriaLabel?: string; + /** + * Whether to scroll by 1 item. + * Use when type is 'scroll' + * @default false + */ + single?: boolean; /** * Set type of slide transition. * @default 'push' diff --git a/src/components/Carousel/ItemsMap.ts b/src/components/Carousel/ItemsMap.ts index e48577728..bd1640538 100644 --- a/src/components/Carousel/ItemsMap.ts +++ b/src/components/Carousel/ItemsMap.ts @@ -73,26 +73,55 @@ class ItemsMap extends Map { ); return [arr, current]; } - public prev( + + public prevGroup( item: string | IntersectionObserverItem, onlyItems?: boolean ): IntersectionObserverItem | undefined { const [arr, current] = this.getCurrentPos(item, !!onlyItems); - // We scroll to the previous plus two so the arrow button doesn't occlude the target item. + // We scroll to the previous plus two so the next button + // doesn't occlude the last occluded item of the initial group. + // --------------------- --------------------- ---------------------- + // <4 | {5} | 6 | 7 | 8> --> <1 | 2 | 3 | {4} | 5> --> | {1} | 2 | 3 | 4 | 5> + // --------------------- --------------------- ---------------------- return current !== -1 ? arr[current + 2]?.[1] : undefined; } - public next( + public nextGroup( item: IntersectionObserverItem | string, onlyItems?: boolean ): IntersectionObserverItem | undefined { const [arr, current] = this.getCurrentPos(item, !!onlyItems); - // We scroll to the next minus one so the arrow button doesn't occlude the target item. + // We scroll to the next minus one so the previous button + // doesn't occlude the last occluded item of the initial group. + // ---------------------- --------------------- ----------------------- + // | {1} | 2 | 3 | 4 | 5> --> <4 | {5} | 6 | 7 | 8> --> <7 | {8} | 9 | 10 | 11> + // ---------------------- --------------------- ----------------------- + return current !== -1 ? arr[current - 1]?.[1] : undefined; + } + + public prev( + item: string | IntersectionObserverItem, + onlyItems?: boolean + ): IntersectionObserverItem | undefined { + const [arr, current] = this.getCurrentPos(item, !!onlyItems); + + // When single scroll, only ever decrement by 1, don't adjust for occlusion. return current !== -1 ? arr[current - 1]?.[1] : undefined; } + public next( + item: IntersectionObserverItem | string, + onlyItems?: boolean + ): IntersectionObserverItem | undefined { + const [arr, current] = this.getCurrentPos(item, !!onlyItems); + + // When single scroll, only ever increment by 1, don't adjust for occlusion. + return current !== -1 ? arr[current + 1]?.[1] : undefined; + } + public getVisible(): Item[] { return this.filter((value: Item) => value[1].visible); } diff --git a/src/components/Carousel/Settings.ts b/src/components/Carousel/Settings.ts index 2e3346be6..4442843bd 100644 --- a/src/components/Carousel/Settings.ts +++ b/src/components/Carousel/Settings.ts @@ -9,6 +9,6 @@ interface IntersectionObserverOptions extends IntersectionObserverInit { export const observerOptions: IntersectionObserverOptions = { ratio: 0.9, rootMargin: '8px', - threshold: [0.05, 0.5, 0.75, 0.95], + threshold: [0, 0.25, 0.5, 0.75, 1], throttle: 100, }; diff --git a/src/components/Carousel/Tests/ItemsMap.test.ts b/src/components/Carousel/Tests/ItemsMap.test.ts index b4e0a2fb7..f3565b61a 100644 --- a/src/components/Carousel/Tests/ItemsMap.test.ts +++ b/src/components/Carousel/Tests/ItemsMap.test.ts @@ -366,13 +366,32 @@ describe('ItemsMap', () => { describe('previous item', () => { describe('by key', () => { describe('with separators', () => { + test('have previous item group', () => { + const map = new ItemsMap(); + + map.set(data); + + expect(map.nextGroup(data[2][1])).toEqual(data[1][1]); + expect(map.nextGroup(data[1][1])).toEqual(data[0][1]); + }); + + test('does not have prev item group', () => { + const map = new ItemsMap(); + + map.set(data); + + const key = data[1][0]; + + expect(map.prevGroup(key)).toEqual(undefined); + }); + test('have previous item', () => { const map = new ItemsMap(); map.set(data); - expect(map.next(data[2][1])).toEqual(data[1][1]); - expect(map.next(data[1][1])).toEqual(data[0][1]); + expect(map.prev(data[1][0])).toEqual(data[0][1]); + expect(map.prev(data[2][0])).toEqual(data[1][1]); }); test('does not have prev item', () => { @@ -380,7 +399,7 @@ describe('ItemsMap', () => { map.set(data); - const key = data[1][0]; + const key = data[0][0]; expect(map.prev(key)).toEqual(undefined); }); @@ -389,25 +408,48 @@ describe('ItemsMap', () => { describe('without separators', () => { const onlyItems = true; - test('have previous item', () => { + test('have previous item group', () => { const map = new ItemsMap(); map.set(dataWithSeparators); - expect(map.next(dataWithSeparators[4][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[4][1], onlyItems)).toEqual( dataWithSeparators[2][1] ); - expect(map.next(dataWithSeparators[2][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[2][1], onlyItems)).toEqual( dataWithSeparators[0][1] ); }); + test('does not have prev item group', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.prevGroup(dataWithSeparators[1][0], onlyItems)).toEqual( + undefined + ); + }); + + test('have previous item', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.prev(dataWithSeparators[2][0], onlyItems)).toEqual( + dataWithSeparators[0][1] + ); + expect(map.prev(dataWithSeparators[4][0], onlyItems)).toEqual( + dataWithSeparators[2][1] + ); + }); + test('does not have prev item', () => { const map = new ItemsMap(); map.set(dataWithSeparators); - expect(map.prev(dataWithSeparators[1][0], onlyItems)).toEqual( + expect(map.prev(dataWithSeparators[0][0], onlyItems)).toEqual( undefined ); }); @@ -421,19 +463,38 @@ describe('ItemsMap', () => { const item = 'aaa'; - expect(map.prev(item)).toEqual(undefined); - expect(map.prev('')).toEqual(undefined); + expect(map.prevGroup(item)).toEqual(undefined); + expect(map.prevGroup('')).toEqual(undefined); }); describe('by value', () => { describe('with separators', () => { + test('have previous item group', () => { + const map = new ItemsMap(); + + map.set(data); + + expect(map.nextGroup(data[2][1])).toEqual(data[1][1]); + expect(map.nextGroup(data[1][1])).toEqual(data[0][1]); + }); + + test('does not have prev item group', () => { + const map = new ItemsMap(); + + map.set(data); + + const item = data[2][1]; + + expect(map.prevGroup(item)).toEqual(undefined); + }); + test('have previous item', () => { const map = new ItemsMap(); map.set(data); - expect(map.next(data[2][1])).toEqual(data[1][1]); - expect(map.next(data[1][1])).toEqual(data[0][1]); + expect(map.prev(data[1][1])).toEqual(data[0][1]); + expect(map.prev(data[2][1])).toEqual(data[1][1]); }); test('does not have prev item', () => { @@ -441,7 +502,7 @@ describe('ItemsMap', () => { map.set(data); - const item = data[2][1]; + const item = data[0][1]; expect(map.prev(item)).toEqual(undefined); }); @@ -450,25 +511,48 @@ describe('ItemsMap', () => { describe('without separators', () => { const onlyItems = true; - test('have previous item', () => { + test('have previous item group', () => { const map = new ItemsMap(); map.set(dataWithSeparators); - expect(map.next(dataWithSeparators[4][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[4][1], onlyItems)).toEqual( dataWithSeparators[2][1] ); - expect(map.next(dataWithSeparators[2][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[2][1], onlyItems)).toEqual( dataWithSeparators[0][1] ); }); - test('does not have prev item', () => { + test('does not have prev item group', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.prevGroup(dataWithSeparators[2][1], onlyItems)).toEqual( + undefined + ); + }); + + test('have previous item', () => { const map = new ItemsMap(); map.set(dataWithSeparators); expect(map.prev(dataWithSeparators[2][1], onlyItems)).toEqual( + dataWithSeparators[0][1] + ); + expect(map.prev(dataWithSeparators[4][1], onlyItems)).toEqual( + dataWithSeparators[2][1] + ); + }); + + test('does not have prev item', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.prev(dataWithSeparators[0][1], onlyItems)).toEqual( undefined ); }); @@ -479,13 +563,32 @@ describe('ItemsMap', () => { describe('next item', () => { describe('by key', () => { describe('with separators', () => { + test('have next item group', () => { + const map = new ItemsMap(); + + map.set(data); + + expect(map.nextGroup(data[1][1])).toEqual(data[0][1]); + expect(map.nextGroup(data[2][1])).toEqual(data[1][1]); + }); + + test('does not have next item group', () => { + const map = new ItemsMap(); + + map.set(data); + + const key = data[0][0]; + + expect(map.nextGroup(key)).toEqual(undefined); + }); + test('have next item', () => { const map = new ItemsMap(); map.set(data); - expect(map.next(data[1][1])).toEqual(data[0][1]); - expect(map.next(data[2][1])).toEqual(data[1][1]); + expect(map.next(data[0][0])).toEqual(data[1][1]); + expect(map.next(data[1][0])).toEqual(data[2][1]); }); test('does not have next item', () => { @@ -493,7 +596,7 @@ describe('ItemsMap', () => { map.set(data); - const key = data[0][0]; + const key = data[2][0]; expect(map.next(key)).toEqual(undefined); }); @@ -502,25 +605,48 @@ describe('ItemsMap', () => { describe('without separators', () => { const onlyItems = true; - test('have next item', () => { + test('have next item group', () => { const map = new ItemsMap(); map.set(dataWithSeparators); - expect(map.next(dataWithSeparators[4][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[4][1], onlyItems)).toEqual( dataWithSeparators[2][1] ); - expect(map.next(dataWithSeparators[2][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[2][1], onlyItems)).toEqual( dataWithSeparators[0][1] ); }); + test('does not have next item group', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.nextGroup(dataWithSeparators[1][0], onlyItems)).toEqual( + undefined + ); + }); + + test('have next item', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.next(dataWithSeparators[0][0], onlyItems)).toEqual( + dataWithSeparators[2][1] + ); + expect(map.next(dataWithSeparators[2][0], onlyItems)).toEqual( + dataWithSeparators[4][1] + ); + }); + test('does not have next item', () => { const map = new ItemsMap(); map.set(dataWithSeparators); - expect(map.next(dataWithSeparators[1][0], onlyItems)).toEqual( + expect(map.next(dataWithSeparators[4][0], onlyItems)).toEqual( undefined ); }); @@ -534,19 +660,38 @@ describe('ItemsMap', () => { const item = 'aaa'; - expect(map.next(item)).toEqual(undefined); - expect(map.next('')).toEqual(undefined); + expect(map.nextGroup(item)).toEqual(undefined); + expect(map.nextGroup('')).toEqual(undefined); }); describe('by value', () => { describe('without separators', () => { + test('have next item group', () => { + const map = new ItemsMap(); + + map.set(data); + + expect(map.nextGroup(data[1][1])).toEqual(data[0][1]); + expect(map.nextGroup(data[2][1])).toEqual(data[1][1]); + }); + + test('does not have next item group', () => { + const map = new ItemsMap(); + + map.set(data); + + const item = data.slice(-3)[0][1]; + + expect(map.nextGroup(item)).toEqual(undefined); + }); + test('have next item', () => { const map = new ItemsMap(); map.set(data); - expect(map.next(data[1][1])).toEqual(data[0][1]); - expect(map.next(data[2][1])).toEqual(data[1][1]); + expect(map.next(data[0][1])).toEqual(data[1][1]); + expect(map.next(data[1][1])).toEqual(data[2][1]); }); test('does not have next item', () => { @@ -554,7 +699,7 @@ describe('ItemsMap', () => { map.set(data); - const item = data.slice(-3)[0][1]; + const item = data.slice(-1)[0][1]; expect(map.next(item)).toEqual(undefined); }); @@ -563,28 +708,51 @@ describe('ItemsMap', () => { describe('without separators', () => { const onlyItems = true; - test('have next item', () => { + test('have next item group', () => { const map = new ItemsMap(); map.set(dataWithSeparators); console.log(dataWithSeparators); - expect(map.next(dataWithSeparators[4][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[4][1], onlyItems)).toEqual( dataWithSeparators[2][1] ); - expect(map.next(dataWithSeparators[2][1], onlyItems)).toEqual( + expect(map.nextGroup(dataWithSeparators[2][1], onlyItems)).toEqual( dataWithSeparators[0][1] ); }); - test('does not have next item', () => { + test('does not have next item group', () => { const map = new ItemsMap(); map.set(dataWithSeparators); const item = dataWithSeparators.slice(-2)[0][1]; + expect(map.nextGroup(item, onlyItems)).toEqual(undefined); + }); + + test('have next item', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + expect(map.next(dataWithSeparators[0][1], onlyItems)).toEqual( + dataWithSeparators[2][1] + ); + expect(map.next(dataWithSeparators[2][1], onlyItems)).toEqual( + dataWithSeparators[4][1] + ); + }); + + test('does not have next item', () => { + const map = new ItemsMap(); + + map.set(dataWithSeparators); + + const item = dataWithSeparators.slice(-1)[0][1]; + expect(map.next(item, onlyItems)).toEqual(undefined); }); }); diff --git a/src/components/Carousel/Tests/ScrollMenu.test.tsx b/src/components/Carousel/Tests/ScrollMenu.test.tsx index 2ce8f680d..c82dad48f 100644 --- a/src/components/Carousel/Tests/ScrollMenu.test.tsx +++ b/src/components/Carousel/Tests/ScrollMenu.test.tsx @@ -80,7 +80,7 @@ const options = { ratio: 0.9, root: null as any, rootMargin: '8px', - threshold: [0.05, 0.5, 0.75, 0.95], + threshold: [0, 0.25, 0.5, 0.75, 1], throttle: 100, }; @@ -473,13 +473,22 @@ function comparePublicApi(call: autoScrollApiType) { const { getItemById, getItemByIndex, + getNextElement, + getNextElementGroup, getNextItem, + getNextItemGroup, + getPrevElement, + getPrevElementGroup, getPrevItem, + getPrevItemGroup, isItemVisible, isLastItem, scrollNext, + scrollNextGroup, scrollPrev, + scrollPrevGroup, scrollToItem, + scrollBySingleItem, visibleElementsWithSeparators, visibleElements, initComplete, @@ -491,11 +500,20 @@ function comparePublicApi(call: autoScrollApiType) { expect(getItemById).toEqual(expect.any(Function)); expect(getItemByIndex).toEqual(expect.any(Function)); + expect(getNextElement).toEqual(expect.any(Function)); + expect(getNextElementGroup).toEqual(expect.any(Function)); expect(getNextItem).toEqual(expect.any(Function)); + expect(getNextItemGroup).toEqual(expect.any(Function)); + expect(getPrevElement).toEqual(expect.any(Function)); + expect(getPrevElementGroup).toEqual(expect.any(Function)); expect(getPrevItem).toEqual(expect.any(Function)); + expect(getPrevItemGroup).toEqual(expect.any(Function)); expect(isItemVisible).toEqual(expect.any(Function)); expect(isLastItem).toEqual(expect.any(Function)); + expect(scrollBySingleItem).toEqual(expect.any(Function)); expect(scrollNext).toEqual(expect.any(Function)); + expect(scrollNextGroup).toEqual(expect.any(Function)); + expect(scrollPrevGroup).toEqual(expect.any(Function)); expect(scrollPrev).toEqual(expect.any(Function)); expect(scrollToItem).toEqual(expect.any(Function)); expect(visibleElementsWithSeparators).toEqual(defaultItemsWithSeparators); diff --git a/src/components/Carousel/Tests/Utilities.test.tsx b/src/components/Carousel/Tests/Utilities.test.tsx index 19c813285..c0172968d 100644 --- a/src/components/Carousel/Tests/Utilities.test.tsx +++ b/src/components/Carousel/Tests/Utilities.test.tsx @@ -1,14 +1,5 @@ import React from 'react'; -import { - filterSeparators, - getElementOrConstructor, - getItemElementById, - getItemElementByIndex, - getItemId, - getNodesFromRefs, - observerEntriesToItems, - scrollToItem, -} from '../Utilities'; +import * as utilities from '../Utilities'; import { observerOptions } from '../Settings'; import { IntersectionObserverItem } from '../Carousel.types'; import scrollIntoView from 'smooth-scroll-into-view-if-needed'; @@ -22,7 +13,7 @@ describe('getNodesFromRefs', () => { node2: { current: null as any }, }; - expect(getNodesFromRefs(refs)).toEqual([]); + expect(utilities.getNodesFromRefs(refs)).toEqual([]); }); test('should return array of nodes for existing regs', () => { @@ -33,13 +24,13 @@ describe('getNodesFromRefs', () => { const result = Object.values(refs).map((ref) => ref.current); - expect(getNodesFromRefs(refs as any)).toEqual(result); + expect(utilities.getNodesFromRefs(refs as any)).toEqual(result); }); }); describe('observerEntriesToItems', () => { test('should return empty array if no entries', () => { - expect(observerEntriesToItems([], observerOptions)).toEqual([]); + expect(utilities.observerEntriesToItems([], observerOptions)).toEqual([]); }); test('should return items if entries exist', () => { @@ -98,7 +89,10 @@ describe('observerEntriesToItems', () => { ]; expect( - observerEntriesToItems(entries, { ...observerOptions, ratio: 0.5 }) + utilities.observerEntriesToItems(entries, { + ...observerOptions, + ratio: 0.5, + }) ).toEqual(result); }); }); @@ -120,7 +114,7 @@ describe('scrollToItem', () => { duration: 400, }; - scrollToItem(item); + utilities.scrollToItem(item); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenNthCalledWith( @@ -140,7 +134,14 @@ describe('scrollToItem', () => { } as unknown as IntersectionObserverItem; const noPolyfill = true; - scrollToItem(item, undefined, undefined, undefined, undefined, noPolyfill); + utilities.scrollToItem( + item, + undefined, + undefined, + undefined, + undefined, + noPolyfill + ); expect(scrollIntoView).not.toHaveBeenCalled(); expect(standartScrollIntoViewMock).toHaveBeenCalled(); @@ -156,7 +157,7 @@ describe('scrollToItem', () => { ease: (t: number) => t / 2, boundary: document.createElement('div'), }; - scrollToItem(item, 'auto', 'end', 'center', options); + utilities.scrollToItem(item, 'auto', 'end', 'center', options); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenNthCalledWith(1, item.entry.target, { @@ -168,7 +169,7 @@ describe('scrollToItem', () => { }); test('should not scroll if target not provided', () => { - scrollToItem(undefined); + utilities.scrollToItem(undefined); expect(scrollIntoView).not.toHaveBeenCalled(); }); @@ -182,7 +183,7 @@ describe('getItemElementById', () => {
other node
other2
`; - const result = getItemElementById(id); + const result = utilities.getItemElementById(id); expect(result instanceof HTMLDivElement).toBeTruthy(); expect(result?.textContent).toEqual(id); @@ -195,9 +196,9 @@ describe('getItemElementById', () => {
other node
other2
`; - expect(getItemElementById('test456')).toEqual(null); - expect(getItemElementById(456)).toEqual(null); - expect(getItemElementById('')).toEqual(null); + expect(utilities.getItemElementById('test456')).toEqual(null); + expect(utilities.getItemElementById(456)).toEqual(null); + expect(utilities.getItemElementById('')).toEqual(null); }); }); @@ -209,7 +210,7 @@ describe('getItemElementByIndex', () => {
other node
other2
`; - const result = getItemElementByIndex(index); + const result = utilities.getItemElementByIndex(index); expect(result instanceof HTMLDivElement).toBeTruthy(); expect(result?.textContent).toEqual(index); @@ -222,9 +223,9 @@ describe('getItemElementByIndex', () => {
other node
other2
`; - expect(getItemElementByIndex('456')).toEqual(null); - expect(getItemElementByIndex(456)).toEqual(null); - expect(getItemElementByIndex('')).toEqual(null); + expect(utilities.getItemElementByIndex('456')).toEqual(null); + expect(utilities.getItemElementByIndex(456)).toEqual(null); + expect(utilities.getItemElementByIndex('')).toEqual(null); }); }); @@ -233,24 +234,24 @@ describe('getElementOrConstructor', () => { const JsxElemConstructor = () => JsxElem; test('should return jsx element if jsx elem passed', () => { - expect(getElementOrConstructor(JsxElem)).toEqual(JsxElem); + expect(utilities.getElementOrConstructor(JsxElem)).toEqual(JsxElem); }); test('should return a jsx elem if constructor passed', () => { - expect(getElementOrConstructor(JsxElemConstructor)).toEqual( + expect(utilities.getElementOrConstructor(JsxElemConstructor)).toEqual( ); }); test('should return null if no element passed', () => { - expect(getElementOrConstructor(undefined)).toEqual(null); + expect(utilities.getElementOrConstructor(undefined)).toEqual(null); }); }); describe('filterSeparators', () => { test('should filter separators from items', () => { expect( - filterSeparators([ + utilities.filterSeparators([ 'test0', 'test0-separator', 'test1', @@ -266,8 +267,11 @@ describe('filterSeparators', () => { }); test('should return argument if nothing to filter', () => { - expect(filterSeparators(['test0', 'test1'])).toEqual(['test0', 'test1']); - expect(filterSeparators([])).toEqual([]); + expect(utilities.filterSeparators(['test0', 'test1'])).toEqual([ + 'test0', + 'test1', + ]); + expect(utilities.filterSeparators([])).toEqual([]); }); }); @@ -279,23 +283,25 @@ describe('getItemId', () => { ); it('should return itemId if exists', () => { - expect(getItemId()).toEqual(id); - expect(getItemId()).toEqual(id); + expect(utilities.getItemId()).toEqual(id); + expect(utilities.getItemId()).toEqual(id); }); it('should work if "id" is number', () => { const id = 123; const expected = String(id); - expect(getItemId()).toEqual(expected); - expect(getItemId()).toEqual(expected); + expect(utilities.getItemId()).toEqual(expected); + expect(utilities.getItemId()).toEqual( + expected + ); }); it('should return key if itemId does not exists', () => { - expect(getItemId()).toEqual(id); + expect(utilities.getItemId()).toEqual(id); }); it('should return empty string if itemId and key does not exists', () => { - expect(getItemId()).toEqual(''); + expect(utilities.getItemId()).toEqual(''); }); }); }); diff --git a/src/components/Carousel/Tests/autoScrollApi.test.ts b/src/components/Carousel/Tests/autoScrollApi.test.ts index 91c48a56c..b0c0792fc 100644 --- a/src/components/Carousel/Tests/autoScrollApi.test.ts +++ b/src/components/Carousel/Tests/autoScrollApi.test.ts @@ -174,6 +174,28 @@ describe('autoScrollApi', () => { }); }); + describe('scrollBySingleItem', () => { + test('should call scrollBy', () => { + const scrollBySingleItemSpy = jest.spyOn( + utilities, + 'scrollBySingleItem' + ); + const { items, visibleElementsWithSeparators } = setup([0.7, 0, 0]); + const boundary = { current: document.createElement('div') }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary + ).scrollBySingleItem( + document.createElement('div'), + 'smooth', + 'next', + 8 + ); + expect(scrollBySingleItemSpy).toHaveBeenCalledTimes(1); + }); + }); + test('getItemElementById', () => { const { items, visibleElementsWithSeparators } = setup([0.7, 0, 0]); @@ -322,11 +344,11 @@ describe('autoScrollApi', () => { expect( autoScrollApi(items, visibleElementsWithSeparators).getPrevItem() - ).toEqual(nodes[0.1]); + ).toEqual(nodes[0]); }); test('does not have previous item', () => { - const { items, visibleElementsWithSeparators } = setup([0, 0.1, 1]); + const { items, visibleElementsWithSeparators } = setup([1, 0.1, 0.3]); expect( autoScrollApi(items, visibleElementsWithSeparators).getPrevItem() @@ -334,18 +356,38 @@ describe('autoScrollApi', () => { }); }); + describe('getPrevItemGroup', () => { + test('have previous item group', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([ + 0.1, 1, 0.9, + ]); + + expect( + autoScrollApi(items, visibleElementsWithSeparators).getPrevItemGroup() + ).toEqual(nodes[0.1]); + }); + + test('does not have previous item group', () => { + const { items, visibleElementsWithSeparators } = setup([0, 0.1, 1]); + + expect( + autoScrollApi(items, visibleElementsWithSeparators).getPrevItemGroup() + ).toEqual(undefined); + }); + }); + describe('getPrevElement', () => { - test('have previous item', () => { + test('have previous element', () => { const { items, nodes, visibleElementsWithSeparators } = setup([ 0.1, 1, 0.9, ]); expect( autoScrollApi(items, visibleElementsWithSeparators).getPrevElement() - ).toEqual(nodes[0.1]); + ).toEqual(nodes[0]); }); - test('does not have previous item', () => { + test('does not have previous element', () => { const { items, visibleElementsWithSeparators } = setup([1, 0.1, 0.3]); expect( @@ -354,18 +396,44 @@ describe('autoScrollApi', () => { }); }); + describe('getPrevElementGroup', () => { + test('have previous element group', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([ + 0.1, 1, 0.9, + ]); + + expect( + autoScrollApi( + items, + visibleElementsWithSeparators + ).getPrevElementGroup() + ).toEqual(nodes[0.1]); + }); + + test('does not have previous element group', () => { + const { items, visibleElementsWithSeparators } = setup([1, 0.1, 0.3]); + + expect( + autoScrollApi( + items, + visibleElementsWithSeparators + ).getPrevElementGroup() + ).toEqual(undefined); + }); + }); + describe('getNextItem', () => { test('have next item', () => { const { items, nodes, visibleElementsWithSeparators } = setup([ - 1, 1, 1.1, + 1, 1, 0.3, ]); expect( autoScrollApi(items, visibleElementsWithSeparators).getNextItem() - ).toEqual(nodes[1]); + ).toEqual(nodes[2]); }); test('does not have next item', () => { - const { items, visibleElementsWithSeparators } = setup([0, 0.1]); + const { items, visibleElementsWithSeparators } = setup([0, 0.1, 0.9]); expect( autoScrollApi(items, visibleElementsWithSeparators).getNextItem() @@ -373,21 +441,65 @@ describe('autoScrollApi', () => { }); }); + describe('getNextItemGroup', () => { + test('have next item group', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([ + 1, 1, 1.1, + ]); + expect( + autoScrollApi(items, visibleElementsWithSeparators).getNextItemGroup() + ).toEqual(nodes[1]); + }); + + test('does not have next item group', () => { + const { items, visibleElementsWithSeparators } = setup([0, 0.1]); + + expect( + autoScrollApi(items, visibleElementsWithSeparators).getNextItemGroup() + ).toEqual(undefined); + }); + }); + describe('getNextElement', () => { - test('have next item', () => { + test('have next element', () => { const { items, nodes, visibleElementsWithSeparators } = setup([ - 0, 0.1, 1, 1.1, 2, + 1, 1, 0.1, ]); expect( autoScrollApi(items, visibleElementsWithSeparators).getNextElement() + ).toEqual(nodes[2]); + }); + + test('does not have next element', () => { + const { items, visibleElementsWithSeparators } = setup([0, 0.1, 0.9]); + + expect( + autoScrollApi(items, visibleElementsWithSeparators).getNextElement() + ).toEqual(undefined); + }); + }); + + describe('getNextElementGroup', () => { + test('have next element group', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([ + 0, 0.1, 1, 1.1, 2, + ]); + expect( + autoScrollApi( + items, + visibleElementsWithSeparators + ).getNextElementGroup() ).toEqual(nodes[0]); }); - test('does not have next item', () => { + test('does not have next element group', () => { const { items, visibleElementsWithSeparators } = setup([0, 0.1]); expect( - autoScrollApi(items, visibleElementsWithSeparators).getNextElement() + autoScrollApi( + items, + visibleElementsWithSeparators + ).getNextElementGroup() ).toEqual(undefined); }); }); @@ -416,7 +528,7 @@ describe('autoScrollApi', () => { describe('scrollPrev', () => { test('have previous item', () => { - const { items, nodes, visibleElementsWithSeparators } = setup([1, 2, 3]); + const { items, nodes, visibleElementsWithSeparators } = setup([0, 1, 1]); const boundary = { current: document.createElement('li') }; autoScrollApi( @@ -426,7 +538,7 @@ describe('autoScrollApi', () => { ).scrollPrev(); expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[2].entry.target, { + expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[0].entry.target, { behavior: 'smooth', block: 'nearest', inline: 'end', @@ -436,8 +548,37 @@ describe('autoScrollApi', () => { }); }); + describe('scrollPrevGroup', () => { + test('have previous group', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([ + 1, 2, 3, + ]); + + const boundary = { current: document.createElement('li') }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary + ).scrollPrevGroup(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenNthCalledWith( + 1, + nodes[2].entry.target, + { + behavior: 'smooth', + block: 'nearest', + inline: 'end', + duration: undefined, + ease: undefined, + boundary: boundary.current, + } + ); + }); + }); + test('no prev item', () => { - const { items, visibleElementsWithSeparators } = setup([0, 1]); + const { items, visibleElementsWithSeparators } = setup([1, 1, 1]); autoScrollApi(items, visibleElementsWithSeparators).scrollPrev(); @@ -445,7 +586,7 @@ describe('autoScrollApi', () => { }); test('should pass rtl to scrollToItem', () => { - const { items, visibleElementsWithSeparators } = setup([0, 1, 2, 3]); + const { items, visibleElementsWithSeparators } = setup([0, 1, 1]); const scrollToItemSpy = jest.spyOn(utilities, 'scrollToItem'); const rtl = true; @@ -481,7 +622,7 @@ describe('autoScrollApi', () => { expect(noPolyfillrop).toEqual(noPolyfill); }); - test('with transition options', () => { + test('group with transition options', () => { const { items, nodes, visibleElementsWithSeparators } = setup([1, 2, 3]); const boundary = { current: document.createElement('div') }; @@ -495,7 +636,7 @@ describe('autoScrollApi', () => { visibleElementsWithSeparators, boundary, transitionOptions - ).scrollPrev(); + ).scrollPrevGroup(); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[2].entry.target, { @@ -508,7 +649,34 @@ describe('autoScrollApi', () => { }); }); - test('arguments should have priority over transitionOptions', () => { + test('default with transition options', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([0, 1, 1]); + + const boundary = { current: document.createElement('div') }; + const transitionOptions = { + duration: 400, + ease: (t: number) => t, + behavior: () => false, + }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary, + transitionOptions + ).scrollPrev(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[0].entry.target, { + behavior: transitionOptions.behavior, + block: 'nearest', + inline: 'end', + duration: transitionOptions.duration, + ease: transitionOptions.ease, + boundary: boundary.current, + }); + }); + + test('group arguments should have priority over transitionOptions', () => { const { items, nodes, visibleElementsWithSeparators } = setup([1, 2, 3]); const boundary = { current: document.createElement('div') }; @@ -522,7 +690,7 @@ describe('autoScrollApi', () => { visibleElementsWithSeparators, boundary, transitionOptions - ).scrollPrev('auto', 'center', 'center'); + ).scrollPrevGroup('auto', 'center', 'center'); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[2].entry.target, { @@ -536,11 +704,36 @@ describe('autoScrollApi', () => { }); }); + test('default arguments should have priority over transitionOptions', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([0, 1, 1]); + + const boundary = { current: document.createElement('div') }; + const transitionOptions = { + duration: 500, + ease: (t: number) => t, + behavior: () => false, + }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary, + transitionOptions + ).scrollPrev('auto', 'center', 'center'); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[0].entry.target, { + behavior: 'auto', + block: 'center', + inline: 'center', + duration: transitionOptions.duration, + ease: transitionOptions.ease, + boundary: boundary.current, + }); + }); + describe('scrollNext', () => { test('have next item', () => { - const { items, nodes, visibleElementsWithSeparators } = setup([ - 1, 2, 3, 4, - ]); + const { items, nodes, visibleElementsWithSeparators } = setup([1, 1, 0]); const boundary = { current: document.createElement('li') }; autoScrollApi( @@ -550,7 +743,7 @@ describe('autoScrollApi', () => { ).scrollNext(); expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[1].entry.target, { + expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[2].entry.target, { behavior: 'smooth', block: 'nearest', inline: 'start', @@ -560,6 +753,35 @@ describe('autoScrollApi', () => { }); }); + describe('scrollNextGroup', () => { + test('have next group', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([ + 1, 2, 3, 4, + ]); + + const boundary = { current: document.createElement('li') }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary + ).scrollNextGroup(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenNthCalledWith( + 1, + nodes[1].entry.target, + { + behavior: 'smooth', + block: 'nearest', + inline: 'start', + duration: undefined, + ease: undefined, + boundary: boundary.current, + } + ); + }); + }); + test('no next item', () => { const { items, visibleElementsWithSeparators } = setup([0, 0.1]); @@ -605,7 +827,7 @@ describe('autoScrollApi', () => { expect(noPolyfillrop).toEqual(noPolyfill); }); - test('with transition options', () => { + test('group with transition options', () => { const { items, nodes, visibleElementsWithSeparators } = setup([1, 1, 0]); const boundary = { current: document.createElement('li') }; @@ -619,7 +841,7 @@ describe('autoScrollApi', () => { visibleElementsWithSeparators, boundary, transitionOptions - ).scrollNext(); + ).scrollNextGroup(); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[0].entry.target, { @@ -632,7 +854,34 @@ describe('autoScrollApi', () => { }); }); - test('arguments should have priority over transitionOptions', () => { + test('default with transition options', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([1, 1, 0]); + + const boundary = { current: document.createElement('li') }; + const transitionOptions = { + duration: 400, + ease: (t: number) => t, + behavior: () => false, + }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary, + transitionOptions + ).scrollNext(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[2].entry.target, { + behavior: transitionOptions.behavior, + block: 'nearest', + inline: 'start', + duration: transitionOptions.duration, + ease: transitionOptions.ease, + boundary: boundary.current, + }); + }); + + test('group arguments should have priority over transitionOptions', () => { const { items, nodes, visibleElementsWithSeparators } = setup([1, 1, 0]); const boundary = { current: document.createElement('div') }; @@ -646,7 +895,7 @@ describe('autoScrollApi', () => { visibleElementsWithSeparators, boundary, transitionOptions - ).scrollNext('auto', 'center', 'center'); + ).scrollNextGroup('auto', 'center', 'center'); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[0].entry.target, { @@ -658,5 +907,32 @@ describe('autoScrollApi', () => { boundary: boundary.current, }); }); + + test('default arguments should have priority over transitionOptions', () => { + const { items, nodes, visibleElementsWithSeparators } = setup([1, 1, 0]); + + const boundary = { current: document.createElement('div') }; + const transitionOptions = { + duration: 400, + ease: (t: number) => t, + behavior: () => false, + }; + autoScrollApi( + items, + visibleElementsWithSeparators, + boundary, + transitionOptions + ).scrollNext('auto', 'center', 'center'); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenNthCalledWith(1, nodes[2].entry.target, { + behavior: 'auto', + block: 'center', + inline: 'center', + duration: transitionOptions.duration, + ease: transitionOptions.ease, + boundary: boundary.current, + }); + }); }); }); diff --git a/src/components/Carousel/Tests/useItemsChanged.test.tsx b/src/components/Carousel/Tests/useItemsChanged.test.tsx index d749cdae9..bbcd31ddd 100644 --- a/src/components/Carousel/Tests/useItemsChanged.test.tsx +++ b/src/components/Carousel/Tests/useItemsChanged.test.tsx @@ -155,7 +155,6 @@ describe('useItemsChanged', () => { 'child1', 'child1-separator', 'chidl2', - 'child2-separator', ]); const newHash = JSON.parse(utils.getByTestId('hash').textContent!); diff --git a/src/components/Carousel/Utilities/index.ts b/src/components/Carousel/Utilities/index.ts index 109fd0d8b..ee2533e2c 100644 --- a/src/components/Carousel/Utilities/index.ts +++ b/src/components/Carousel/Utilities/index.ts @@ -6,3 +6,4 @@ export * from './getItemId'; export * from './getNodesFromRefs'; export * from './observerEntriesToItems'; export * from './scrollToItem'; +export * from './scrollBySingleItem'; diff --git a/src/components/Carousel/Utilities/scrollBySingleItem.tsx b/src/components/Carousel/Utilities/scrollBySingleItem.tsx new file mode 100644 index 000000000..15698f8fa --- /dev/null +++ b/src/components/Carousel/Utilities/scrollBySingleItem.tsx @@ -0,0 +1,32 @@ +import { CustomScrollBehaviorCallback } from 'scroll-into-view-if-needed/typings/types'; +import { + CustomScrollBehavior, + IntersectionObserverItem, + ItemOrElement, +} from '../Carousel.types'; + +// TODO: Currently, scrollIntoView API, nor it's extended versions afford an offset to avoid +// occlusion of menu items when single scroll. For now use scrollBy API, then find a normailized solution. +export function scrollBySingleItem( + item: ItemOrElement, + behavior?: ScrollBehavior | CustomScrollBehavior, + direction?: string, + gap?: number, + offset?: number +): T | Promise | void { + const _item: Element = + (item as IntersectionObserverItem)?.entry?.target || (item as Element); + const _behavior: ScrollBehavior | CustomScrollBehaviorCallback = + behavior || 'smooth'; + + if (_item) { + const gapMultiplierValue: number = gap * 2; + const scrollDistance: number = + _item.getBoundingClientRect().width - gapMultiplierValue + offset; + + return _item.parentElement?.scrollBy({ + left: direction === 'next' ? scrollDistance : -scrollDistance, + behavior: _behavior as ScrollBehavior, + }); + } +} diff --git a/src/components/Carousel/autoScrollApi.ts b/src/components/Carousel/autoScrollApi.ts index 0b9bb9c4d..ec0ee374c 100644 --- a/src/components/Carousel/autoScrollApi.ts +++ b/src/components/Carousel/autoScrollApi.ts @@ -1,6 +1,7 @@ import { filterSeparators, scrollToItem, + scrollBySingleItem, getItemElementById, getItemElementByIndex, } from './Utilities'; @@ -42,6 +43,18 @@ export const autoScrollApi = ( const isItemVisible: (id: string) => boolean = (id: string) => visibleElements.includes(String(id)); + const getPrevItemGroup: () => IntersectionObserverItem = () => + items.prevGroup(items.getVisible()?.[0]?.[1]); + + const getPrevElementGroup: () => IntersectionObserverItem = () => + items.prevGroup(items.getVisibleElements()?.[0]?.[1], true); + + const getNextItemGroup: () => IntersectionObserverItem = () => + items.nextGroup(items.getVisible()?.slice?.(-1)?.[0]?.[1]); + + const getNextElementGroup: () => IntersectionObserverItem = () => + items.nextGroup(items.getVisibleElements()?.slice?.(-1)?.[0]?.[1], true); + const getPrevItem: () => IntersectionObserverItem = () => items.prev(items.getVisible()?.[0]?.[1]); @@ -57,6 +70,60 @@ export const autoScrollApi = ( const isLastItem: (id: string) => boolean = (id: string) => items.last() === getItemById(id); + const scrollPrevGroup = ( + behavior?: CustomScrollBehavior, + inline?: ScrollLogicalPosition, + block?: ScrollLogicalPosition, + { + duration, + ease, + boundary = boundaryElement?.current, + }: scrollToItemOptions = {} + ) => { + const _behavior = (behavior ?? + transitionOptions?.behavior) as ScrollBehavior; + + return scrollToItem( + getPrevItemGroup(), + _behavior, + inline || 'end', + block || 'nearest', + { + boundary, + duration: duration ?? transitionOptions?.duration, + ease: ease ?? transitionOptions?.ease, + }, + rtl || noPolyfill + ); + }; + + const scrollNextGroup = ( + behavior?: CustomScrollBehavior, + inline?: ScrollLogicalPosition, + block?: ScrollLogicalPosition, + { + duration, + ease, + boundary = boundaryElement?.current, + }: scrollToItemOptions = {} + ) => { + const _behavior = (behavior ?? + transitionOptions?.behavior) as ScrollBehavior; + + return scrollToItem( + getNextItemGroup(), + _behavior, + inline || 'start', + block || 'nearest', + { + boundary, + duration: duration ?? transitionOptions?.duration, + ease: ease ?? transitionOptions?.ease, + }, + rtl || noPolyfill + ); + }; + const scrollPrev = ( behavior?: CustomScrollBehavior, inline?: ScrollLogicalPosition, @@ -117,15 +184,39 @@ export const autoScrollApi = ( getItemByIndex, getItemElementByIndex, getNextItem, + getNextItemGroup, getNextElement, + getNextElementGroup, getPrevItem, + getPrevItemGroup, getPrevElement, + getPrevElementGroup, isFirstItemVisible, isItemVisible, isLastItem, isLastItemVisible, + scrollBySingleItem: ( + target?: ItemOrElement, + behavior?: CustomScrollBehavior, + direction?: string, + gap?: number, + offset?: number + ) => { + const _behavior: string | Function = + behavior ?? transitionOptions?.behavior; + + return scrollBySingleItem( + target, + _behavior as ScrollBehavior | CustomScrollBehavior, + direction, + gap, + offset + ); + }, scrollNext, + scrollNextGroup, scrollPrev, + scrollPrevGroup, scrollToItem: ( target?: ItemOrElement, behavior?: CustomScrollBehavior, diff --git a/src/components/ConfigProvider/ConfigProvider.stories.tsx b/src/components/ConfigProvider/ConfigProvider.stories.tsx index 7eff21e20..e9aec2b3d 100644 --- a/src/components/ConfigProvider/ConfigProvider.stories.tsx +++ b/src/components/ConfigProvider/ConfigProvider.stories.tsx @@ -1,6 +1,7 @@ -import React, { FC, useState, useRef } from 'react'; +import React, { FC, useState, useRef, useCallback } from 'react'; import { Stories } from '@storybook/addon-docs'; import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useArgs } from '@storybook/client-api'; import { ButtonSize, DefaultButton, @@ -28,7 +29,95 @@ import { Navbar, NavbarContent } from '../Navbar'; import { Dropdown } from '../Dropdown'; import { Menu, MenuVariant } from '../Menu'; import { TextArea } from '../Inputs'; +import DatePicker, { + DatePickerProps, + DatePickerShape, + DatePickerSize, + RangePickerProps, +} from '../DateTimePicker/DatePicker'; import { Dialog } from '../Dialog'; +import { Pagination, PaginationLayoutOptions } from '../Pagination'; +import { SelectOption, SelectSize } from '../Select/Select.types'; +import { Select } from '../Select'; +import { snack, SnackbarContainer } from '../Snackbar'; +import Upload, { UploadProps } from '../Upload'; +import dayjs, { Dayjs } from 'dayjs'; + +// locales +import csCZ from '../Locale/cs_CZ'; // čeština +import daDK from '../Locale/da_DK'; // Dansk +import deDE from '../Locale/de_DE'; // Deutsch +import elGR from '../Locale/el_GR'; // Ελληνικά +import enGB from '../Locale/en_GB'; // English (United Kingdom) +import enUS from '../Locale/en_US'; // English (United States) +import esES from '../Locale/es_ES'; // Español +import esDO from '../Locale/es_DO'; // Español (Dominican Republic) +import esMX from '../Locale/es_MX'; // Español (Mexico) +import fiFI from '../Locale/fi_FI'; // Suomi +import frBE from '../Locale/fr_BE'; // Français (Belgium) TODO: dayjs has no fr_BE locale, use fr +import frCA from '../Locale/fr_CA'; // Français (Canada) +import frFR from '../Locale/fr_FR'; // Français +import heIL from '../Locale/he_IL'; // עברית +// import hiHI from '../Locale/hi_HI'; // हिंदी TODO: Add Hindi locale +import hrHR from '../Locale/hr_HR'; // Hrvatski +import htHT from '../Locale/ht_HT'; // Haitian +import huHU from '../Locale/hu_HU'; // Magyar +import itIT from '../Locale/it_IT'; // Italiano +import jaJP from '../Locale/ja_JP'; // 日本語 +import koKR from '../Locale/ko_KR'; // 한국어 +import msMY from '../Locale/ms_MY'; // Bahasa melayu +import nbNO from '../Locale/nb_NO'; // Norsk +import nlBE from '../Locale/nl_BE'; // Nederlands (Belgium) +import nlNL from '../Locale/nl_NL'; // Nederlands +import plPL from '../Locale/pl_PL'; // Polski +import ptBR from '../Locale/pt_BR'; // Português (Brazil) +import ptPT from '../Locale/pt_PT'; // Português +import ruRU from '../Locale/ru_RU'; // Pусский +import svSE from '../Locale/sv_SE'; // Svenska +import thTH from '../Locale/th_TH'; // ภาษาไทย +import trTR from '../Locale/tr_TR'; // Türkçe +import ukUA from '../Locale/uk_UA'; // Yкраїнська +import zhCN from '../Locale/zh_CN'; // 中文 (简体) +import zhTW from '../Locale/zh_TW'; // 中文 (繁體) + +// Dayjs locales +import 'dayjs/locale/cs'; +import 'dayjs/locale/da'; +import 'dayjs/locale/de'; +import 'dayjs/locale/el'; +import 'dayjs/locale/en'; +import 'dayjs/locale/en-gb'; +import 'dayjs/locale/es'; +import 'dayjs/locale/es-do'; +import 'dayjs/locale/es-mx'; +import 'dayjs/locale/fi'; +import 'dayjs/locale/fr'; // Use fr for fr-BE too +import 'dayjs/locale/fr-ca'; +import 'dayjs/locale/he'; +// import 'dayjs/locale/hi'; uncomment when Hindi locale is added +import 'dayjs/locale/hr'; +import 'dayjs/locale/ht'; +import 'dayjs/locale/hu'; +import 'dayjs/locale/it'; +import 'dayjs/locale/ja'; +import 'dayjs/locale/ko'; +import 'dayjs/locale/ms-my'; +import 'dayjs/locale/nb'; +import 'dayjs/locale/nl-be'; +import 'dayjs/locale/nl'; +import 'dayjs/locale/pl'; +import 'dayjs/locale/pt'; +import 'dayjs/locale/pt-br'; +import 'dayjs/locale/ru'; +import 'dayjs/locale/sv'; +import 'dayjs/locale/th'; +import 'dayjs/locale/tr'; +import 'dayjs/locale/uk'; +import 'dayjs/locale/zh-cn'; +import 'dayjs/locale/zh-tw'; + +const { Dropzone } = Upload; +const { RangePicker } = DatePicker; export default { title: 'Config Provider', @@ -93,7 +182,7 @@ const ThemedComponents: FC = () => { })); return ( - +

Selected Theme: { | Accent

- +

Predefined

+ + + + + + + + ); +}; + +export const Locale = Locale_Story.bind({}); + +const providerArgs = { focusVisibleOptions: { focusVisible: DEFAULT_FOCUS_VISIBLE, focusVisibleElement: DEFAULT_FOCUS_VISIBLE_ELEMENT, @@ -549,31 +891,16 @@ Theming.args = { // customizations the storybook user has applied so far. themeOptions: { name: 'blue', - customTheme: { - varTheme: undefined, - tabsTheme: { - label: '--text-secondary-color', - activeLabel: '--primary-color', - activeBackground: 'transparent', - hoverLabel: '--primary-color', - hoverBackground: 'transparent', - indicatorColor: '--primary-color', - smallActiveBackground: 'transparent', - smallHoverBackground: 'transparent', - pillLabel: '--text-secondary-color', - pillActiveLabel: '--primary-color', - pillActiveBackground: '--accent-color-20', - pillHoverLabel: '--primary-color', - pillBackground: '--grey-color-10', - }, - navbarTheme: { - background: '--primary-color-80', - textColor: '--primary-color-10', - textHoverBackground: '--primary-color-80', - textHoverColor: '--primary-color-20', - }, - }, } as ThemeOptions, icomoonIconSet: {}, + disabled: false, +}; + +Theming.args = { + ...providerArgs, children: , }; + +Locale.args = { + ...providerArgs, +}; diff --git a/src/components/ConfigProvider/ConfigProvider.test.tsx b/src/components/ConfigProvider/ConfigProvider.test.tsx new file mode 100644 index 000000000..f65a048f7 --- /dev/null +++ b/src/components/ConfigProvider/ConfigProvider.test.tsx @@ -0,0 +1,227 @@ +import React, { createContext } from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { ConfigProvider, useConfig } from './ConfigProvider'; +import DisabledContext from './DisabledContext'; +import { IConfigContext } from './ConfigProvider.types'; +import ShapeContext, { Shape } from './ShapeContext'; +import SizeContext, { Size } from './SizeContext'; +import esES from '../Locale/es_ES'; +import iconSet from '../Icon/selection.json'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +const ConfigContext: React.Context> = createContext< + Partial +>({}); + +describe('ConfigProvider', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Renders its children', () => { + const { getByTestId } = render( + +
+ + ); + expect(getByTestId('test-child')).toBeTruthy(); + }); + + test('Provides the theme if props are provided', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(result.current.themeOptions).toEqual({ name: 'red' }); + }); + + test('Provides the no theme if no props are provided', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.themeOptions).toEqual(undefined); + }); + + test('Provides fontOptions config if provided as prop', () => { + const fontOptions = { + fontFamily: 'Roboto', + fontSize: 16, + fontStack: 'sans-serif', + }; + const { result } = renderHook(() => useConfig(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(result.current.fontOptions.customFont).toEqual(fontOptions); + }); + + test('Provides disabled config if provided as prop', () => { + const { getByTestId } = render( + + + {(disabled): JSX.Element => ( +
{disabled.toString()}
+ )} +
+
+ ); + expect(getByTestId('disabled').textContent).toBe('true'); + }); + + test('Provides default disabled config if not provided as prop', () => { + const { getByTestId } = render( + + + {(disabled): JSX.Element => ( +
{disabled.toString()}
+ )} +
+
+ ); + expect(getByTestId('disabled').textContent).toBe('false'); + }); + + test('Provides focusVisibleOptions if props are provided', () => { + const testScope = document.createElement('div'); // create a dummy element + const focusVisibleOptions = { + focusVisible: false, + focusVisibleElement: testScope, // set the testScope element as the focusVisibleElement + }; + const { result } = renderHook(() => useConfig(), { + wrapper: ({ children }) => ( + +
{children}
+
+ ), + }); + expect(result.current.focusVisibleOptions).toEqual(focusVisibleOptions); + }); + + test('Provides default focusVisibleOptions if no props are provided', () => { + const defaultFocusVisibleOptions = { + focusVisible: true, + focusVisibleElement: document.documentElement, + }; + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.focusVisibleOptions).toEqual( + defaultFocusVisibleOptions + ); + }); + + test('Provides shape config if provided as prop', () => { + const shape = Shape.Underline; + const { getByTestId } = render( + + + {(shape: Shape): JSX.Element => ( +
{shape.toString()}
+ )} +
+
+ ); + expect(getByTestId('shape').textContent).toEqual(shape); + }); + + test('Provides no shape config if not provided as prop or context', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.shape).toEqual(undefined); + }); + + test('Provides size config if provided as prop', () => { + const size = Size.Large; + const { getByTestId } = render( + + + {(size: Size): JSX.Element => ( +
{size.toString()}
+ )} +
+
+ ); + expect(getByTestId('size').textContent).toEqual(size); + }); + + test('Provides no size config if not provided as prop or context', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.size).toEqual(undefined); + }); + + test('Provides locale config if provided as prop', () => { + const locale = esES; + const { result } = renderHook(() => useConfig(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.locale).toEqual(locale); + }); + + test('Provides no locale config if not provided as prop or context', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.locale).toEqual(undefined); + }); + + test('Provides icomoon icon set if provided as prop', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.icomoonIconSet).toEqual(iconSet); + }); + + test('Provides no icomoon icon set if not provided as prop or context', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.icomoonIconSet).toEqual({}); + }); + + test('Provides form context values if provided as prop', () => { + const formProps = { + validateMessages: { + required: 'Test is required.', + }, + requiredMark: true, + colon: true, + }; + const { result } = renderHook(() => useConfig(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.form).toEqual(formProps); + }); + + test('Provides no form context values if not provided as prop or context', () => { + const { result } = renderHook(() => useConfig(), { + wrapper: ConfigProvider, + }); + expect(result.current.form).toEqual(undefined); + }); +}); diff --git a/src/components/ConfigProvider/ConfigProvider.tsx b/src/components/ConfigProvider/ConfigProvider.tsx index 861511d6b..0899f1a62 100644 --- a/src/components/ConfigProvider/ConfigProvider.tsx +++ b/src/components/ConfigProvider/ConfigProvider.tsx @@ -141,12 +141,18 @@ const ConfigProvider: FC = ({ {childNode} diff --git a/src/components/ConfigProvider/ConfigProvider.types.ts b/src/components/ConfigProvider/ConfigProvider.types.ts index 42dacd71d..bcd7b5c5b 100644 --- a/src/components/ConfigProvider/ConfigProvider.types.ts +++ b/src/components/ConfigProvider/ConfigProvider.types.ts @@ -37,9 +37,19 @@ export interface IConfigContext { themeOptions: ThemeOptions; setFontOptions: (fontOptions: FontOptions) => void; setThemeOptions: (themeOptions: ThemeOptions) => void; + disabled?: boolean; + focusVisibleOptions?: FocusVisibleOptions; + form?: { + validateMessages?: ValidateMessages; + requiredMark?: RequiredMark; + colon?: boolean; + }; + icomoonIconSet?: Object; + locale?: Locale; registeredFont?: IRegisterFont; registeredTheme?: IRegisterTheme; - icomoonIconSet?: Object; + shape?: Shape; + size?: Size; } export interface ConfigProviderProps { @@ -88,7 +98,7 @@ export interface ConfigProviderProps { size?: Size; /** * Options for theming - * @default { name: 'blue', useSystemTheme: false, customTheme: null } + * @default { name: 'blue', customTheme: null } */ themeOptions?: ThemeOptions; } diff --git a/src/components/ConfigProvider/Theming/Theming.types.ts b/src/components/ConfigProvider/Theming/Theming.types.ts index c91a8620b..ec9db7370 100644 --- a/src/components/ConfigProvider/Theming/Theming.types.ts +++ b/src/components/ConfigProvider/Theming/Theming.types.ts @@ -65,12 +65,6 @@ export interface ThemeOptions { * @default blue */ name?: ThemeName; - /** - * Use system theme or not - * @default false - * @experimental - */ - useSystemTheme?: boolean; /** * Define a custom theme palette * @type {OcBaseTheme} diff --git a/src/components/ConfigProvider/Theming/themes.ts b/src/components/ConfigProvider/Theming/themes.ts index b60bfc9ef..d395c6324 100644 --- a/src/components/ConfigProvider/Theming/themes.ts +++ b/src/components/ConfigProvider/Theming/themes.ts @@ -1,36 +1,8 @@ import { OcBaseTheme, OcTheme, OcThemeName } from './Theming.types'; -export const themeDefaults: OcBaseTheme = { - textColor: '#1A212E', - textColorSecondary: '#4F5666', - textColorInverse: '#fff', - backgroundColor: '#fff', - successColor: '#2B715F', - warningColor: '#9D6309', - infoColor: '#4F5666', - errorColor: '#993838', - tabsTheme: { - label: '#4F5666', - activeLabel: '#146DA6', - activeBackground: 'transparent', - hoverLabel: '#054D7B', - hoverBackground: 'transparent', - indicatorColor: '#146DA6', - smallActiveBackground: 'transparent', - smallHoverBackground: 'transparent', - pillLabel: '#4F5666', - pillActiveLabel: '#054D7B', - pillActiveBackground: '#B0F3FE', - pillHoverLabel: '#054D7B', - pillBackground: '#F6F7F8', - }, - navbarTheme: { - background: '#054D7B', - textColor: '#fff', - textHoverBackground: '#054D7B', - textHoverColor: '#fff', - }, -}; +// NOTE: Theme should not provide defaults. The css variables provide +// the defaults, and the ConfigProvider theme can override them accordingly. +export const themeDefaults: OcBaseTheme = {}; export const red: OcTheme = { primaryColor: '#6C2222', diff --git a/src/components/DateTimePicker/Internal/Locale/cs_CZ.ts b/src/components/DateTimePicker/Internal/Locale/cs_CZ.ts index a84fdf40e..2488d33c5 100644 --- a/src/components/DateTimePicker/Internal/Locale/cs_CZ.ts +++ b/src/components/DateTimePicker/Internal/Locale/cs_CZ.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'cs_CZ', - today: 'Dnes', - now: 'Nyní', backToToday: 'Zpět na dnešek', - ok: 'OK', clear: 'Vymazat', - month: 'Měsíc', - year: 'Rok', - timeSelect: 'Vybrat čas', - dateSelect: 'Vybrat datum', - monthSelect: 'Vyberte měsíc', - yearSelect: 'Vyberte rok', - decadeSelect: 'Vyberte dekádu', - yearFormat: 'YYYY', dateFormat: 'D.M.YYYY', - dayFormat: 'D', + dateSelect: 'Vybrat datum', dateTimeFormat: 'D.M.YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Vyberte dekádu', + month: 'Měsíc', monthBeforeYear: true, - previousMonth: 'Předchozí měsíc (PageUp)', + monthSelect: 'Vyberte měsíc', + nextCentury: 'Následující století', + nextDecade: 'Následující dekáda', nextMonth: 'Následující (PageDown)', - previousYear: 'Předchozí rok (Control + left)', nextYear: 'Následující rok (Control + right)', - previousDecade: 'Předchozí dekáda', - nextDecade: 'Následující dekáda', + now: 'Nyní', + ok: 'OK', previousCentury: 'Předchozí století', - nextCentury: 'Následující století', + previousDecade: 'Předchozí dekáda', + previousMonth: 'Předchozí měsíc (PageUp)', + previousYear: 'Předchozí rok (Control + left)', + shortMonths: 'led_úno_bře_dub_kvě_čvn_čvc_srp_zář_říj_lis_pro'.split('_'), + shortWeekDays: 'ne_po_út_st_čt_pá_so'.split('_'), + timeSelect: 'Vybrat čas', + today: 'Dnes', + year: 'Rok', + yearFormat: 'YYYY', + yearSelect: 'Vyberte rok', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/da_DK.ts b/src/components/DateTimePicker/Internal/Locale/da_DK.ts index 49eac23f5..df6103c7d 100644 --- a/src/components/DateTimePicker/Internal/Locale/da_DK.ts +++ b/src/components/DateTimePicker/Internal/Locale/da_DK.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'da_DK', - today: 'I dag', - now: 'Nu', backToToday: 'Gå til i dag', - ok: 'OK', clear: 'Ryd', - month: 'Måned', - year: 'År', - timeSelect: 'Vælg tidspunkt', - dateSelect: 'Vælg dato', - monthSelect: 'Vælg måned', - yearSelect: 'Vælg år', - decadeSelect: 'Vælg årti', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Vælg dato', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Vælg årti', + month: 'Måned', monthBeforeYear: true, - previousMonth: 'Forrige måned (Page Up)', + monthSelect: 'Vælg måned', + nextCentury: 'Næste århundrede', + nextDecade: 'Næste årti', nextMonth: 'Næste måned (Page Down)', - previousYear: 'Forrige år (Ctrl-venstre pil)', nextYear: 'Næste år (Ctrl-højre pil)', - previousDecade: 'Forrige årti', - nextDecade: 'Næste årti', + now: 'Nu', + ok: 'OK', previousCentury: 'Forrige århundrede', - nextCentury: 'Næste århundrede', + previousDecade: 'Forrige årti', + previousMonth: 'Forrige måned (Page Up)', + previousYear: 'Forrige år (Ctrl-venstre pil)', + shortMonths: 'jan_feb_mar_apr_maj_juni_juli_aug_sept_okt_nov_dec'.split('_'), + shortWeekDays: 'sø_ma_ti_on_to_fr_lø'.split('_'), + timeSelect: 'Vælg tidspunkt', + today: 'I dag', + year: 'År', + yearFormat: 'YYYY', + yearSelect: 'Vælg år', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/de_DE.ts b/src/components/DateTimePicker/Internal/Locale/de_DE.ts index 596e50db4..29394223a 100644 --- a/src/components/DateTimePicker/Internal/Locale/de_DE.ts +++ b/src/components/DateTimePicker/Internal/Locale/de_DE.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'de_DE', - today: 'Heute', - now: 'Jetzt', backToToday: 'Zurück zu Heute', - ok: 'OK', clear: 'Zurücksetzen', - month: 'Monat', - year: 'Jahr', - timeSelect: 'Zeit wählen', - dateSelect: 'Datum wählen', - monthSelect: 'Wähle einen Monat', - yearSelect: 'Wähle ein Jahr', - decadeSelect: 'Wähle ein Jahrzehnt', - yearFormat: 'YYYY', dateFormat: 'D.M.YYYY', - dayFormat: 'D', + dateSelect: 'Datum wählen', dateTimeFormat: 'D.M.YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Wähle ein Jahrzehnt', + month: 'Monat', monthBeforeYear: true, - previousMonth: 'Vorheriger Monat (PageUp)', + monthSelect: 'Wähle einen Monat', + nextCentury: 'Nächstes Jahrhundert', + nextDecade: 'Nächstes Jahrzehnt', nextMonth: 'Nächster Monat (PageDown)', - previousYear: 'Vorheriges Jahr (Ctrl + left)', nextYear: 'Nächstes Jahr (Ctrl + right)', - previousDecade: 'Vorheriges Jahrzehnt', - nextDecade: 'Nächstes Jahrzehnt', + now: 'Jetzt', + ok: 'OK', previousCentury: 'Vorheriges Jahrhundert', - nextCentury: 'Nächstes Jahrhundert', + previousDecade: 'Vorheriges Jahrzehnt', + previousMonth: 'Vorheriger Monat (PageUp)', + previousYear: 'Vorheriges Jahr (Ctrl + left)', + shortMonths: 'Jan_Feb_März_Apr_Mai_Juni_Juli_Aug_Sept_Okt_Nov_Dez'.split('_'), + shortWeekDays: 'So_Mo_Di_Mi_Do_Fr_Sa'.split('_'), + timeSelect: 'Zeit wählen', + today: 'Heute', + year: 'Jahr', + yearFormat: 'YYYY', + yearSelect: 'Wähle ein Jahr', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/el_GR.ts b/src/components/DateTimePicker/Internal/Locale/el_GR.ts index e1e2a2efb..5f6691c08 100644 --- a/src/components/DateTimePicker/Internal/Locale/el_GR.ts +++ b/src/components/DateTimePicker/Internal/Locale/el_GR.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'el_GR', - today: 'Σήμερα', - now: 'Τώρα', backToToday: 'Πίσω στη σημερινή μέρα', - ok: 'OK', clear: 'Καθαρισμός', - month: 'Μήνας', - year: 'Έτος', - timeSelect: 'Επιλογή ώρας', - dateSelect: 'Επιλογή ημερομηνίας', - monthSelect: 'Επιλογή μήνα', - yearSelect: 'Επιλογή έτους', - decadeSelect: 'Επιλογή δεκαετίας', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Επιλογή ημερομηνίας', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Επιλογή δεκαετίας', + month: 'Μήνας', monthBeforeYear: true, - previousMonth: 'Προηγούμενος μήνας (PageUp)', + monthSelect: 'Επιλογή μήνα', + nextCentury: 'Επόμενος αιώνας', + nextDecade: 'Επόμενη δεκαετία', nextMonth: 'Επόμενος μήνας (PageDown)', - previousYear: 'Προηγούμενο έτος (Control + αριστερά)', nextYear: 'Επόμενο έτος (Control + δεξιά)', - previousDecade: 'Προηγούμενη δεκαετία', - nextDecade: 'Επόμενη δεκαετία', + now: 'Τώρα', + ok: 'OK', previousCentury: 'Προηγούμενος αιώνας', - nextCentury: 'Επόμενος αιώνας', + previousDecade: 'Προηγούμενη δεκαετία', + previousMonth: 'Προηγούμενος μήνας (PageUp)', + previousYear: 'Προηγούμενο έτος (Control + αριστερά)', + shortMonths: 'Ιαν_Φεβ_Μαρ_Απρ_Μαι_Ιουν_Ιουλ_Αυγ_Σεπτ_Οκτ_Νοε_Δεκ'.split('_'), + shortWeekDays: 'Κυ_Δε_Τρ_Τε_Πε_Πα_Σα'.split('_'), + timeSelect: 'Επιλογή ώρας', + today: 'Σήμερα', + year: 'Έτος', + yearFormat: 'YYYY', + yearSelect: 'Επιλογή έτους', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/en_GB.ts b/src/components/DateTimePicker/Internal/Locale/en_GB.ts index e43cbcbd9..9fb8a7f39 100644 --- a/src/components/DateTimePicker/Internal/Locale/en_GB.ts +++ b/src/components/DateTimePicker/Internal/Locale/en_GB.ts @@ -2,31 +2,31 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'en_GB', - today: 'Today', - now: 'Now', backToToday: 'Back to today', - ok: 'OK', clear: 'Clear', - month: 'Month', - year: 'Year', - timeSelect: 'Select time', - dateSelect: 'Select date', - monthSelect: 'Choose a month', - yearSelect: 'Choose a year', - decadeSelect: 'Choose a decade', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Select date', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Choose a decade', + month: 'Month', monthBeforeYear: true, - previousMonth: 'Previous month (PageUp)', + monthSelect: 'Choose a month', + nextCentury: 'Next century', + nextDecade: 'Next decade', nextMonth: 'Next month (PageDown)', - previousYear: 'Last year (Control + left)', nextYear: 'Next year (Control + right)', - previousDecade: 'Last decade', - nextDecade: 'Next decade', + now: 'Now', + ok: 'OK', previousCentury: 'Last century', - nextCentury: 'Next century', + previousDecade: 'Last decade', + previousMonth: 'Previous month (PageUp)', + previousYear: 'Last year (Control + left)', + timeSelect: 'Select time', + today: 'Today', + year: 'Year', + yearFormat: 'YYYY', + yearSelect: 'Choose a year', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/en_US.ts b/src/components/DateTimePicker/Internal/Locale/en_US.ts index 92db1211b..8d4772f12 100644 --- a/src/components/DateTimePicker/Internal/Locale/en_US.ts +++ b/src/components/DateTimePicker/Internal/Locale/en_US.ts @@ -2,32 +2,32 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'en_US', - today: 'Today', - now: 'Now', backToToday: 'Back to today', - ok: 'OK', clear: 'Clear', - month: 'Month', - year: 'Year', - timeSelect: 'select time', - dateSelect: 'select date', - weekSelect: 'Choose a week', - monthSelect: 'Choose a month', - yearSelect: 'Choose a year', - decadeSelect: 'Choose a decade', - yearFormat: 'YYYY', dateFormat: 'M/D/YYYY', - dayFormat: 'D', + dateSelect: 'select date', dateTimeFormat: 'M/D/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Choose a decade', + month: 'Month', monthBeforeYear: true, - previousMonth: 'Previous month (PageUp)', + monthSelect: 'Choose a month', + nextCentury: 'Next century', + nextDecade: 'Next decade', nextMonth: 'Next month (PageDown)', - previousYear: 'Last year (Control + left)', nextYear: 'Next year (Control + right)', - previousDecade: 'Last decade', - nextDecade: 'Next decade', + now: 'Now', + ok: 'OK', previousCentury: 'Last century', - nextCentury: 'Next century', + previousDecade: 'Last decade', + previousMonth: 'Previous month (PageUp)', + previousYear: 'Last year (Control + left)', + timeSelect: 'select time', + today: 'Today', + weekSelect: 'Choose a week', + year: 'Year', + yearFormat: 'YYYY', + yearSelect: 'Choose a year', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/es_DO.ts b/src/components/DateTimePicker/Internal/Locale/es_DO.ts index 478d46953..30a86302f 100644 --- a/src/components/DateTimePicker/Internal/Locale/es_DO.ts +++ b/src/components/DateTimePicker/Internal/Locale/es_DO.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'es_DO', - today: 'Hoy', - now: 'Ahora', backToToday: 'Volver a hoy', - ok: 'Aceptar', clear: 'Limpiar', - month: 'Mes', - year: 'Año', - timeSelect: 'Seleccionar hora', - dateSelect: 'Seleccionar fecha', - monthSelect: 'Elegir un mes', - yearSelect: 'Elegir un año', - decadeSelect: 'Elegir una década', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Seleccionar fecha', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Elegir una década', + month: 'Mes', monthBeforeYear: true, - previousMonth: 'Mes anterior (PageUp)', + monthSelect: 'Elegir un mes', + nextCentury: 'Siglo siguiente', + nextDecade: 'Década siguiente', nextMonth: 'Mes siguiente (PageDown)', - previousYear: 'Año anterior (Control + left)', nextYear: 'Año siguiente (Control + right)', - previousDecade: 'Década anterior', - nextDecade: 'Década siguiente', + now: 'Ahora', + ok: 'Aceptar', previousCentury: 'Siglo anterior', - nextCentury: 'Siglo siguiente', + previousDecade: 'Década anterior', + previousMonth: 'Mes anterior (PageUp)', + previousYear: 'Año anterior (Control + left)', + shortMonths: 'ene_feb_mar_abr_may_jun_jul_ago_sep_oct_nov_dic'.split('_'), + shortWeekDays: 'do_lu_ma_mi_ju_vi_sá'.split('_'), + timeSelect: 'Seleccionar hora', + today: 'Hoy', + year: 'Año', + yearFormat: 'YYYY', + yearSelect: 'Elegir un año', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/es_ES.ts b/src/components/DateTimePicker/Internal/Locale/es_ES.ts index 6335d1c3f..156cab27b 100644 --- a/src/components/DateTimePicker/Internal/Locale/es_ES.ts +++ b/src/components/DateTimePicker/Internal/Locale/es_ES.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'es_ES', - today: 'Hoy', - now: 'Ahora', backToToday: 'Volver a hoy', - ok: 'Aceptar', clear: 'Limpiar', - month: 'Mes', - year: 'Año', - timeSelect: 'Seleccionar hora', - dateSelect: 'Seleccionar fecha', - monthSelect: 'Elegir un mes', - yearSelect: 'Elegir un año', - decadeSelect: 'Elegir una década', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Seleccionar fecha', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Elegir una década', + month: 'Mes', monthBeforeYear: true, - previousMonth: 'Mes anterior (PageUp)', + monthSelect: 'Elegir un mes', + nextCentury: 'Siglo siguiente', + nextDecade: 'Década siguiente', nextMonth: 'Mes siguiente (PageDown)', - previousYear: 'Año anterior (Control + left)', nextYear: 'Año siguiente (Control + right)', - previousDecade: 'Década anterior', - nextDecade: 'Década siguiente', + now: 'Ahora', + ok: 'Aceptar', previousCentury: 'Siglo anterior', - nextCentury: 'Siglo siguiente', + previousDecade: 'Década anterior', + previousMonth: 'Mes anterior (PageUp)', + previousYear: 'Año anterior (Control + left)', + shortMonths: 'ene_feb_mar_abr_may_jun_jul_ago_sep_oct_nov_dic'.split('_'), + shortWeekDays: 'do_lu_ma_mi_ju_vi_sá'.split('_'), + timeSelect: 'Seleccionar hora', + today: 'Hoy', + year: 'Año', + yearFormat: 'YYYY', + yearSelect: 'Elegir un año', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/es_MX.ts b/src/components/DateTimePicker/Internal/Locale/es_MX.ts index 8024186f9..2a7066ecd 100644 --- a/src/components/DateTimePicker/Internal/Locale/es_MX.ts +++ b/src/components/DateTimePicker/Internal/Locale/es_MX.ts @@ -2,32 +2,34 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'es_MX', - today: 'Hoy', - now: 'Ahora', backToToday: 'Volver a hoy', - ok: 'Aceptar', clear: 'Limpiar', - month: 'Mes', - year: 'Año', - timeSelect: 'elegir hora', - dateSelect: 'elegir fecha', - weekSelect: 'elegir semana', - monthSelect: 'Seleccionar mes', - yearSelect: 'Seleccionar año', - decadeSelect: 'Seleccionar década', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'elegir fecha', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Seleccionar década', + month: 'Mes', monthBeforeYear: true, - previousMonth: 'Mes anterior (PageUp)', + monthSelect: 'Seleccionar mes', + nextCentury: 'Siglo siguiente', + nextDecade: 'Década siguiente', nextMonth: 'Mes siguiente (PageDown)', - previousYear: 'Año anterior (Control + Left)', nextYear: 'Año siguiente (Control + Right)', - previousDecade: 'Década anterior', - nextDecade: 'Década siguiente', + now: 'Ahora', + ok: 'Aceptar', previousCentury: 'Siglo anterior', - nextCentury: 'Siglo siguiente', + previousDecade: 'Década anterior', + previousMonth: 'Mes anterior (PageUp)', + previousYear: 'Año anterior (Control + Left)', + shortMonths: 'ene_feb_mar_abr_may_jun_jul_ago_sep_oct_nov_dic'.split('_'), + shortWeekDays: 'do_lu_ma_mi_ju_vi_sá'.split('_'), + timeSelect: 'elegir hora', + today: 'Hoy', + weekSelect: 'elegir semana', + year: 'Año', + yearFormat: 'YYYY', + yearSelect: 'Seleccionar año', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/fi_FI.ts b/src/components/DateTimePicker/Internal/Locale/fi_FI.ts index fd461dcd1..e90293851 100644 --- a/src/components/DateTimePicker/Internal/Locale/fi_FI.ts +++ b/src/components/DateTimePicker/Internal/Locale/fi_FI.ts @@ -2,31 +2,36 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'fi_FI', - today: 'Tänään', - now: 'Nyt', backToToday: 'Tämä päivä', - ok: 'OK', clear: 'Tyhjennä', - month: 'Kuukausi', - year: 'Vuosi', - timeSelect: 'Valise aika', - dateSelect: 'Valitse päivä', - monthSelect: 'Valitse kuukausi', - yearSelect: 'Valitse vuosi', - decadeSelect: 'Valitse vuosikymmen', - yearFormat: 'YYYY', dateFormat: 'D.M.YYYY', - dayFormat: 'D', + dateSelect: 'Valitse päivä', dateTimeFormat: 'D.M.YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Valitse vuosikymmen', + month: 'Kuukausi', monthBeforeYear: true, - previousMonth: 'Edellinen kuukausi (PageUp)', + monthSelect: 'Valitse kuukausi', + nextCentury: 'Seuraava vuosisata', + nextDecade: 'Seuraava vuosikymmen', nextMonth: 'Seuraava kuukausi (PageDown)', - previousYear: 'Edellinen vuosi (Control + left)', nextYear: 'Seuraava vuosi (Control + right)', - previousDecade: 'Edellinen vuosikymmen', - nextDecade: 'Seuraava vuosikymmen', + now: 'Nyt', + ok: 'OK', previousCentury: 'Edellinen vuosisata', - nextCentury: 'Seuraava vuosisata', + previousDecade: 'Edellinen vuosikymmen', + previousMonth: 'Edellinen kuukausi (PageUp)', + previousYear: 'Edellinen vuosi (Control + left)', + shortMonths: + 'tammi_helmi_maalis_huhti_touko_kesä_heinä_elo_syys_loka_marras_joulu'.split( + '_' + ), + shortWeekDays: 'su_ma_ti_ke_to_pe_la'.split('_'), + timeSelect: 'Valise aika', + today: 'Tänään', + year: 'Vuosi', + yearFormat: 'YYYY', + yearSelect: 'Valitse vuosi', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/fr_BE.ts b/src/components/DateTimePicker/Internal/Locale/fr_BE.ts index 2118911cc..e4d1c2e26 100644 --- a/src/components/DateTimePicker/Internal/Locale/fr_BE.ts +++ b/src/components/DateTimePicker/Internal/Locale/fr_BE.ts @@ -2,31 +2,35 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'fr_BE', - today: "Aujourd'hui", - now: 'Maintenant', backToToday: "Aujourd'hui", - ok: 'OK', clear: 'Rétablir', - month: 'Mois', - year: 'Année', - timeSelect: "Sélectionner l'heure", - dateSelect: "Sélectionner l'heure", - monthSelect: 'Choisissez un mois', - yearSelect: 'Choisissez une année', - decadeSelect: 'Choisissez une décennie', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: "Sélectionner l'heure", dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Choisissez une décennie', + month: 'Mois', monthBeforeYear: true, - previousMonth: 'Mois précédent (PageUp)', + monthSelect: 'Choisissez un mois', + nextCentury: 'Siècle suivant', + nextDecade: 'Décennie suivante', nextMonth: 'Mois suivant (PageDown)', - previousYear: 'Année précédente (Ctrl + gauche)', nextYear: 'Année prochaine (Ctrl + droite)', - previousDecade: 'Décennie précédente', - nextDecade: 'Décennie suivante', + now: 'Maintenant', + ok: 'OK', previousCentury: 'Siècle précédent', - nextCentury: 'Siècle suivant', + previousDecade: 'Décennie précédente', + previousMonth: 'Mois précédent (PageUp)', + previousYear: 'Année précédente (Ctrl + gauche)', + shortMonths: 'janv_févr_mars_avr_mai_juin_juil_août_sept_oct_nov_déc'.split( + '_' + ), + shortWeekDays: 'di_lu_ma_me_je_ve_sa'.split('_'), + timeSelect: "Sélectionner l'heure", + today: "Aujourd'hui", + year: 'Année', + yearFormat: 'YYYY', + yearSelect: 'Choisissez une année', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/fr_CA.ts b/src/components/DateTimePicker/Internal/Locale/fr_CA.ts index d5b4f6cc5..d469c8532 100644 --- a/src/components/DateTimePicker/Internal/Locale/fr_CA.ts +++ b/src/components/DateTimePicker/Internal/Locale/fr_CA.ts @@ -2,31 +2,35 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'fr_CA', - today: "Aujourd'hui", - now: 'Maintenant', backToToday: "Aujourd'hui", - ok: 'OK', clear: 'Rétablir', - month: 'Mois', - year: 'Année', - timeSelect: "Sélectionner l'heure", - dateSelect: 'Sélectionner la date', - monthSelect: 'Choisissez un mois', - yearSelect: 'Choisissez une année', - decadeSelect: 'Choisissez une décennie', - yearFormat: 'YYYY', dateFormat: 'DD/MM/YYYY', - dayFormat: 'DD', + dateSelect: 'Sélectionner la date', dateTimeFormat: 'DD/MM/YYYY HH:mm:ss', + dayFormat: 'DD', + decadeSelect: 'Choisissez une décennie', + month: 'Mois', monthBeforeYear: true, - previousMonth: 'Mois précédent (PageUp)', + monthSelect: 'Choisissez un mois', + nextCentury: 'Siècle suivant', + nextDecade: 'Décennie suivante', nextMonth: 'Mois suivant (PageDown)', - previousYear: 'Année précédente (Ctrl + gauche)', nextYear: 'Année prochaine (Ctrl + droite)', - previousDecade: 'Décennie précédente', - nextDecade: 'Décennie suivante', + now: 'Maintenant', + ok: 'OK', previousCentury: 'Siècle précédent', - nextCentury: 'Siècle suivant', + previousDecade: 'Décennie précédente', + previousMonth: 'Mois précédent (PageUp)', + previousYear: 'Année précédente (Ctrl + gauche)', + shortMonths: 'janv_févr_mars_avr_mai_juin_juil_août_sept_oct_nov_déc'.split( + '_' + ), + shortWeekDays: 'di_lu_ma_me_je_ve_sa'.split('_'), + timeSelect: "Sélectionner l'heure", + today: "Aujourd'hui", + year: 'Année', + yearFormat: 'YYYY', + yearSelect: 'Choisissez une année', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/fr_FR.ts b/src/components/DateTimePicker/Internal/Locale/fr_FR.ts index 7601678e0..16f907932 100644 --- a/src/components/DateTimePicker/Internal/Locale/fr_FR.ts +++ b/src/components/DateTimePicker/Internal/Locale/fr_FR.ts @@ -2,31 +2,35 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'fr_FR', - today: "Aujourd'hui", - now: 'Maintenant', backToToday: "Aujourd'hui", - ok: 'OK', clear: 'Rétablir', - month: 'Mois', - year: 'Année', - timeSelect: "Sélectionner l'heure", - dateSelect: 'Sélectionner la date', - monthSelect: 'Choisissez un mois', - yearSelect: 'Choisissez une année', - decadeSelect: 'Choisissez une décennie', - yearFormat: 'YYYY', dateFormat: 'DD/MM/YYYY', - dayFormat: 'DD', + dateSelect: 'Sélectionner la date', dateTimeFormat: 'DD/MM/YYYY HH:mm:ss', + dayFormat: 'DD', + decadeSelect: 'Choisissez une décennie', + month: 'Mois', monthBeforeYear: true, - previousMonth: 'Mois précédent (PageUp)', + monthSelect: 'Choisissez un mois', + nextCentury: 'Siècle suivant', + nextDecade: 'Décennie suivante', nextMonth: 'Mois suivant (PageDown)', - previousYear: 'Année précédente (Ctrl + gauche)', nextYear: 'Année prochaine (Ctrl + droite)', - previousDecade: 'Décennie précédente', - nextDecade: 'Décennie suivante', + now: 'Maintenant', + ok: 'OK', previousCentury: 'Siècle précédent', - nextCentury: 'Siècle suivant', + previousDecade: 'Décennie précédente', + previousMonth: 'Mois précédent (PageUp)', + previousYear: 'Année précédente (Ctrl + gauche)', + shortMonths: 'janv_févr_mars_avr_mai_juin_juil_août_sept_oct_nov_déc'.split( + '_' + ), + shortWeekDays: 'di_lu_ma_me_je_ve_sa'.split('_'), + timeSelect: "Sélectionner l'heure", + today: "Aujourd'hui", + year: 'Année', + yearFormat: 'YYYY', + yearSelect: 'Choisissez une année', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/he_IL.ts b/src/components/DateTimePicker/Internal/Locale/he_IL.ts index b80c96280..46095bf0a 100644 --- a/src/components/DateTimePicker/Internal/Locale/he_IL.ts +++ b/src/components/DateTimePicker/Internal/Locale/he_IL.ts @@ -2,32 +2,34 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'he_IL', - today: 'היום', - now: 'עכשיו', backToToday: 'חזור להיום', - ok: 'אישור', clear: 'איפוס', - month: 'חודש', - year: 'שנה', - timeSelect: 'בחר שעה', - dateSelect: 'בחר תאריך', - weekSelect: 'בחר שבוע', - monthSelect: 'בחר חודש', - yearSelect: 'בחר שנה', - decadeSelect: 'בחר עשור', - yearFormat: 'YYYY', dateFormat: 'M/D/YYYY', - dayFormat: 'D', + dateSelect: 'בחר תאריך', dateTimeFormat: 'M/D/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'בחר עשור', + month: 'חודש', monthBeforeYear: true, - previousMonth: 'חודש קודם (PageUp)', + monthSelect: 'בחר חודש', + nextCentury: 'המאה הבאה', + nextDecade: 'העשור הבא', nextMonth: 'חודש הבא (PageDown)', - previousYear: 'שנה שעברה (Control + left)', nextYear: 'שנה הבאה (Control + right)', - previousDecade: 'העשור הקודם', - nextDecade: 'העשור הבא', + now: 'עכשיו', + ok: 'אישור', previousCentury: 'המאה הקודמת', - nextCentury: 'המאה הבאה', + previousDecade: 'העשור הקודם', + previousMonth: 'חודש קודם (PageUp)', + previousYear: 'שנה שעברה (Control + left)', + shortMonths: 'ינו_פבר_מרץ_אפר_מאי_יונ_יול_אוג_ספט_אוק_נוב_דצמ'.split('_'), + shortWeekDays: 'א׳_ב׳_ג׳_ד׳_ה׳_ו_ש׳'.split('_'), + timeSelect: 'בחר שעה', + today: 'היום', + weekSelect: 'בחר שבוע', + year: 'שנה', + yearFormat: 'YYYY', + yearSelect: 'בחר שנה', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/hr_HR.ts b/src/components/DateTimePicker/Internal/Locale/hr_HR.ts index 186a4e3f6..bfdee605f 100644 --- a/src/components/DateTimePicker/Internal/Locale/hr_HR.ts +++ b/src/components/DateTimePicker/Internal/Locale/hr_HR.ts @@ -2,32 +2,34 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'hr_HR', - today: 'Danas', - now: 'Sad', backToToday: 'Natrag na danas', - ok: 'OK', clear: 'Očisti', - month: 'Mjesec', - year: 'Godina', - timeSelect: 'odaberite vrijeme', - dateSelect: 'odaberite datum', - weekSelect: 'Odaberite tjedan', - monthSelect: 'Odaberite mjesec', - yearSelect: 'Odaberite godinu', - decadeSelect: 'Odaberite desetljeće', - yearFormat: 'YYYY', dateFormat: 'D.M.YYYY', - dayFormat: 'D', + dateSelect: 'odaberite datum', dateTimeFormat: 'D.M.YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Odaberite desetljeće', + month: 'Mjesec', monthBeforeYear: true, - previousMonth: 'Prošli mjesec (PageUp)', + monthSelect: 'Odaberite mjesec', + nextCentury: 'Sljedeće stoljeće', + nextDecade: 'Sljedeće desetljeće', nextMonth: 'Sljedeći mjesec (PageDown)', - previousYear: 'Prošla godina (Control + left)', nextYear: 'Sljedeća godina (Control + right)', - previousDecade: 'Prošlo desetljeće', - nextDecade: 'Sljedeće desetljeće', + now: 'Sad', + ok: 'OK', previousCentury: 'Prošlo stoljeće', - nextCentury: 'Sljedeće stoljeće', + previousDecade: 'Prošlo desetljeće', + previousMonth: 'Prošli mjesec (PageUp)', + previousYear: 'Prošla godina (Control + left)', + shortMonths: 'sij_velj_ožu_tra_svi_lip_srp_kol_ruj_lis_stu_pro'.split('_'), + shortWeekDays: 'ne_po_ut_sr_če_pe_su'.split('_'), + timeSelect: 'odaberite vrijeme', + today: 'Danas', + weekSelect: 'Odaberite tjedan', + year: 'Godina', + yearFormat: 'YYYY', + yearSelect: 'Odaberite godinu', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/ht_HT.ts b/src/components/DateTimePicker/Internal/Locale/ht_HT.ts index 3f0b1c8d5..6cde2620c 100644 --- a/src/components/DateTimePicker/Internal/Locale/ht_HT.ts +++ b/src/components/DateTimePicker/Internal/Locale/ht_HT.ts @@ -2,32 +2,34 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'ht_HT', - today: 'Jodi a', - now: 'Kounye a', backToToday: 'Retounen jodi a', - ok: 'OK', clear: 'Klè', - month: 'Mwa', - year: 'Ane', - timeSelect: 'chwazi tan', - dateSelect: 'chwazi dat', - weekSelect: 'Chwazi yon semèn', - monthSelect: 'Chwazi yon mwa', - yearSelect: 'Chwazi yon ane', - decadeSelect: 'Chwazi yon dekad', - yearFormat: 'YYYY', dateFormat: 'DD/MM/YYYY', - dayFormat: 'DD', + dateSelect: 'chwazi dat', dateTimeFormat: 'DD/MM/YYYY HH:mm:ss', + dayFormat: 'DD', + decadeSelect: 'Chwazi yon dekad', + month: 'Mwa', monthBeforeYear: true, - previousMonth: 'Mwa anvan (PageUp)', + monthSelect: 'Chwazi yon mwa', + nextCentury: 'Pwochen syèk la', + nextDecade: 'Pwochen deseni', nextMonth: 'Mwa pwochen (PageDown)', - previousYear: 'Ane pase a (Control + left)', nextYear: 'Ane pwochèn (Control + right)', - previousDecade: 'Dènye deseni', - nextDecade: 'Pwochen deseni', + now: 'Kounye a', + ok: 'OK', previousCentury: 'Dènye syèk', - nextCentury: 'Pwochen syèk la', + previousDecade: 'Dènye deseni', + previousMonth: 'Mwa anvan (PageUp)', + previousYear: 'Ane pase a (Control + left)', + shortMonths: 'jan_fev_mas_avr_me_jen_jiyè_out_sept_okt_nov_des'.split('_'), + shortWeekDays: 'di_le_ma_mè_je_va_sa'.split('_'), + timeSelect: 'chwazi tan', + today: 'Jodi a', + weekSelect: 'Chwazi yon semèn', + year: 'Ane', + yearFormat: 'YYYY', + yearSelect: 'Chwazi yon ane', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/hu_HU.ts b/src/components/DateTimePicker/Internal/Locale/hu_HU.ts index a63cee812..ae549eab7 100644 --- a/src/components/DateTimePicker/Internal/Locale/hu_HU.ts +++ b/src/components/DateTimePicker/Internal/Locale/hu_HU.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'hu_HU', - today: 'Ma', // 'Today', - now: 'Most', // 'Now', backToToday: 'Vissza a mai napra', // 'Back to today', - ok: 'OK', clear: 'Törlés', // 'Clear', - month: 'Hónap', // 'Month', - year: 'Év', // 'Year', - timeSelect: 'Időpont kiválasztása', // 'Select time', - dateSelect: 'Dátum kiválasztása', // 'Select date', - monthSelect: 'Hónap kiválasztása', // 'Choose a month', - yearSelect: 'Év kiválasztása', // 'Choose a year', - decadeSelect: 'Évtized kiválasztása', // 'Choose a decade', - yearFormat: 'YYYY', dateFormat: 'YYYY/MM/DD', // 'M/D/YYYY', - dayFormat: 'DD', // 'D', + dateSelect: 'Dátum kiválasztása', // 'Select date', dateTimeFormat: 'YYYY/MM/DD HH:mm:ss', // 'M/D/YYYY HH:mm:ss', + dayFormat: 'DD', // 'D', + decadeSelect: 'Évtized kiválasztása', // 'Choose a decade', + month: 'Hónap', // 'Month', monthBeforeYear: true, - previousMonth: 'Előző hónap (PageUp)', // 'Previous month (PageUp)', + monthSelect: 'Hónap kiválasztása', // 'Choose a month', + nextCentury: 'Jövő évszázad', // 'Next century', + nextDecade: 'Következő évtized', // 'Next decade', nextMonth: 'Következő hónap (PageDown)', // 'Next month (PageDown)', - previousYear: 'Múlt év (Control + left)', // 'Last year (Control + left)', nextYear: 'Jövő év (Control + right)', // 'Next year (Control + right)', - previousDecade: 'Előző évtized', // 'Last decade', - nextDecade: 'Következő évtized', // 'Next decade', + now: 'Most', // 'Now', + ok: 'OK', previousCentury: 'Múlt évszázad', // 'Last century', - nextCentury: 'Jövő évszázad', // 'Next century', + previousDecade: 'Előző évtized', // 'Last decade', + previousMonth: 'Előző hónap (PageUp)', // 'Previous month (PageUp)', + previousYear: 'Múlt év (Control + left)', // 'Last year (Control + left)', + shortMonths: 'jan_feb_márc_ápr_máj_jún_júl_aug_szept_okt_nov_dec'.split('_'), + shortWeekDays: 'v_h_k_sze_cs_p_szo'.split('_'), + timeSelect: 'Időpont kiválasztása', // 'Select time', + today: 'Ma', // 'Today', + year: 'Év', // 'Year', + yearFormat: 'YYYY', + yearSelect: 'Év kiválasztása', // 'Choose a year', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/it_IT.ts b/src/components/DateTimePicker/Internal/Locale/it_IT.ts index 3d6396cfc..a48dc5835 100644 --- a/src/components/DateTimePicker/Internal/Locale/it_IT.ts +++ b/src/components/DateTimePicker/Internal/Locale/it_IT.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'it_IT', - today: 'Oggi', - now: 'Adesso', backToToday: 'Torna ad oggi', - ok: 'OK', clear: 'Cancella', - month: 'Mese', - year: 'Anno', - timeSelect: "Seleziona l'ora", - dateSelect: 'Seleziona la data', - monthSelect: 'Seleziona il mese', - yearSelect: "Seleziona l'anno", - decadeSelect: 'Seleziona il decennio', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Seleziona la data', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Seleziona il decennio', + month: 'Mese', monthBeforeYear: true, - previousMonth: 'Il mese scorso (PageUp)', + monthSelect: 'Seleziona il mese', + nextCentury: 'Prossimo secolo', + nextDecade: 'Prossimo decennio', nextMonth: 'Il prossimo mese (PageDown)', - previousYear: "L'anno scorso (Control + sinistra)", nextYear: "L'anno prossimo (Control + destra)", - previousDecade: 'Ultimo decennio', - nextDecade: 'Prossimo decennio', + now: 'Adesso', + ok: 'OK', previousCentury: 'Secolo precedente', - nextCentury: 'Prossimo secolo', + previousDecade: 'Ultimo decennio', + previousMonth: 'Il mese scorso (PageUp)', + previousYear: "L'anno scorso (Control + sinistra)", + shortMonths: 'gen_feb_mar_apr_mag_giu_lug_ago_set_ott_nov_dic'.split('_'), + shortWeekDays: 'do_lu_ma_me_gi_ve_sa'.split('_'), + timeSelect: "Seleziona l'ora", + today: 'Oggi', + year: 'Anno', + yearFormat: 'YYYY', + yearSelect: "Seleziona l'anno", }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/ja_JP.ts b/src/components/DateTimePicker/Internal/Locale/ja_JP.ts index 775dc7fa7..506363167 100644 --- a/src/components/DateTimePicker/Internal/Locale/ja_JP.ts +++ b/src/components/DateTimePicker/Internal/Locale/ja_JP.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'ja_JP', - today: '今日', - now: '現在時刻', backToToday: '今日に戻る', - ok: '決定', - timeSelect: '時間を選択', - dateSelect: '日時を選択', - weekSelect: '週を選択', clear: 'クリア', - month: '月', - year: '年', - previousMonth: '前月 (ページアップキー)', - nextMonth: '翌月 (ページダウンキー)', - monthSelect: '月を選択', - yearSelect: '年を選択', - decadeSelect: '年代を選択', - yearFormat: 'YYYY年', - dayFormat: 'D日', dateFormat: 'YYYY年M月D日', + dateSelect: '日時を選択', dateTimeFormat: 'YYYY年M月D日 HH時mm分ss秒', - previousYear: '前年 (Controlを押しながら左キー)', - nextYear: '翌年 (Controlを押しながら右キー)', - previousDecade: '前の年代', + dayFormat: 'D日', + decadeSelect: '年代を選択', + month: '月', + monthSelect: '月を選択', + nextCentury: '次の世紀', nextDecade: '次の年代', + nextMonth: '翌月 (ページダウンキー)', + nextYear: '翌年 (Controlを押しながら右キー)', + now: '現在時刻', + ok: '決定', previousCentury: '前の世紀', - nextCentury: '次の世紀', + previousDecade: '前の年代', + previousMonth: '前月 (ページアップキー)', + previousYear: '前年 (Controlを押しながら左キー)', + shortMonths: '1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月'.split('_'), + shortWeekDays: '日_月_火_水_木_金_土'.split('_'), + timeSelect: '時間を選択', + today: '今日', + weekSelect: '週を選択', + year: '年', + yearFormat: 'YYYY年', + yearSelect: '年を選択', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/ko_KR.ts b/src/components/DateTimePicker/Internal/Locale/ko_KR.ts index 240a1cd6d..97af2b064 100644 --- a/src/components/DateTimePicker/Internal/Locale/ko_KR.ts +++ b/src/components/DateTimePicker/Internal/Locale/ko_KR.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'ko_KR', - today: '오늘', - now: '현재 시각', backToToday: '오늘로 돌아가기', - ok: '확인', clear: '지우기', - month: '월', - year: '년', - timeSelect: '시간 선택', - dateSelect: '날짜 선택', - monthSelect: '달 선택', - yearSelect: '연 선택', - decadeSelect: '연대 선택', - yearFormat: 'YYYY년', dateFormat: 'YYYY-MM-DD', - dayFormat: 'Do', + dateSelect: '날짜 선택', dateTimeFormat: 'YYYY-MM-DD HH:mm:ss', + dayFormat: 'Do', + decadeSelect: '연대 선택', + month: '월', monthBeforeYear: false, - previousMonth: '이전 달 (PageUp)', + monthSelect: '달 선택', + nextCentury: '다음 세기', + nextDecade: '다음 연대', nextMonth: '다음 달 (PageDown)', - previousYear: '이전 해 (Control + left)', nextYear: '다음 해 (Control + right)', - previousDecade: '이전 연대', - nextDecade: '다음 연대', + now: '현재 시각', + ok: '확인', previousCentury: '이전 세기', - nextCentury: '다음 세기', + previousDecade: '이전 연대', + previousMonth: '이전 달 (PageUp)', + previousYear: '이전 해 (Control + left)', + shortMonths: '1월_2월_3월_4월_5월_6월_7월_8월_9월_10월_11월_12월'.split('_'), + shortWeekDays: '일_월_화_수_목_금_토'.split('_'), + timeSelect: '시간 선택', + today: '오늘', + year: '년', + yearFormat: 'YYYY년', + yearSelect: '연 선택', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/ms_MY.ts b/src/components/DateTimePicker/Internal/Locale/ms_MY.ts index 316f0f1e8..35ef1903d 100644 --- a/src/components/DateTimePicker/Internal/Locale/ms_MY.ts +++ b/src/components/DateTimePicker/Internal/Locale/ms_MY.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'ms_MY', - today: 'Hari ini', - now: 'Sekarang', backToToday: 'Kembali ke hari ini', - ok: 'OK', - timeSelect: 'Pilih masa', - dateSelect: 'Pilih tarikh', - weekSelect: 'Pilih minggu', clear: 'Padam', - month: 'Bulan', - year: 'Tahun', - previousMonth: 'Bulan lepas', - nextMonth: 'Bulan depan', - monthSelect: 'Pilih bulan', - yearSelect: 'Pilih tahun', - decadeSelect: 'Pilih dekad', - yearFormat: 'YYYY', - dayFormat: 'D', dateFormat: 'M/D/YYYY', + dateSelect: 'Pilih tarikh', dateTimeFormat: 'M/D/YYYY HH:mm:ss', - previousYear: 'Tahun lepas (Ctrl+left)', - nextYear: 'Tahun depan (Ctrl+right)', - previousDecade: 'Dekad lepas', + dayFormat: 'D', + decadeSelect: 'Pilih dekad', + month: 'Bulan', + monthSelect: 'Pilih bulan', + nextCentury: 'Abad depan', nextDecade: 'Dekad depan', + nextMonth: 'Bulan depan', + nextYear: 'Tahun depan (Ctrl+right)', + now: 'Sekarang', + ok: 'OK', previousCentury: 'Abad lepas', - nextCentury: 'Abad depan', + previousDecade: 'Dekad lepas', + previousMonth: 'Bulan lepas', + previousYear: 'Tahun lepas (Ctrl+left)', + shortMonths: 'Jan_Feb_Mac_Apr_Mei_Jun_Jul_Ogs_Sep_Okt_Nov_Dis'.split('_'), + shortWeekDays: 'Ah_Is_Sl_Rb_Km_Jm_Sb'.split('_'), + timeSelect: 'Pilih masa', + today: 'Hari ini', + weekSelect: 'Pilih minggu', + year: 'Tahun', + yearFormat: 'YYYY', + yearSelect: 'Pilih tahun', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/nb_NO.ts b/src/components/DateTimePicker/Internal/Locale/nb_NO.ts index 520db8699..6d8c12daa 100644 --- a/src/components/DateTimePicker/Internal/Locale/nb_NO.ts +++ b/src/components/DateTimePicker/Internal/Locale/nb_NO.ts @@ -2,32 +2,36 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'nb_NO', - today: 'I dag', - now: 'Nå', backToToday: 'Gå til i dag', - ok: 'OK', clear: 'Annuller', - month: 'Måned', - year: 'År', - timeSelect: 'Velg tidspunkt', - dateSelect: 'Velg dato', - weekSelect: 'Velg uke', - monthSelect: 'Velg måned', - yearSelect: 'Velg år', - decadeSelect: 'Velg tiår', - yearFormat: 'YYYY', dateFormat: 'DD.MM.YYYY', - dayFormat: 'DD', + dateSelect: 'Velg dato', dateTimeFormat: 'DD.MM.YYYY HH:mm:ss', + dayFormat: 'DD', + decadeSelect: 'Velg tiår', + month: 'Måned', monthBeforeYear: true, - previousMonth: 'Forrige måned (PageUp)', + monthSelect: 'Velg måned', + nextCentury: 'Neste århundre', + nextDecade: 'Neste tiår', nextMonth: 'Neste måned (PageDown)', - previousYear: 'Forrige år (Control + venstre)', nextYear: 'Neste år (Control + høyre)', - previousDecade: 'Forrige tiår', - nextDecade: 'Neste tiår', + now: 'Nå', + ok: 'OK', previousCentury: 'Forrige århundre', - nextCentury: 'Neste århundre', + previousDecade: 'Forrige tiår', + previousMonth: 'Forrige måned (PageUp)', + previousYear: 'Forrige år (Control + venstre)', + shortMonths: 'jan_feb_mars_april_mai_juni_juli_aug_sep_okt_nov_des'.split( + '_' + ), + shortWeekDays: 'sø_ma_ti_on_to_fr_lø'.split('_'), + timeSelect: 'Velg tidspunkt', + today: 'I dag', + weekSelect: 'Velg uke', + year: 'År', + yearFormat: 'YYYY', + yearSelect: 'Velg år', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/nl_BE.ts b/src/components/DateTimePicker/Internal/Locale/nl_BE.ts index 91f21de3f..ee2bdcd09 100644 --- a/src/components/DateTimePicker/Internal/Locale/nl_BE.ts +++ b/src/components/DateTimePicker/Internal/Locale/nl_BE.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'nl_BE', - today: 'Vandaag', - now: 'Nu', backToToday: 'Terug naar vandaag', - ok: 'OK', clear: 'Reset', - month: 'Maand', - year: 'Jaar', - timeSelect: 'Selecteer tijd', - dateSelect: 'Selecteer datum', - monthSelect: 'Kies een maand', - yearSelect: 'Kies een jaar', - decadeSelect: 'Kies een decennium', - yearFormat: 'YYYY', dateFormat: 'D-M-YYYY', - dayFormat: 'D', + dateSelect: 'Selecteer datum', dateTimeFormat: 'D-M-YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Kies een decennium', + month: 'Maand', monthBeforeYear: true, - previousMonth: 'Vorige maand (PageUp)', + monthSelect: 'Kies een maand', + nextCentury: 'Volgende eeuw', + nextDecade: 'Volgend decennium', nextMonth: 'Volgende maand (PageDown)', - previousYear: 'Vorig jaar (Control + left)', nextYear: 'Volgend jaar (Control + right)', - previousDecade: 'Vorig decennium', - nextDecade: 'Volgend decennium', + now: 'Nu', + ok: 'OK', previousCentury: 'Vorige eeuw', - nextCentury: 'Volgende eeuw', + previousDecade: 'Vorig decennium', + previousMonth: 'Vorige maand (PageUp)', + previousYear: 'Vorig jaar (Control + left)', + shortMonths: 'jan_feb_mrt_apr_mei_jun_jul_aug_sep_okt_nov_dec'.split('_'), + shortWeekDays: 'zo_ma_di_wo_do_vr_za'.split('_'), + timeSelect: 'Selecteer tijd', + today: 'Vandaag', + year: 'Jaar', + yearFormat: 'YYYY', + yearSelect: 'Kies een jaar', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/nl_NL.ts b/src/components/DateTimePicker/Internal/Locale/nl_NL.ts index 205532d24..65d1420d7 100644 --- a/src/components/DateTimePicker/Internal/Locale/nl_NL.ts +++ b/src/components/DateTimePicker/Internal/Locale/nl_NL.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'nl_NL', - today: 'Vandaag', - now: 'Nu', backToToday: 'Terug naar vandaag', - ok: 'OK', clear: 'Reset', - month: 'Maand', - year: 'Jaar', - timeSelect: 'Selecteer tijd', - dateSelect: 'Selecteer datum', - monthSelect: 'Kies een maand', - yearSelect: 'Kies een jaar', - decadeSelect: 'Kies een decennium', - yearFormat: 'YYYY', dateFormat: 'D-M-YYYY', - dayFormat: 'D', + dateSelect: 'Selecteer datum', dateTimeFormat: 'D-M-YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Kies een decennium', + month: 'Maand', monthBeforeYear: true, - previousMonth: 'Vorige maand (PageUp)', + monthSelect: 'Kies een maand', + nextCentury: 'Volgende eeuw', + nextDecade: 'Volgend decennium', nextMonth: 'Volgende maand (PageDown)', - previousYear: 'Vorig jaar (Control + left)', nextYear: 'Volgend jaar (Control + right)', - previousDecade: 'Vorig decennium', - nextDecade: 'Volgend decennium', + now: 'Nu', + ok: 'OK', previousCentury: 'Vorige eeuw', - nextCentury: 'Volgende eeuw', + previousDecade: 'Vorig decennium', + previousMonth: 'Vorige maand (PageUp)', + previousYear: 'Vorig jaar (Control + left)', + shortMonths: 'jan_feb_mrt_apr_mei_jun_jul_aug_sep_okt_nov_dec'.split('_'), + shortWeekDays: 'zo_ma_di_wo_do_vr_za'.split('_'), + timeSelect: 'Selecteer tijd', + today: 'Vandaag', + year: 'Jaar', + yearFormat: 'YYYY', + yearSelect: 'Kies een jaar', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/pl_PL.ts b/src/components/DateTimePicker/Internal/Locale/pl_PL.ts index bd2d7d2df..5c5ce4198 100644 --- a/src/components/DateTimePicker/Internal/Locale/pl_PL.ts +++ b/src/components/DateTimePicker/Internal/Locale/pl_PL.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'pl_PL', - today: 'Dzisiaj', - now: 'Teraz', backToToday: 'Ustaw dzisiaj', - ok: 'OK', clear: 'Wyczyść', - month: 'Miesiąc', - year: 'Rok', - timeSelect: 'Ustaw czas', - dateSelect: 'Ustaw datę', - monthSelect: 'Wybierz miesiąc', - yearSelect: 'Wybierz rok', - decadeSelect: 'Wybierz dekadę', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Ustaw datę', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Wybierz dekadę', + month: 'Miesiąc', monthBeforeYear: true, - previousMonth: 'Poprzedni miesiąc (PageUp)', + monthSelect: 'Wybierz miesiąc', + nextCentury: 'Następny wiek', + nextDecade: 'Następna dekada', nextMonth: 'Następny miesiąc (PageDown)', - previousYear: 'Ostatni rok (Ctrl + left)', nextYear: 'Następny rok (Ctrl + right)', - previousDecade: 'Ostatnia dekada', - nextDecade: 'Następna dekada', + now: 'Teraz', + ok: 'OK', previousCentury: 'Ostatni wiek', - nextCentury: 'Następny wiek', + previousDecade: 'Ostatnia dekada', + previousMonth: 'Poprzedni miesiąc (PageUp)', + previousYear: 'Ostatni rok (Ctrl + left)', + shortMonths: 'sty_lut_mar_kwi_maj_cze_lip_sie_wrz_paź_lis_gru'.split('_'), + shortWeekDays: 'Nd_Pn_Wt_Śr_Cz_Pt_So'.split('_'), + timeSelect: 'Ustaw czas', + today: 'Dzisiaj', + year: 'Rok', + yearFormat: 'YYYY', + yearSelect: 'Wybierz rok', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/pt_BR.ts b/src/components/DateTimePicker/Internal/Locale/pt_BR.ts index c95d9b713..9048f380d 100644 --- a/src/components/DateTimePicker/Internal/Locale/pt_BR.ts +++ b/src/components/DateTimePicker/Internal/Locale/pt_BR.ts @@ -2,46 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'pt_BR', - today: 'Hoje', - now: 'Agora', backToToday: 'Voltar para hoje', - ok: 'OK', clear: 'Limpar', - month: 'Mês', - year: 'Ano', - timeSelect: 'Selecionar hora', - dateSelect: 'Selecionar data', - monthSelect: 'Escolher mês', - yearSelect: 'Escolher ano', - decadeSelect: 'Escolher década', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Selecionar data', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Escolher década', + month: 'Mês', monthBeforeYear: false, - previousMonth: 'Mês anterior (PageUp)', + monthSelect: 'Escolher mês', + nextCentury: 'Próximo século', + nextDecade: 'Próxima década', nextMonth: 'Próximo mês (PageDown)', - previousYear: 'Ano anterior (Control + esquerda)', nextYear: 'Próximo ano (Control + direita)', - previousDecade: 'Década anterior', - nextDecade: 'Próxima década', + now: 'Agora', + ok: 'OK', previousCentury: 'Século anterior', - nextCentury: 'Próximo século', - shortWeekDays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'], - shortMonths: [ - 'Jan', - 'Fev', - 'Mar', - 'Abr', - 'Mai', - 'Jun', - 'Jul', - 'Ago', - 'Set', - 'Out', - 'Nov', - 'Dez', - ], + previousDecade: 'Década anterior', + previousMonth: 'Mês anterior (PageUp)', + previousYear: 'Ano anterior (Control + esquerda)', + shortMonths: 'jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez'.split('_'), + shortWeekDays: 'Do_2ª_3ª_4ª_5ª_6ª_Sá'.split('_'), + timeSelect: 'Selecionar hora', + today: 'Hoje', + year: 'Ano', + yearFormat: 'YYYY', + yearSelect: 'Escolher ano', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/pt_PT.ts b/src/components/DateTimePicker/Internal/Locale/pt_PT.ts index 475409420..30dc648c2 100644 --- a/src/components/DateTimePicker/Internal/Locale/pt_PT.ts +++ b/src/components/DateTimePicker/Internal/Locale/pt_PT.ts @@ -2,46 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'pt_PT', - today: 'Hoje', - now: 'Agora', backToToday: 'Hoje', - ok: 'OK', clear: 'Limpar', - month: 'Mês', - year: 'Ano', - timeSelect: 'Selecionar hora', - dateSelect: 'Selecionar data', - monthSelect: 'Selecionar mês', - yearSelect: 'Selecionar ano', - decadeSelect: 'Selecionar década', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'Selecionar data', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Selecionar década', + month: 'Mês', monthBeforeYear: true, - previousMonth: 'Mês anterior (PageUp)', + monthSelect: 'Selecionar mês', + nextCentury: 'Século seguinte', + nextDecade: 'Década seguinte', nextMonth: 'Mês seguinte (PageDown)', - previousYear: 'Ano anterior (Control + left)', nextYear: 'Ano seguinte (Control + right)', - previousDecade: 'Década anterior', - nextDecade: 'Década seguinte', + now: 'Agora', + ok: 'OK', previousCentury: 'Século anterior', - nextCentury: 'Século seguinte', - shortWeekDays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'], - shortMonths: [ - 'Jan', - 'Fev', - 'Mar', - 'Abr', - 'Mai', - 'Jun', - 'Jul', - 'Ago', - 'Set', - 'Out', - 'Nov', - 'Dez', - ], + previousDecade: 'Década anterior', + previousMonth: 'Mês anterior (PageUp)', + previousYear: 'Ano anterior (Control + left)', + shortMonths: 'jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez'.split('_'), + shortWeekDays: 'Do_2ª_3ª_4ª_5ª_6ª_Sa'.split('_'), + timeSelect: 'Selecionar hora', + today: 'Hoje', + year: 'Ano', + yearFormat: 'YYYY', + yearSelect: 'Selecionar ano', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/ru_RU.ts b/src/components/DateTimePicker/Internal/Locale/ru_RU.ts index 58ce1643f..69520f47d 100644 --- a/src/components/DateTimePicker/Internal/Locale/ru_RU.ts +++ b/src/components/DateTimePicker/Internal/Locale/ru_RU.ts @@ -2,31 +2,35 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'ru_RU', - today: 'Сегодня', - now: 'Сейчас', backToToday: 'Текущая дата', - ok: 'ОК', clear: 'Очистить', - month: 'Месяц', - year: 'Год', - timeSelect: 'Выбрать время', - dateSelect: 'Выбрать дату', - monthSelect: 'Выбрать месяц', - yearSelect: 'Выбрать год', - decadeSelect: 'Выбрать десятилетие', - yearFormat: 'YYYY', dateFormat: 'D-M-YYYY', - dayFormat: 'D', + dateSelect: 'Выбрать дату', dateTimeFormat: 'D-M-YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Выбрать десятилетие', + month: 'Месяц', monthBeforeYear: true, - previousMonth: 'Предыдущий месяц (PageUp)', + monthSelect: 'Выбрать месяц', + nextCentury: 'Следующий век', + nextDecade: 'Следущее десятилетие', nextMonth: 'Следующий месяц (PageDown)', - previousYear: 'Предыдущий год (Control + left)', nextYear: 'Следующий год (Control + right)', - previousDecade: 'Предыдущее десятилетие', - nextDecade: 'Следущее десятилетие', + now: 'Сейчас', + ok: 'ОК', previousCentury: 'Предыдущий век', - nextCentury: 'Следующий век', + previousDecade: 'Предыдущее десятилетие', + previousMonth: 'Предыдущий месяц (PageUp)', + previousYear: 'Предыдущий год (Control + left)', + shortMonths: 'янв_февр_мар_апр_мая_июня_июля_авг_сент_окт_нояб_дек'.split( + '_' + ), + shortWeekDays: 'вс_пн_вт_ср_чт_пт_сб'.split('_'), + timeSelect: 'Выбрать время', + today: 'Сегодня', + year: 'Год', + yearFormat: 'YYYY', + yearSelect: 'Выбрать год', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/sv_SE.ts b/src/components/DateTimePicker/Internal/Locale/sv_SE.ts index 3d48be0d0..8906e06ee 100644 --- a/src/components/DateTimePicker/Internal/Locale/sv_SE.ts +++ b/src/components/DateTimePicker/Internal/Locale/sv_SE.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'sv_SE', - today: 'I dag', - now: 'Nu', backToToday: 'Till idag', - ok: 'OK', clear: 'Avbryt', - month: 'Månad', - year: 'År', - timeSelect: 'Välj tidpunkt', - dateSelect: 'Välj datum', - monthSelect: 'Välj månad', - yearSelect: 'Välj år', - decadeSelect: 'Välj årtionde', - yearFormat: 'YYYY', dateFormat: 'YYYY-MM-DD', - dayFormat: 'D', + dateSelect: 'Välj datum', dateTimeFormat: 'YYYY-MM-DD H:mm:ss', + dayFormat: 'D', + decadeSelect: 'Välj årtionde', + month: 'Månad', monthBeforeYear: true, - previousMonth: 'Förra månaden (PageUp)', + monthSelect: 'Välj månad', + nextCentury: 'Nästa århundrade', + nextDecade: 'Nästa årtionde', nextMonth: 'Nästa månad (PageDown)', - previousYear: 'Föreg år (Control + left)', nextYear: 'Nästa år (Control + right)', - previousDecade: 'Föreg årtionde', - nextDecade: 'Nästa årtionde', + now: 'Nu', + ok: 'OK', previousCentury: 'Föreg århundrade', - nextCentury: 'Nästa århundrade', + previousDecade: 'Föreg årtionde', + previousMonth: 'Förra månaden (PageUp)', + previousYear: 'Föreg år (Control + left)', + shortMonths: 'jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec'.split('_'), + shortWeekDays: 'sö_må_ti_on_to_fr_lö'.split('_'), + timeSelect: 'Välj tidpunkt', + today: 'I dag', + year: 'År', + yearFormat: 'YYYY', + yearSelect: 'Välj år', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/th_TH.ts b/src/components/DateTimePicker/Internal/Locale/th_TH.ts index c09f8c7a4..ae3675f82 100644 --- a/src/components/DateTimePicker/Internal/Locale/th_TH.ts +++ b/src/components/DateTimePicker/Internal/Locale/th_TH.ts @@ -2,31 +2,34 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'th_TH', - today: 'วันนี้', - now: 'ตอนนี้', backToToday: 'กลับไปยังวันนี้', - ok: 'ตกลง', clear: 'ลบล้าง', - month: 'เดือน', - year: 'ปี', - timeSelect: 'เลือกเวลา', - dateSelect: 'เลือกวัน', - monthSelect: 'เลือกเดือน', - yearSelect: 'เลือกปี', - decadeSelect: 'เลือกทศวรรษ', - yearFormat: 'YYYY', dateFormat: 'D/M/YYYY', - dayFormat: 'D', + dateSelect: 'เลือกวัน', dateTimeFormat: 'D/M/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'เลือกทศวรรษ', + month: 'เดือน', monthBeforeYear: true, - previousMonth: 'เดือนก่อนหน้า (PageUp)', + monthSelect: 'เลือกเดือน', + nextCentury: 'ศตวรรษถัดไป', + nextDecade: 'ทศวรรษถัดไป', nextMonth: 'เดือนถัดไป (PageDown)', - previousYear: 'ปีก่อนหน้า (Control + left)', nextYear: 'ปีถัดไป (Control + right)', - previousDecade: 'ทศวรรษก่อนหน้า', - nextDecade: 'ทศวรรษถัดไป', + now: 'ตอนนี้', + ok: 'ตกลง', previousCentury: 'ศตวรรษก่อนหน้า', - nextCentury: 'ศตวรรษถัดไป', + previousDecade: 'ทศวรรษก่อนหน้า', + previousMonth: 'เดือนก่อนหน้า (PageUp)', + previousYear: 'ปีก่อนหน้า (Control + left)', + shortMonths: + 'ม.ค._ก.พ._มี.ค._เม.ย._พ.ค._มิ.ย._ก.ค._ส.ค._ก.ย._ต.ค._พ.ย._ธ.ค.'.split('_'), + shortWeekDays: 'อา._จ._อ._พ._พฤ._ศ._ส.'.split('_'), + timeSelect: 'เลือกเวลา', + today: 'วันนี้', + year: 'ปี', + yearFormat: 'YYYY', + yearSelect: 'เลือกปี', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/tr_TR.ts b/src/components/DateTimePicker/Internal/Locale/tr_TR.ts index c24f107f5..542f72f7e 100644 --- a/src/components/DateTimePicker/Internal/Locale/tr_TR.ts +++ b/src/components/DateTimePicker/Internal/Locale/tr_TR.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'tr_TR', - today: 'Bugün', - now: 'Şimdi', backToToday: 'Bugüne Geri Dön', - ok: 'tamam', clear: 'Temizle', - month: 'Ay', - year: 'Yıl', - timeSelect: 'Zaman Seç', - dateSelect: 'Tarih Seç', - monthSelect: 'Ay Seç', - yearSelect: 'Yıl Seç', - decadeSelect: 'On Yıl Seç', - yearFormat: 'YYYY', dateFormat: 'M/D/YYYY', - dayFormat: 'D', + dateSelect: 'Tarih Seç', dateTimeFormat: 'M/D/YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'On Yıl Seç', + month: 'Ay', monthBeforeYear: true, - previousMonth: 'Önceki Ay (PageUp)', + monthSelect: 'Ay Seç', + nextCentury: 'Sonraki Yüzyıl', + nextDecade: 'Sonraki On Yıl', nextMonth: 'Sonraki Ay (PageDown)', - previousYear: 'Önceki Yıl (Control + Sol)', nextYear: 'Sonraki Yıl (Control + Sağ)', - previousDecade: 'Önceki On Yıl', - nextDecade: 'Sonraki On Yıl', + now: 'Şimdi', + ok: 'tamam', previousCentury: 'Önceki Yüzyıl', - nextCentury: 'Sonraki Yüzyıl', + previousDecade: 'Önceki On Yıl', + previousMonth: 'Önceki Ay (PageUp)', + previousYear: 'Önceki Yıl (Control + Sol)', + shortMonths: 'Oca_Şub_Mar_Nis_May_Haz_Tem_Ağu_Eyl_Eki_Kas_Ara'.split('_'), + shortWeekDays: 'Pz_Pt_Sa_Ça_Pe_Cu_Ct'.split('_'), + timeSelect: 'Zaman Seç', + today: 'Bugün', + year: 'Yıl', + yearFormat: 'YYYY', + yearSelect: 'Yıl Seç', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/uk_UA.ts b/src/components/DateTimePicker/Internal/Locale/uk_UA.ts index 0e91c3889..2c3b06142 100644 --- a/src/components/DateTimePicker/Internal/Locale/uk_UA.ts +++ b/src/components/DateTimePicker/Internal/Locale/uk_UA.ts @@ -2,31 +2,35 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'uk_UA', - today: 'Сьогодні', - now: 'Зараз', backToToday: 'Поточна дата', - ok: 'OK', clear: 'Очистити', - month: 'Місяць', - year: 'Рік', - timeSelect: 'Обрати час', - dateSelect: 'Обрати дату', - monthSelect: 'Обрати місяць', - yearSelect: 'Обрати рік', - decadeSelect: 'Обрати десятиріччя', - yearFormat: 'YYYY', dateFormat: 'D-M-YYYY', - dayFormat: 'D', + dateSelect: 'Обрати дату', dateTimeFormat: 'D-M-YYYY HH:mm:ss', + dayFormat: 'D', + decadeSelect: 'Обрати десятиріччя', + month: 'Місяць', monthBeforeYear: true, - previousMonth: 'Попередній місяць (PageUp)', + monthSelect: 'Обрати місяць', + nextCentury: 'Наступне століття', + nextDecade: 'Наступне десятиріччя', nextMonth: 'Наступний місяць (PageDown)', - previousYear: 'Попередній рік (Control + left)', nextYear: 'Наступний рік (Control + right)', - previousDecade: 'Попереднє десятиріччя', - nextDecade: 'Наступне десятиріччя', + now: 'Зараз', + ok: 'OK', previousCentury: 'Попереднє століття', - nextCentury: 'Наступне століття', + previousDecade: 'Попереднє десятиріччя', + previousMonth: 'Попередній місяць (PageUp)', + previousYear: 'Попередній рік (Control + left)', + shortMonths: 'січ_лют_бер_квіт_трав_черв_лип_серп_вер_жовт_лист_груд'.split( + '_' + ), + shortWeekDays: 'нд_пн_вт_ср_чт_пт_сб'.split('_'), + timeSelect: 'Обрати час', + today: 'Сьогодні', + year: 'Рік', + yearFormat: 'YYYY', + yearSelect: 'Обрати рік', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/zh_CN.ts b/src/components/DateTimePicker/Internal/Locale/zh_CN.ts index 0bdd45129..07d2a52f2 100644 --- a/src/components/DateTimePicker/Internal/Locale/zh_CN.ts +++ b/src/components/DateTimePicker/Internal/Locale/zh_CN.ts @@ -2,31 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'zh_CN', - today: '今天', - now: '此刻', backToToday: '返回今天', - ok: '确定', - timeSelect: '选择时间', - dateSelect: '选择日期', - weekSelect: '选择周', clear: '清除', - month: '月', - year: '年', - previousMonth: '上个月 (翻页上键)', - nextMonth: '下个月 (翻页下键)', - monthSelect: '选择月份', - yearSelect: '选择年份', - decadeSelect: '选择年代', - yearFormat: 'YYYY年', - dayFormat: 'D日', dateFormat: 'YYYY年M月D日', + dateSelect: '选择日期', dateTimeFormat: 'YYYY年M月D日 HH时mm分ss秒', - previousYear: '上一年 (Control键加左方向键)', - nextYear: '下一年 (Control键加右方向键)', - previousDecade: '上一年代', + dayFormat: 'D日', + decadeSelect: '选择年代', + month: '月', + monthSelect: '选择月份', + nextCentury: '下一世纪', nextDecade: '下一年代', + nextMonth: '下个月 (翻页下键)', + nextYear: '下一年 (Control键加右方向键)', + now: '此刻', + ok: '确定', previousCentury: '上一世纪', - nextCentury: '下一世纪', + previousDecade: '上一年代', + previousMonth: '上个月 (翻页上键)', + previousYear: '上一年 (Control键加左方向键)', + shortMonths: '1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月'.split('_'), + shortWeekDays: '日_一_二_三_四_五_六'.split('_'), + timeSelect: '选择时间', + today: '今天', + weekSelect: '选择周', + year: '年', + yearFormat: 'YYYY年', + yearSelect: '选择年份', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/Locale/zh_TW.ts b/src/components/DateTimePicker/Internal/Locale/zh_TW.ts index 79b17321a..e6b541b4c 100644 --- a/src/components/DateTimePicker/Internal/Locale/zh_TW.ts +++ b/src/components/DateTimePicker/Internal/Locale/zh_TW.ts @@ -2,32 +2,33 @@ import type { Locale } from '../OcPicker.types'; const locale: Locale = { locale: 'zh_TW', - - today: '今天', - now: '此刻', backToToday: '返回今天', - ok: '確定', - timeSelect: '選擇時間', - dateSelect: '選擇日期', - weekSelect: '選擇周', clear: '清除', - month: '月', - year: '年', - previousMonth: '上個月 (翻頁上鍵)', - nextMonth: '下個月 (翻頁下鍵)', - monthSelect: '選擇月份', - yearSelect: '選擇年份', - decadeSelect: '選擇年代', - yearFormat: 'YYYY年', - dayFormat: 'D日', dateFormat: 'YYYY年M月D日', + dateSelect: '選擇日期', dateTimeFormat: 'YYYY年M月D日 HH時mm分ss秒', - previousYear: '上一年 (Control鍵加左方向鍵)', - nextYear: '下一年 (Control鍵加右方向鍵)', - previousDecade: '上一年代', + dayFormat: 'D日', + decadeSelect: '選擇年代', + month: '月', + monthSelect: '選擇月份', + nextCentury: '下一世紀', nextDecade: '下一年代', + nextMonth: '下個月 (翻頁下鍵)', + nextYear: '下一年 (Control鍵加右方向鍵)', + now: '此刻', + ok: '確定', previousCentury: '上一世紀', - nextCentury: '下一世紀', + previousDecade: '上一年代', + previousMonth: '上個月 (翻頁上鍵)', + previousYear: '上一年 (Control鍵加左方向鍵)', + shortMonths: '1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月'.split('_'), + shortWeekDays: '日_一_二_三_四_五_六'.split('_'), + timeSelect: '選擇時間', + today: '今天', + weekSelect: '選擇周', + year: '年', + yearFormat: 'YYYY年', + yearSelect: '選擇年份', }; export default locale; diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx index b70cb3f42..11f8ea62b 100644 --- a/src/components/Dropdown/Dropdown.stories.tsx +++ b/src/components/Dropdown/Dropdown.stories.tsx @@ -121,11 +121,13 @@ const Overlay = () => ( iconProps={{ path: item.icon, }} + role="menuitem" style={{ margin: '4px 0', }} /> )} + role="menu" /> ); diff --git a/src/components/Dropdown/Dropdown.test.tsx b/src/components/Dropdown/Dropdown.test.tsx new file mode 100644 index 000000000..dc9642b59 --- /dev/null +++ b/src/components/Dropdown/Dropdown.test.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { Dropdown } from './'; +import { + ButtonIconAlign, + ButtonTextAlign, + ButtonWidth, + DefaultButton, +} from '../Button'; +import { IconName } from '../Icon'; +import { List } from '../List'; +import { render, screen, waitFor } from '@testing-library/react'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +interface User { + name: string; + icon: IconName; +} + +const sampleList: User[] = [1, 2, 3].map((i) => ({ + name: `User profile ${i}`, + icon: IconName.mdiAccount, +})); + +const Overlay = () => ( + + items={sampleList} + renderItem={(item) => ( + + )} + /> +); + +const DropdownComponent = (): JSX.Element => { + const [visible, setVisibility] = useState(false); + + const dropdownProps: object = { + trigger: 'click', + classNames: 'my-dropdown-class', + style: {}, + dropdownClassNames: 'my-dropdown-class', + dropdownStyle: { + color: 'red', + }, + placement: 'bottom-start', + overlay: Overlay(), + offset: 0, + positionStrategy: 'absolute', + disabled: false, + closeOnDropdownClick: true, + portal: false, + }; + + return ( + setVisibility(isVisible)} + > + + + ); +}; + +describe('Dropdown', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Should render a dropdown button', () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + expect(dropdownButton).toBeTruthy(); + expect(() => container).not.toThrowError(); + expect(container).toMatchSnapshot(); + }); + + test('Should show the dropdown options when the button is clicked', async () => { + render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('User profile 1')); + const option1 = screen.getByText('User profile 1'); + const option2 = screen.getByText('User profile 2'); + const option3 = screen.getByText('User profile 3'); + expect(option1).toBeTruthy(); + expect(option2).toBeTruthy(); + expect(option3).toBeTruthy(); + }); + + test('Should support props such as dropdownClassNames and dropdownStyle', async () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('User profile 1')); + expect(container.querySelector('.dropdown-wrapper')?.classList).toContain( + 'my-dropdown-class' + ); + expect( + (container.querySelector('.dropdown-wrapper') as HTMLElement).style.color + ).toContain('red'); + }); +}); diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 6aa3b5801..3e984347d 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -6,20 +6,31 @@ import React, { useImperativeHandle, useState, } from 'react'; +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset as fOffset, + shift, + useFloating, +} from '@floating-ui/react'; import { DropdownProps, DropdownRef } from './Dropdown.types'; -import { autoUpdate, flip, shift, useFloating } from '@floating-ui/react-dom'; -import { offset as fOffset } from '@floating-ui/core'; +import { Menu } from '../Menu'; +import { useAccessibility } from '../../hooks/useAccessibility'; +import { useMergedState } from '../../hooks/useMergedState'; +import { useOnClickOutside } from '../../hooks/useOnClickOutside'; import { ConditionalWrapper, mergeClasses, uniqueId, } from '../../shared/utilities'; -import { useOnClickOutside } from '../../hooks/useOnClickOutside'; -import { useAccessibility } from '../../hooks/useAccessibility'; + import styles from './dropdown.module.scss'; -import { FloatingPortal } from '@floating-ui/react-dom-interactions'; -import { Menu } from '../Menu'; -import { useMergedState } from '../../hooks/useMergedState'; + +const ANIMATION_DURATION: number = 200; + +const PREVENT_DEFAULT_TRIGGERS: string[] = ['contextmenu']; const TRIGGER_TO_HANDLER_MAP_ON_ENTER = { click: 'onClick', @@ -33,10 +44,6 @@ const TRIGGER_TO_HANDLER_MAP_ON_LEAVE = { contextmenu: '', }; -const PREVENT_DEFAULT_TRIGGERS = ['contextmenu']; - -const ANIMATION_DURATION = 200; - export const Dropdown: FC = React.memo( React.forwardRef( ( @@ -56,13 +63,15 @@ export const Dropdown: FC = React.memo( placement = 'bottom-start', portal = false, positionStrategy = 'absolute', + referenceOnClick, + role = 'listbox', showDropdown, style, trigger = 'click', visible, width, }, - ref + ref: React.ForwardedRef ) => { const [mergedVisible, setVisible] = useMergedState(false, { value: visible, @@ -72,13 +81,12 @@ export const Dropdown: FC = React.memo( const dropdownId: string = uniqueId('dropdown-'); let timeout: ReturnType; - const { x, y, reference, floating, strategy, update, refs } = useFloating( - { + const { x, y, reference, floating, strategy, update, refs, context } = + useFloating({ placement, strategy: positionStrategy, middleware: [fOffset(offset), flip(), shift()], - } - ); + }); const toggle: Function = (show: boolean, showDropdown = (show: boolean) => show): Function => @@ -107,7 +115,6 @@ export const Dropdown: FC = React.memo( refs.floating, (e) => { if (closeOnOutsideClick) { - console.log('outside clicked'); toggle(false)(e); } onClickOutside?.(e); @@ -162,6 +169,15 @@ export const Dropdown: FC = React.memo( height: height ?? '', }; + const handleReferenceClick = (event: React.MouseEvent): void => { + event.stopPropagation(); + if (disabled) { + return; + } + toggle(!mergedVisible)(event); + referenceOnClick?.(event); + }; + const getReference = (): JSX.Element => { const child = React.Children.only(children) as React.ReactElement; const referenceWrapperClasses: string = mergeClasses([ @@ -174,7 +190,7 @@ export const Dropdown: FC = React.memo( ...{ [TRIGGER_TO_HANDLER_MAP_ON_ENTER[trigger]]: toggle(true), }, - onClick: toggle(!mergedVisible), + onClick: handleReferenceClick, className: referenceWrapperClasses, 'aria-controls': dropdownId, 'aria-expanded': mergedVisible, @@ -186,16 +202,27 @@ export const Dropdown: FC = React.memo( const getDropdown = (): JSX.Element => mergedVisible && ( -
- {overlay} -
+
+ {overlay} +
+ ); return ( diff --git a/src/components/Dropdown/Dropdown.types.ts b/src/components/Dropdown/Dropdown.types.ts index 1fae59945..9b82bd814 100644 --- a/src/components/Dropdown/Dropdown.types.ts +++ b/src/components/Dropdown/Dropdown.types.ts @@ -1,5 +1,5 @@ import React, { Ref } from 'react'; -import { Placement, Strategy } from '@floating-ui/react-dom'; +import { Placement, Strategy } from '@floating-ui/react'; export interface DropdownProps { /** @@ -66,6 +66,17 @@ export interface DropdownProps { * @default absolute */ positionStrategy?: Strategy; + /** + * Callback executed on reference element click. + * @param event + * @returns (event: React.MouseEvent) => void + */ + referenceOnClick?: (event: React.MouseEvent) => void; + /** + * The dropdown aria role. + * @default 'listbox' + */ + role?: string; /** * Callback to control the show/hide behavior of the dropdown. * triggered before the visible change diff --git a/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap b/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap new file mode 100644 index 000000000..64c00ca71 --- /dev/null +++ b/src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dropdown Should render a dropdown button 1`] = ` +
+
+ +
+
+`; diff --git a/src/components/Dropdown/dropdown.module.scss b/src/components/Dropdown/dropdown.module.scss index 66ba7b988..cc2887c4c 100644 --- a/src/components/Dropdown/dropdown.module.scss +++ b/src/components/Dropdown/dropdown.module.scss @@ -23,9 +23,8 @@ background: var(--dropdown-background-color); padding: $space-xs; box-shadow: $shadow-object-m; - border-radius: $border-radius-xl; + border-radius: $border-radius-l; // TODO: ENG-46367 Add DropdownSize type and handle mapping via trigger size, then L 24px, M 20px, S 16px border-radius determined by size of the trigger. font-family: $octuple-font-family; - transition: all $motion-duration-fast $motion-easing-easeout; min-width: 200px; opacity: 0; white-space: normal; @@ -46,6 +45,20 @@ &.no-padding { padding: 0; } + + // Hides the browser default keyboard focus-visible styles. + // Use the ConfigProvider instead. + &:focus-visible { + outline: none; + } +} + +:global(.focus-visible) { + .dropdown-wrapper { + &:focus-visible { + box-shadow: var(--focus-visible-box-shadow); + } + } } @keyframes slideUpIn { diff --git a/src/components/Form/Tests/index.test.js b/src/components/Form/Tests/index.test.js index 073509666..f70622b48 100644 --- a/src/components/Form/Tests/index.test.js +++ b/src/components/Form/Tests/index.test.js @@ -15,7 +15,7 @@ import { Modal } from '../../Modal'; import { ConfigProvider } from '../../ConfigProvider'; import zhCN from '../../Locale/zh_CN'; import { sleep } from '../../../tests/Utilities'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import 'jest-specific-snapshot'; const { RangePicker } = DatePicker; @@ -944,30 +944,38 @@ describe('Form', () => { }); describe('tooltip', () => { - test('ReactNode', () => { - const wrapper = mount( + test('ReactNode', async () => { + const { container, getByTestId } = render(
- Mia}> + Mia} + > ); - const tooltipProps = wrapper.find('Tooltip').props(); - expect(tooltipProps.content).toEqual(Mia); + fireEvent.mouseOver(container.querySelector('.reference-wrapper')); + await waitFor(() => getByTestId('tooltip-1')); + expect(getByTestId('tooltip-1').innerHTML).toBe('Mia'); }); - test('config', () => { - const wrapper = mount( + test('config', async () => { + const { container, getByTestId } = render(
- + Mia }} + > ); - const tooltipProps = wrapper.find('Tooltip').props(); - expect(tooltipProps.content).toEqual('Mia'); + fireEvent.mouseOver(container.querySelector('.reference-wrapper')); + await waitFor(() => getByTestId('tooltip-2')); + expect(getByTestId('tooltip-2').innerHTML).toBe('Mia'); }); }); diff --git a/src/components/Icon/Icon.test.tsx b/src/components/Icon/Icon.test.tsx new file mode 100644 index 000000000..15e0935d5 --- /dev/null +++ b/src/components/Icon/Icon.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { Icon, IconName, IconSize } from './'; +import { render } from '@testing-library/react'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +describe('Icon', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Renders without crashing', () => { + const { container } = render(); + expect(() => container).not.toThrowError(); + expect(container.querySelector('.icon-wrapper')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('Renders with the correct icon', () => { + const { container } = render(); + expect(container.querySelector('path').getAttribute('d')).toBe( + 'M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z' + ); + }); + + test('Renders with the correct color', () => { + const { container } = render( + + ); + expect(container.querySelector('path').getAttribute('style')).toBe( + 'fill: #666666;' + ); + expect(container).toMatchSnapshot(); + }); + + test('Renders with the correct additional classes', () => { + const { container } = render( + + ); + expect( + container + .querySelector('.icon-wrapper') + .classList.contains('custom-class') + ).toBe(true); + }); + + test('Renders with the correct title attribute', () => { + const { container } = render( + + ); + expect(container.querySelector('title').innerHTML).toBe('Search Icon'); + }); + + test('Icon is large', () => { + const { container } = render( + + ); + expect(container.querySelector('svg').getAttribute('style')).toBe( + 'width: 24px; height: 24px;' + ); + expect(container).toMatchSnapshot(); + }); + + test('Icon is medium', () => { + const { container } = render( + + ); + expect(container.querySelector('svg').getAttribute('style')).toBe( + 'width: 20px; height: 20px;' + ); + expect(container).toMatchSnapshot(); + }); + + test('Icon is small', () => { + const { container } = render( + + ); + expect(container.querySelector('svg').getAttribute('style')).toBe( + 'width: 16px; height: 16px;' + ); + expect(container).toMatchSnapshot(); + }); + + test('Icon is xsmall', () => { + const { container } = render( + + ); + expect(container.querySelector('svg').getAttribute('style')).toBe( + 'width: 14px; height: 14px;' + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Icon/__snapshots__/Icon.test.tsx.snap b/src/components/Icon/__snapshots__/Icon.test.tsx.snap new file mode 100644 index 000000000..13b750fc1 --- /dev/null +++ b/src/components/Icon/__snapshots__/Icon.test.tsx.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Icon Icon is large 1`] = ` +
+ + + + + +
+`; + +exports[`Icon Icon is medium 1`] = ` +
+ + + + + +
+`; + +exports[`Icon Icon is small 1`] = ` +
+ + + + + +
+`; + +exports[`Icon Icon is xsmall 1`] = ` +
+ + + + + +
+`; + +exports[`Icon Renders with the correct color 1`] = ` +
+ + + + + +
+`; + +exports[`Icon Renders without crashing 1`] = ` +
+ + + + + +
+`; diff --git a/src/components/Icon/mdi.ts b/src/components/Icon/mdi.ts index 34a892a02..3948758b5 100644 --- a/src/components/Icon/mdi.ts +++ b/src/components/Icon/mdi.ts @@ -24,6 +24,7 @@ export enum IconName { mdiAccountTieHat = 'M16 14.5C16 15.6 15.7 18 13.8 20.8L13 16L13.9 14.1C13.3 14.1 12.7 14 12 14S10.7 14.1 10.1 14.1L11 16L10.2 20.8C8.3 18.1 8 15.6 8 14.5C5.6 15.2 4 16.5 4 18V22H20V18C20 16.5 18.4 15.2 16 14.5M6 4.5C6 3.1 8.7 2 12 2S18 3.1 18 4.5C18 4.9 17.8 5.2 17.5 5.5C16.6 4.6 14.5 4 12 4S7.4 4.6 6.5 5.5C6.2 5.2 6 4.9 6 4.5M15.9 7.4C16 7.6 16 7.8 16 8C16 10.2 14.2 12 12 12S8 10.2 8 8C8 7.8 8 7.6 8.1 7.4C9.1 7.8 10.5 8 12 8S14.9 7.8 15.9 7.4M16.6 6.1C15.5 6.6 13.9 7 12 7S8.5 6.6 7.4 6.1C8.1 5.5 9.8 5 12 5S15.9 5.5 16.6 6.1Z', mdiAccountTieHatOutline = 'M6 4.5C6 3.1 8.7 2 12 2S18 3.1 18 4.5C18 4.9 17.8 5.2 17.5 5.5C16.6 4.6 14.5 4 12 4S7.4 4.6 6.5 5.5C6.2 5.2 6 4.9 6 4.5M12 5C9.8 5 8.1 5.5 7.4 6.1C8.5 6.6 10.1 7 12 7S15.5 6.6 16.6 6.1C15.9 5.5 14.2 5 12 5M14 8C14 9.1 13.1 10 12 10S10 9.1 10 8V7.9C9.3 7.8 8.6 7.7 8 7.5V8C8 10.2 9.8 12 12 12S16 10.2 16 8C16 7.8 16 7.6 15.9 7.4C15.3 7.6 14.6 7.7 13.9 7.8C14 7.9 14 7.9 14 8M16.4 13.8L15.7 15L15.5 15.5C17 16 18.1 16.6 18.1 17V20.1H13.9L13 15L13.9 13.1C13.3 13.1 12.7 13 12 13S10.7 13 10.1 13.1L11 15L10.1 20.1H5.9V17C5.9 16.6 7 16 8.5 15.5L8.3 15L7.7 13.8C5.7 14.4 4 15.5 4 17V22H20V17C20 15.5 18.3 14.4 16.4 13.8Z', mdiAirplane = 'M20.56 3.91C21.15 4.5 21.15 5.45 20.56 6.03L16.67 9.92L18.79 19.11L17.38 20.53L13.5 13.1L9.6 17L9.96 19.47L8.89 20.53L7.13 17.35L3.94 15.58L5 14.5L7.5 14.87L11.37 11L3.94 7.09L5.36 5.68L14.55 7.8L18.44 3.91C19 3.33 20 3.33 20.56 3.91Z', + mdiAlarm = "M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22A9,9 0 0,0 21,13A9,9 0 0,0 12,4M12.5,8H11V14L15.75,16.85L16.5,15.62L12.5,13.25V8M7.88,3.39L6.6,1.86L2,5.71L3.29,7.24L7.88,3.39M22,5.72L17.4,1.86L16.11,3.39L20.71,7.25L22,5.72Z", mdiAlert = 'M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z', mdiAlertCircle = 'M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z', mdiAlertCircleOutline = 'M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z', diff --git a/src/components/InfoBar/InfoBar.test.tsx b/src/components/InfoBar/InfoBar.test.tsx new file mode 100644 index 000000000..2c0e43c68 --- /dev/null +++ b/src/components/InfoBar/InfoBar.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { InfoBar, InfoBarType } from './'; +import { fireEvent, render } from '@testing-library/react'; +import { IconName } from '../Icon'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +describe('InfoBar', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Renders without crashing', () => { + const { container, getByRole } = render( + + ); + const infoBar = getByRole('alert'); + expect(() => container).not.toThrowError(); + expect(infoBar).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('Calls onClose callback when close button is clicked', () => { + const onClose = jest.fn(); + const { container } = render( + + ); + fireEvent.click(container.querySelector('.close-button')); + expect(onClose).toHaveBeenCalled(); + }); + + test('Renders a custom icon when the icon prop uses a custom icon', () => { + const { container } = render( + + ); + expect(container.querySelector('.icon')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('InfoBar is Disruptive', () => { + const { container } = render( + + ); + expect(container.querySelector('.disruptive')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('InfoBar is Neutral', () => { + const { container } = render( + + ); + expect(container.querySelector('.neutral')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('InfoBar is Positive', () => { + const { container } = render( + + ); + expect(container.querySelector('.positive')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('InfoBar is Warning', () => { + const { container } = render( + + ); + expect(container.querySelector('.warning')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/InfoBar/InfoBar.tsx b/src/components/InfoBar/InfoBar.tsx index bf3dc5119..930fbcce3 100644 --- a/src/components/InfoBar/InfoBar.tsx +++ b/src/components/InfoBar/InfoBar.tsx @@ -93,8 +93,12 @@ export const InfoBar: FC = React.forwardRef(
{content}
{actionButtonProps && ( )} {closable && ( @@ -103,8 +107,12 @@ export const InfoBar: FC = React.forwardRef( iconProps={{ path: closeIcon }} onClick={onClose} shape={ButtonShape.Round} + transparent {...closeButtonProps} - disruptive={type === InfoBarType.disruptive} + classNames={mergeClasses([ + styles.closeButton, + closeButtonProps?.classNames, + ])} /> )}
diff --git a/src/components/InfoBar/__snapshots__/InfoBar.test.tsx.snap b/src/components/InfoBar/__snapshots__/InfoBar.test.tsx.snap new file mode 100644 index 000000000..b9f6bc73a --- /dev/null +++ b/src/components/InfoBar/__snapshots__/InfoBar.test.tsx.snap @@ -0,0 +1,187 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InfoBar InfoBar is Disruptive 1`] = ` +
+ +
+`; + +exports[`InfoBar InfoBar is Neutral 1`] = ` +
+ +
+`; + +exports[`InfoBar InfoBar is Positive 1`] = ` +
+ +
+`; + +exports[`InfoBar InfoBar is Warning 1`] = ` +
+ +
+`; + +exports[`InfoBar Renders a custom icon when the icon prop uses a custom icon 1`] = ` +
+ +
+`; + +exports[`InfoBar Renders without crashing 1`] = ` +
+ +
+`; diff --git a/src/components/InfoBar/infoBar.module.scss b/src/components/InfoBar/infoBar.module.scss index deec4c39e..8e14fc258 100644 --- a/src/components/InfoBar/infoBar.module.scss +++ b/src/components/InfoBar/infoBar.module.scss @@ -11,35 +11,74 @@ &.neutral { background-color: var(--info-bar-background-color); color: var(--info-bar-text-color); + + .action-button, + .close-button { + &:active { + background-color: var(--info-bar-button-active-background-color); + } + } } &.positive { background-color: var(--info-bar-positive-background-color); color: var(--info-bar-positive-text-color); + + .action-button, + .close-button { + &:active { + background-color: var( + --info-bar-positive-button-active-background-color + ); + } + } } &.warning { background-color: var(--info-bar-warning-background-color); color: var(--info-bar-warning-text-color); + + .action-button, + .close-button { + &:active { + background-color: var( + --info-bar-warning-button-active-background-color + ); + } + } } &.disruptive { background-color: var(--info-bar-disruptive-background-color); color: var(--info-bar-disruptive-text-color); + + .action-button, + .close-button { + &:active { + background-color: var( + --info-bar-disruptive-button-active-background-color + ); + } + } } + .action-button, + .close-button, .icon { color: inherit; } + .action-button, + .close-button { + &:hover { + background-color: var(--info-bar-button-hover-background-color); + } + } + .message { flex: 1; display: flex; align-items: center; color: inherit; } - - .buttons-container { - padding: $space-m; - } } diff --git a/src/components/Inputs/TextInput/TextInput.tsx b/src/components/Inputs/TextInput/TextInput.tsx index 403afe154..bdc68822a 100644 --- a/src/components/Inputs/TextInput/TextInput.tsx +++ b/src/components/Inputs/TextInput/TextInput.tsx @@ -70,6 +70,7 @@ export const TextInput: FC = React.forwardRef( placeholder, required = false, readonly = false, + role = 'textbox', shape = TextInputShape.Rectangle, size = TextInputSize.Medium, status, @@ -433,7 +434,7 @@ export const TextInput: FC = React.forwardRef( onKeyDown={!allowDisabledFocus ? onKeyDown : null} placeholder={placeholder} required={required} - role="textbox" + role={role} style={style} tabIndex={0} type={numbersOnly ? 'number' : htmlType} diff --git a/src/components/Label/Label.types.ts b/src/components/Label/Label.types.ts index 220bd60c2..6e62d33f3 100644 --- a/src/components/Label/Label.types.ts +++ b/src/components/Label/Label.types.ts @@ -1,5 +1,5 @@ import { ButtonProps } from '../Button'; -import { Placement, Strategy } from '@floating-ui/react-dom'; +import { Placement, Strategy } from '@floating-ui/react'; import { TooltipTheme, TooltipProps } from '../Tooltip'; import { OcBaseProps } from '../OcBase'; import { Size } from '../ConfigProvider'; diff --git a/src/components/Link/Link.stories.tsx b/src/components/Link/Link.stories.tsx index 259c79f2e..8dccfe99e 100644 --- a/src/components/Link/Link.stories.tsx +++ b/src/components/Link/Link.stories.tsx @@ -51,8 +51,8 @@ export default { }, argTypes: { variant: { - options: ['default', 'primary'], - control: { type: 'inline-radio' }, + options: ['default', 'primary', 'secondary', 'neutral', 'disruptive'], + control: { type: 'inline' }, }, onClick: { action: 'click', @@ -60,27 +60,46 @@ export default { }, } as ComponentMeta; -const Default_Story: ComponentStory = (args) => ; - -export const Default = Default_Story.bind({}); - -const Primary_Story: ComponentStory = (args) => ; - -export const Primary = Primary_Story.bind({}); - -const Default_Disabled_Story: ComponentStory = (args) => ( - -); +const Link_Story: ComponentStory = (args) => { + // Prevents :visited from persisting + const testAnchor = (): string => { + return `#${Math.floor(Math.random() * 1000)}-eftestanchor`; + }; + return ; +}; -export const Default_Disabled = Default_Disabled_Story.bind({}); +export const Default = Link_Story.bind({}); +export const Primary = Link_Story.bind({}); +export const Secondary = Link_Story.bind({}); +export const Neutral = Link_Story.bind({}); +export const Disruptive = Link_Story.bind({}); +export const Primary_Underline = Link_Story.bind({}); +export const Secondary_Underline = Link_Story.bind({}); +export const Neutral_Underline = Link_Story.bind({}); +export const Disruptive_Underline = Link_Story.bind({}); +export const Default_Disabled = Link_Story.bind({}); +export const Primary_Disabled = Link_Story.bind({}); +export const Secondary_Disabled = Link_Story.bind({}); +export const Neutral_Disabled = Link_Story.bind({}); +export const Disruptive_Disabled = Link_Story.bind({}); const linkArgs: Object = { - href: 'https://eightfold.ai', classNames: 'my-link-class', - children: 'Eightfold', + children: ( + + + Default + + ), + fullWidth: true, target: '_self', variant: 'default', - style: {}, }; Default.args = { @@ -89,11 +108,24 @@ Default.args = { Primary.args = { ...linkArgs, - href: 'https://eightfold.ai', + children: ( + + + Primary + + ), + fullWidth: false, variant: 'primary', - style: { - maxWidth: '100px', - }, +}; + +Neutral.args = { + ...linkArgs, children: ( - - User Profile + + Neutral ), + fullWidth: false, + variant: 'neutral', +}; + +Secondary.args = { + ...linkArgs, + children: ( + + + Secondary + + ), + fullWidth: false, + variant: 'secondary', +}; + +Disruptive.args = { + ...linkArgs, + children: ( + + + Disruptive + + ), + fullWidth: false, + variant: 'disruptive', +}; + +Primary_Underline.args = { + ...linkArgs, + children: ( + + + Primary + + ), + fullWidth: false, + underline: true, + variant: 'primary', +}; + +Neutral_Underline.args = { + ...linkArgs, + children: ( + + + Neutral + + ), + fullWidth: false, + underline: true, + variant: 'neutral', +}; + +Secondary_Underline.args = { + ...linkArgs, + children: ( + + + Secondary + + ), + fullWidth: false, + underline: true, + variant: 'secondary', +}; + +Disruptive_Underline.args = { + ...linkArgs, + children: ( + + + Disruptive + + ), + fullWidth: false, + underline: true, + variant: 'disruptive', }; Default_Disabled.args = { ...linkArgs, - href: 'https://eightfold.ai', - onClick: (e: React.MouseEvent) => e.preventDefault(), children: ( - - User Profile + + Default + + ), + disabled: true, + fullWidth: false, +}; + +Primary_Disabled.args = { + ...linkArgs, + children: ( + + + Primary + + ), + disabled: true, + fullWidth: false, + variant: 'primary', +}; + +Neutral_Disabled.args = { + ...linkArgs, + children: ( + + + Neutral + + ), + disabled: true, + fullWidth: false, + variant: 'neutral', +}; + +Secondary_Disabled.args = { + ...linkArgs, + children: ( + + + Secondary + + ), + disabled: true, + fullWidth: false, + variant: 'secondary', +}; + +Disruptive_Disabled.args = { + ...linkArgs, + children: ( + + + Disruptive ), + disabled: true, + fullWidth: false, + variant: 'disruptive', }; diff --git a/src/components/Link/Link.test.tsx b/src/components/Link/Link.test.tsx new file mode 100644 index 000000000..5e3fabaf9 --- /dev/null +++ b/src/components/Link/Link.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { Link } from '.'; +import { render } from '@testing-library/react'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +describe('Link', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Should navigate to ... when Link is clicked', () => { + const LinkToClick = (): JSX.Element => { + return ( + <> + + Click me + + + ); + }; + const { getByTestId } = render(); + expect(getByTestId('link-1').getAttribute('href')).toBe( + 'https://eightfold.ai' + ); + }); + + test('Link is default', () => { + const { container } = render( + Test default + ); + expect(container.getElementsByClassName('link-style')).toHaveLength(1); + expect(container).toMatchSnapshot(); + }); + + test('Link is neutral', () => { + const { container } = render( + + Test neutral + + ); + expect(container.getElementsByClassName('link-style')).toHaveLength(1); + expect(container.getElementsByClassName('neutral')).toHaveLength(1); + expect(container).toMatchSnapshot(); + }); + + test('Link is primary', () => { + const { container } = render( + + ); + expect(container.getElementsByClassName('link-style')).toHaveLength(1); + expect(container.getElementsByClassName('primary')).toHaveLength(1); + expect(container).toMatchSnapshot(); + }); + + test('Link is secondary', () => { + const { container } = render( + + Test secondary + + ); + expect(container.getElementsByClassName('link-style')).toHaveLength(1); + expect(container.getElementsByClassName('secondary')).toHaveLength(1); + expect(container).toMatchSnapshot(); + }); + + test('Link is underline when variant is primary', () => { + const { container } = render( + + Test underline + + ); + expect(container.getElementsByClassName('link-style')).toHaveLength(1); + expect(container.getElementsByClassName('primary')).toHaveLength(1); + expect(container.getElementsByClassName('underline')).toHaveLength(1); + expect(container).toMatchSnapshot(); + }); + + test('Link is disabled', () => { + const { container } = render( + + Test + + ); + expect(container.getElementsByClassName('link-style')).toHaveLength(1); + expect(container.getElementsByClassName('disabled')).toHaveLength(1); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 2ac651055..440f84b1a 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -11,7 +11,12 @@ export const Link: FC = React.forwardRef( href, classNames, children, + disabled = false, + fullWidth = true, + onClick, + role = 'link', target = '_self', + underline, variant = 'default', style, 'data-test-id': dataTestId, @@ -19,20 +24,39 @@ export const Link: FC = React.forwardRef( }, ref: Ref ) => { - const linkClasses: string = mergeClasses([ + const linkClassNames: string = mergeClasses([ styles.linkStyle, + { [styles.fullWidth]: !!fullWidth }, + { [styles.neutral]: variant === 'neutral' }, { [styles.primary]: variant === 'primary' }, + { [styles.secondary]: variant === 'secondary' }, + { [styles.disruptive]: variant === 'disruptive' }, + { [styles.underline]: !!underline }, + { [styles.disabled]: disabled }, classNames, ]); + const handleOnClick = ( + event: React.MouseEvent + ): void => { + if (disabled) { + event.preventDefault(); + return; + } + onClick?.(event); + }; + return ( {children} diff --git a/src/components/Link/Link.types.ts b/src/components/Link/Link.types.ts index 7cbe73ce9..3b0725d21 100644 --- a/src/components/Link/Link.types.ts +++ b/src/components/Link/Link.types.ts @@ -1,12 +1,41 @@ import { AnchorHTMLAttributes } from 'react'; import { OcBaseProps } from '../OcBase'; +export type LinkType = + | 'default' + | 'disruptive' + | 'neutral' + | 'primary' + | 'secondary'; + export interface LinkProps extends OcBaseProps, AnchorHTMLAttributes { /** - * Link Variant + * Whether the Link is disabled. + * @default false + */ + disabled?: boolean; + /** + * Whether the Link display is inline and width is unset. + * @default true + */ + fullWidth?: boolean; + /** + * The Link onClick event handler. + */ + onClick?: React.MouseEventHandler; + /** + * The Link role. + */ + role?: string; + /** + * Whether to show the Link underline. + */ + underline?: boolean; + /** + * Link Variant. * @default 'default' */ - variant?: 'default' | 'primary'; + variant?: LinkType; } diff --git a/src/components/Link/__snapshots__/Link.test.tsx.snap b/src/components/Link/__snapshots__/Link.test.tsx.snap new file mode 100644 index 000000000..a27674915 --- /dev/null +++ b/src/components/Link/__snapshots__/Link.test.tsx.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Link Link is default 1`] = ` + +`; + +exports[`Link Link is disabled 1`] = ` + +`; + +exports[`Link Link is neutral 1`] = ` + +`; + +exports[`Link Link is primary 1`] = ` +
+ +
+`; + +exports[`Link Link is secondary 1`] = ` + +`; + +exports[`Link Link is underline when variant is primary 1`] = ` + +`; diff --git a/src/components/Link/link.module.scss b/src/components/Link/link.module.scss index 35399ded8..fd337508f 100644 --- a/src/components/Link/link.module.scss +++ b/src/components/Link/link.module.scss @@ -1,15 +1,99 @@ .link-style { cursor: pointer; font-family: $octuple-font-family; + font-weight: $text-font-weight-semibold; font-size: $text-font-size-2; + width: fit-content; + + &.full-width { + width: unset; // Ensure width is unset to be safe accross all browsers + } + + &.neutral { + color: var(--anchor-neutral-color); + + &:active { + color: var(--anchor-neutral-active-color); + } + + &:hover { + color: var(--anchor-neutral-hover-color); + } + + &:visited { + color: var(--anchor-neutral-visited-color); + } + } &.primary { - color: var(--anchor-color); + color: var(--anchor-primary-color); + + &:active { + color: var(--anchor-primary-active-color); + } - &:active, &:hover { - color: var(--anchor-hover-color); + color: var(--anchor-primary-hover-color); + } + + &:visited { + color: var(--anchor-primary-visited-color); + } + } + + &.secondary { + color: var(--anchor-secondary-color); + + &:active { + color: var(--anchor-secondary-active-color); } + + &:hover { + color: var(--anchor-secondary-hover-color); + } + + &:visited { + color: var(--anchor-secondary-visited-color); + } + } + + &.disruptive { + color: var(--anchor-disruptive-color); + + &:active { + color: var(--anchor-disruptive-active-color); + } + + &:hover { + color: var(--anchor-disruptive-hover-color); + } + + &:visited { + color: var(--anchor-disruptive-visited-color); + } + } + + &.neutral, + &.primary, + &.secondary, + &.disruptive { + display: inline-block; + text-decoration: none; + + &:active:not(.disabled), + &:hover:not(.disabled) { + text-decoration: underline; + } + } + + &.underline { + text-decoration: underline; + } + + &.disabled { + cursor: not-allowed; + opacity: $disabled-alpha-value; + text-decoration: none; } // Hides the browser default keyboard focus-visible styles. @@ -24,6 +108,7 @@ &.focus-visible, &:focus-visible { box-shadow: var(--focus-visible-box-shadow); + text-decoration: underline; } } } diff --git a/src/components/Menu/CascadingMenu.test.tsx b/src/components/Menu/CascadingMenu.test.tsx new file mode 100644 index 000000000..d732d0465 --- /dev/null +++ b/src/components/Menu/CascadingMenu.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { MenuItemIconAlign, MenuItemType } from './MenuItem/MenuItem.types'; +import { MenuVariant } from './Menu.types'; +import { CascadingMenu } from './CascadingMenu'; +import { DefaultButton } from '../Button'; +import { IconName } from '../Icon'; +import { render, screen, waitFor } from '@testing-library/react'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +class ResizeObserver { + observe() { + // do nothing + } + unobserve() { + // do nothing + } + disconnect() { + // do nothing + } +} + +window.ResizeObserver = ResizeObserver; + +const cascadingMenuItems = [ + { + iconProps: { + path: IconName.mdiCalendar, + }, + text: 'Button', + value: 'menu 1', + counter: '8', + secondaryButtonProps: { + iconProps: { + path: IconName.mdiTrashCan, + }, + }, + }, + { + text: 'Disabled button', + value: 'menu 2', + disabled: true, + subText: 'This is a sub text', + }, + { + iconProps: { + path: IconName.mdiCalendar, + }, + text: 'Date', + value: 'menu 3', + counter: '8', + }, + { + alignIcon: MenuItemIconAlign.Right, + iconProps: { + path: IconName.mdiChevronRight, + }, + dropdownMenuItems: [ + { + text: 'Button 1', + value: 'subMenuA 1', + }, + { + text: 'Button 2', + value: 'subMenuA 1', + }, + { + text: 'Button 3', + value: 'subMenuA 1', + }, + { + alignIcon: MenuItemIconAlign.Right, + iconProps: { + path: IconName.mdiChevronRight, + }, + dropdownMenuItems: [ + { + text: 'Button 4', + value: 'subMenuB 1', + }, + { + text: 'Button 5', + value: 'subMenuB 2', + }, + { + text: 'Button 6', + value: 'subMenuB 3', + }, + ], + text: 'Sub menu 2', + value: 'subMenuA 2', + }, + ], + text: 'Sub menu 1', + value: 'menu 4', + }, + { + type: MenuItemType.subHeader, + text: 'Sub header', + }, + { + type: MenuItemType.link, + text: 'Twitter link', + href: 'https://twitter.com', + target: '_blank', + }, +]; + +const menuProps: Object = { + variant: MenuVariant.neutral, + classNames: 'my-menu-class', + style: { + color: 'red', + }, + itemClassNames: 'my-menu-item-class', + itemStyle: {}, + listType: 'ul', +}; + +const CascadingMenuComponent = (): JSX.Element => ( + + + +); + +describe('Menu', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Should render a menu button', () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + expect(dropdownButton).toBeTruthy(); + expect(() => container).not.toThrowError(); + expect(container).toMatchSnapshot(); + }); + + test('Should show the menu items when the button is clicked', async () => { + render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Button')); + const menuitem1 = screen.getByText('Button'); + const menuitem2 = screen.getByText('Disabled button'); + const menuitem3 = screen.getByText('Date'); + const menuitem4 = screen.getByText('Sub menu 1'); + const menuitem5 = screen.getByText('Twitter link'); + expect(menuitem1).toBeTruthy(); + expect(menuitem2).toBeTruthy(); + expect(menuitem3).toBeTruthy(); + expect(menuitem4).toBeTruthy(); + expect(menuitem5).toBeTruthy(); + }); + + test('Should show the sub menu items when the button is clicked', async () => { + render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Sub menu 1')); + const submenuitem = screen.getByText('Sub menu 1'); + submenuitem.click(); + await waitFor(() => screen.getByText('Button 1')); + const menuitem1 = screen.getByText('Button 1'); + const menuitem2 = screen.getByText('Button 2'); + const menuitem3 = screen.getByText('Button 3'); + const menuitem4 = screen.getByText('Sub menu 2'); + expect(menuitem1).toBeTruthy(); + expect(menuitem2).toBeTruthy(); + expect(menuitem3).toBeTruthy(); + expect(menuitem4).toBeTruthy(); + }); + + test('Should show the second sub menu items when the first sub menu button is clicked', async () => { + render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Sub menu 1')); + const submenuitem1 = screen.getByText('Sub menu 1'); + submenuitem1.click(); + await waitFor(() => screen.getByText('Sub menu 2')); + const submenuitem2 = screen.getByText('Sub menu 2'); + submenuitem2.click(); + await waitFor(() => screen.getByText('Button 4')); + const menuitem1 = screen.getByText('Button 4'); + const menuitem2 = screen.getByText('Button 5'); + const menuitem3 = screen.getByText('Button 6'); + expect(menuitem1).toBeTruthy(); + expect(menuitem2).toBeTruthy(); + expect(menuitem3).toBeTruthy(); + }); +}); diff --git a/src/components/Menu/CascadingMenu.tsx b/src/components/Menu/CascadingMenu.tsx new file mode 100644 index 000000000..aa9f7492b --- /dev/null +++ b/src/components/Menu/CascadingMenu.tsx @@ -0,0 +1,353 @@ +import React, { + cloneElement, + FC, + forwardRef, + useEffect, + useState, +} from 'react'; +import { + autoUpdate, + ElementProps, + flip, + FloatingFocusManager, + FloatingNode, + FloatingPortal, + FloatingTree, + FloatingTreeType, + offset, + ReferenceType, + safePolygon, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useFloatingTree, + useHover, + useInteractions, + useMergeRefs, + useRole, +} from '@floating-ui/react'; +import { DropdownMenuProps, MenuItemTypes, MenuSize } from './Menu.types'; +import { MenuItemType } from './MenuItem/MenuItem.types'; +import { MenuItem } from './MenuItem/MenuItem'; +import { ButtonSize, NeutralButton, PrimaryButton } from '../Button'; +import { List } from '../List'; +import { Stack } from '../Stack'; +import { useCanvasDirection } from '../../hooks/useCanvasDirection'; +import { mergeClasses } from '../../shared/utilities'; + +import dropdownStyles from '../Dropdown/dropdown.module.scss'; +import menuStyles from './menu.module.scss'; + +export const MenuComponent: FC = forwardRef< + HTMLDivElement, + DropdownMenuProps +>(({ children, ...props }, ref: React.ForwardedRef) => { + const htmlDir: string = useCanvasDirection(); + + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const [allowHover, setAllowHover] = useState(false); + const tree: FloatingTreeType = useFloatingTree(); + const nodeId: string = useFloatingNodeId(); + const parentId: string = useFloatingParentNodeId(); + const isNested: boolean = parentId != null; + + const { x, y, strategy, refs, context } = useFloating({ + nodeId, + open: isOpen, + onOpenChange: setIsOpen, + placement: isNested ? 'right-start' : 'bottom-start', + middleware: [ + offset({ + mainAxis: isNested ? 8 : 0, + alignmentAxis: isNested ? -8 : 0, + }), + flip(), + shift(), + ], + whileElementsMounted: autoUpdate, + }); + + const hover: ElementProps = useHover(context, { + enabled: isNested && allowHover, + delay: { open: 75 }, + handleClose: safePolygon({ + restMs: 25, + blockPointerEvents: true, + }), + }); + + const click: ElementProps = useClick(context, { + event: 'mousedown', + toggle: !isNested || !allowHover, + ignoreMouse: isNested, + }); + + const role: ElementProps = useRole(context, { + role: 'menu', + }); + + const dismiss: ElementProps = useDismiss(context); + + // TODO: ENG-46500 Implement `listNavigation` and `typeahead` in `useInteractions` via + // `useListNavigation` and `useTypeahead` floating-ui helpers. + // Need to figure out how to `useRef` `MenuItemType` to get each ref `HTMLElement`. + // See floatging-ui API reference implementation at: + // https://codesandbox.io/s/bold-panna-9tf226?file=/src/DropdownMenu.tsx + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [hover, click, role, dismiss] + ); + + // Event emitter allows you to communicate across tree components. + // This effect closes all menus when an item gets clicked anywhere + // in the tree. + useEffect(() => { + if (!tree) return null; + + const handleTreeClick = (): void => { + setIsOpen(false); + }; + + const onSubMenuOpen = (event: { + nodeId: string; + parentId: string; + }): void => { + if (event.nodeId !== nodeId && event.parentId === parentId) { + setIsOpen(false); + } + }; + + tree.events.on('click', handleTreeClick); + tree.events.on('menuopen', onSubMenuOpen); + + return () => { + tree.events.off('click', handleTreeClick); + tree.events.off('menuopen', onSubMenuOpen); + }; + }, [tree, nodeId, parentId]); + + useEffect(() => { + if (isOpen && tree) { + tree.events.emit('menuopen', { parentId, nodeId }); + } + }, [tree, isOpen, nodeId, parentId]); + + // Determine if "hover" logic can run based on the modality of input. This + // prevents unwanted focus synchronization as menus open and close with + // keyboard navigation and the cursor is resting on the menu. + useEffect(() => { + const onPointerMove = ({ pointerType }: PointerEvent): void => { + if (pointerType !== 'touch') { + setAllowHover(true); + } + }; + + const onKeyDown = (): void => { + setAllowHover(false); + }; + + window.addEventListener('pointermove', onPointerMove, { + once: true, + capture: true, + }); + + window.addEventListener('keydown', onKeyDown, true); + + return () => { + window.removeEventListener('pointermove', onPointerMove, { + capture: true, + }); + window.removeEventListener('keydown', onKeyDown, true); + }; + }, [allowHover]); + + const reference: (instance: ReferenceType) => void = + useMergeRefs([refs.setReference, ref]); + + const getReference = (): React.ReactElement => { + const child = React.Children.only(children) as React.ReactElement; + const referenceClassNames: string = mergeClasses([ + // Add any classnames added to the reference element + { [child.props.className]: child.props.className }, + ]); + + return cloneElement(child, { + ref: reference, + role: 'button', + 'data-open': isOpen ? '' : undefined, + ...getReferenceProps({ + className: referenceClassNames, + onClick(event) { + event.stopPropagation(); + }, + }), + }); + }; + + const footerClassNames: string = mergeClasses([ + menuStyles.menuFooterContainer, + { + [menuStyles.large]: props.size === MenuSize.large, + [menuStyles.medium]: props.size === MenuSize.medium, + [menuStyles.small]: props.size === MenuSize.small, + }, + ]); + + const headerClassNames: string = mergeClasses([ + menuStyles.menuHeaderContainer, + { + [menuStyles.large]: props.size === MenuSize.large, + [menuStyles.medium]: props.size === MenuSize.medium, + [menuStyles.small]: props.size === MenuSize.small, + }, + ]); + + const menuClassNames: string = mergeClasses([ + props.classNames, + menuStyles.menuContainer, + { + [menuStyles.large]: props.size === MenuSize.large, + [menuStyles.medium]: props.size === MenuSize.medium, + [menuStyles.small]: props.size === MenuSize.small, + }, + ]); + + const menuSizeToButtonSizeMap: Map = new Map< + MenuSize, + ButtonSize + >([ + [MenuSize.large, ButtonSize.Large], + [MenuSize.medium, ButtonSize.Medium], + [MenuSize.small, ButtonSize.Small], + ]); + + const getListItem = (item: MenuItemTypes, index: number): JSX.Element => ( + ) => { + props.onClick?.(event); + tree?.events.emit('click'); + props.onChange?.(item.value); + }} + onMouseEnter={() => { + if (allowHover && isOpen) { + setActiveIndex(index); + } + }} + size={props.size} + tabIndex={activeIndex === index && 0} + type={item.type ?? MenuItemType.button} + variant={props.variant} + {...item} + {...getItemProps()} + /> + ); + + const getHeader = (): JSX.Element => + props.header && ( +
+
{props.header}
+
+ ); + + const getFooter = (): JSX.Element => + (props.cancelButtonProps || props.okButtonProps) && ( + + {props.cancelButtonProps && ( + + )} + {props.okButtonProps && ( + + )} + + ); + + return ( + + {getReference()} + + {isOpen && ( + +
+ + {...props} + classNames={menuClassNames} + footer={getFooter()} + getItem={getListItem} + header={getHeader()} + items={props.items} + listType={props.listType} + role="menu" + style={props.style} + /> +
+
+ )} +
+
+ ); +}); + +export const CascadingMenu: FC = forwardRef< + HTMLDivElement, + DropdownMenuProps +>( + ( + props: React.PropsWithChildren, + ref: React.ForwardedRef + ) => { + const parentId: string = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; + } +); diff --git a/src/components/Menu/Menu.stories.tsx b/src/components/Menu/Menu.stories.tsx index 59510f3d0..5884821f3 100644 --- a/src/components/Menu/Menu.stories.tsx +++ b/src/components/Menu/Menu.stories.tsx @@ -1,11 +1,20 @@ import React from 'react'; import { Stories } from '@storybook/addon-docs'; import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Menu, MenuItemType, MenuProps, MenuSize, MenuVariant } from './'; +import { + Menu, + MenuItemIconAlign, + MenuItemType, + MenuSize, + MenuVariant, +} from './'; import { Dropdown } from '../Dropdown'; import { DefaultButton } from '../Button'; import { RadioGroup } from '../RadioButton'; import { IconName } from '../Icon'; +import { useCanvasDirection } from '../../hooks/useCanvasDirection'; +import { SelectorSize } from '../CheckBox'; +import { CascadingMenu } from './CascadingMenu'; export default { title: 'Menu', @@ -57,12 +66,20 @@ const BasicOverlay = (args: any) => ( path: IconName.mdiCalendar, }, text: 'Date', - value: 'date 1', + value: 'menu 1', counter: '8', + secondaryButtonProps: { + iconProps: { + path: IconName.mdiTrashCan, + }, + onClick: () => { + console.log('Delete clicked'); + }, + }, }, { - text: 'Thumbs up', - value: 'date 1', + text: 'Disabled button', + value: 'menu 2', disabled: true, subText: 'This is a sub text', }, @@ -71,24 +88,24 @@ const BasicOverlay = (args: any) => ( path: IconName.mdiCalendar, }, text: 'Date', - value: 'date 1', + value: 'menu 3', counter: '8', }, { - text: 'Thumbs up', - value: 'date 1', + text: 'Button', + value: 'menu 4', }, { iconProps: { path: IconName.mdiCalendar, }, text: 'Date', - value: 'date 1', + value: 'menu 5', counter: '8', }, { - text: 'Thumbs up', - value: 'date 1', + text: 'Button', + value: 'menu 6', }, ]} onChange={(item) => { @@ -129,93 +146,102 @@ const LinkOverlay = (args: any) => ( /> ); -const SubHeaderOverlay = (args: any) => ( - ([ + [MenuSize.large, SelectorSize.Large], + [MenuSize.medium, SelectorSize.Medium], + [MenuSize.small, SelectorSize.Small], +]); + +const SubHeaderOverlay = (args: any) => { + return ( + ( - ({ - value: `Radio${i}`, - label: `Radio${i}`, - name: 'group', - id: `oea2exk-${i}`, - })), - layout: 'vertical', - }} - onChange={onChange} - /> - ), - }, - { - iconProps: { - path: IconName.mdiCalendar, + { + iconProps: { + path: IconName.mdiCalendar, + }, + text: 'Date', + value: 'menu 3', + counter: '8', }, - text: 'Date', - value: 'date 1', - counter: '8', - }, - { - text: 'Thumbs up', - value: 'date 1', - }, - ]} - onChange={(item) => { - args.onChange(item); - console.log(item); - }} - /> -); + { + text: 'Button', + value: 'menu 4', + }, + { + type: MenuItemType.subHeader, + text: 'Sub header', + }, + { + type: MenuItemType.link, + text: 'Twitter link', + href: 'https://twitter.com', + target: '_blank', + }, + { + type: MenuItemType.link, + text: 'Facebook link', + href: 'https://facebook.com', + target: '_blank', + }, + { + type: MenuItemType.subHeader, + text: 'Menu type custom', + }, + { + type: MenuItemType.custom, + render: ({ onChange }) => ( + ({ + value: `Radio${i}`, + label: `Radio${i}`, + name: 'group', + id: `oea2exk-${i}`, + })), + layout: 'vertical', + }} + onChange={onChange} + size={menuSizeToSelectorSizeSizeMap.get(args.size)} + /> + ), + }, + { + iconProps: { + path: IconName.mdiCalendar, + }, + text: 'Date', + value: 'menu 5', + counter: '8', + }, + { + text: 'Button', + value: 'menu 6', + }, + ]} + onChange={(item) => { + args.onChange(item); + console.log(item); + }} + /> + ); +}; const Basic_Menu_Story: ComponentStory = (args) => ( @@ -241,15 +267,168 @@ const Menu_Sub_Header_Story: ComponentStory = (args) => ( ); -export const BasicMenu = Basic_Menu_Story.bind({}); -export const LinkMenu = Menu_Story.bind({}); -export const MenuHeader = Menu_Header_Story.bind({}); -export const MenuSubHeader = Menu_Sub_Header_Story.bind({}); -export const MenuFooter = Menu_Header_Story.bind({}); +const Cascading_Menu_Story: ComponentStory = (args) => { + const htmlDir = useCanvasDirection(); + + return ( + { + console.log('Delete clicked'); + }, + }, + }, + { + text: 'Disabled button', + value: 'menu 2', + disabled: true, + subText: 'This is a sub text', + }, + { + iconProps: { + path: IconName.mdiCalendar, + }, + text: 'Date', + value: 'menu 3', + counter: '8', + }, + { + alignIcon: MenuItemIconAlign.Right, + iconProps: { + path: + htmlDir === 'rtl' + ? IconName.mdiChevronLeft + : IconName.mdiChevronRight, + }, + dropdownMenuItems: [ + { + text: 'Button', + value: 'subMenuA 1', + }, + { + text: 'Button', + value: 'subMenuA 1', + }, + { + text: 'Button', + value: 'subMenuA 1', + }, + { + alignIcon: MenuItemIconAlign.Right, + iconProps: { + path: + htmlDir === 'rtl' + ? IconName.mdiChevronLeft + : IconName.mdiChevronRight, + }, + dropdownMenuItems: [ + { + type: MenuItemType.subHeader, + text: 'Sub header', + }, + { + type: MenuItemType.custom, + render: ({ onChange }) => ( + ({ + value: `Radio${i}`, + label: `Radio${i}`, + name: 'group', + id: `oea2exk-${i}`, + })), + layout: 'vertical', + }} + onChange={onChange} + size={menuSizeToSelectorSizeSizeMap.get(args.size)} + /> + ), + }, + ], + text: 'Sub menu', + value: 'subMenuA 2', + }, + ], + text: 'Sub menu', + value: 'menu 4', + dropdownMenuProps: { + cancelButtonProps: { + ariaLabel: 'Cancel', + classNames: 'my-cancel-btn-class', + 'data-test-id': 'my-cancel-btn-test-id', + iconProps: null, + id: 'myCancelButton', + text: 'Cancel', + }, + okButtonProps: { + ariaLabel: 'Accept', + classNames: 'my-accept-btn-class', + 'data-test-id': 'my-accept-btn-test-id', + iconProps: null, + id: 'myAcceptButton', + text: 'Accept', + }, + }, + }, + { + text: 'Button', + value: 'menu 6', + }, + { + text: 'Button', + value: 'menu 7', + }, + { + type: MenuItemType.subHeader, + text: 'Sub header', + }, + { + type: MenuItemType.link, + text: 'Twitter link', + href: 'https://twitter.com', + target: '_blank', + }, + { + type: MenuItemType.link, + text: 'Facebook link', + href: 'https://facebook.com', + target: '_blank', + }, + ]} + onChange={(item) => { + args.onChange(item); + console.log(item); + }} + > + + + ); +}; + +export const Basic_Menu = Basic_Menu_Story.bind({}); +export const Link_Menu = Menu_Story.bind({}); +export const Menu_Header = Menu_Header_Story.bind({}); +export const Menu_Sub_Header = Menu_Sub_Header_Story.bind({}); +export const Menu_Footer = Menu_Header_Story.bind({}); +export const Cascading_Menu = Cascading_Menu_Story.bind({}); const menuArgs: object = { variant: MenuVariant.neutral, - size: MenuSize.large, + size: MenuSize.medium, classNames: 'my-menu-class', style: {}, itemClassNames: 'my-menu-item-class', @@ -257,26 +436,26 @@ const menuArgs: object = { listType: 'ul', }; -BasicMenu.args = { +Basic_Menu.args = { ...menuArgs, }; -LinkMenu.args = { +Link_Menu.args = { ...menuArgs, }; -MenuHeader.args = { - header: 'Header 4 is used here', +Menu_Header.args = { + header: 'Header', ...menuArgs, }; -MenuSubHeader.args = { - header: 'Header 4 is used here', +Menu_Sub_Header.args = { + header: 'Header', ...menuArgs, }; -MenuFooter.args = { - header: 'Header 4 is used here', +Menu_Footer.args = { + header: 'Header', ...menuArgs, cancelButtonProps: { ariaLabel: 'Cancel', @@ -295,3 +474,8 @@ MenuFooter.args = { text: 'Accept', }, }; + +Cascading_Menu.args = { + header: 'Header', + ...menuArgs, +}; diff --git a/src/components/Menu/Menu.test.tsx b/src/components/Menu/Menu.test.tsx new file mode 100644 index 000000000..7373ec1db --- /dev/null +++ b/src/components/Menu/Menu.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { MenuItemType } from './MenuItem/MenuItem.types'; +import { MenuSize, MenuVariant } from './Menu.types'; +import { Menu } from './Menu'; +import { Dropdown } from '../Dropdown'; +import { DefaultButton } from '../Button'; +import { IconName } from '../Icon'; +import { RadioGroup } from '../RadioButton'; +import { SelectorSize } from '../CheckBox'; +import { render, screen, waitFor } from '@testing-library/react'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +const MenuOverlay = (args: any) => { + const menuSizeToSelectorSizeSizeMap = new Map([ + [MenuSize.large, SelectorSize.Large], + [MenuSize.medium, SelectorSize.Medium], + [MenuSize.small, SelectorSize.Small], + ]); + + return ( + ( + ({ + value: `Radio${i}`, + label: `Radio${i}`, + name: 'group', + id: `oea2exk-${i}`, + })), + layout: 'vertical', + }} + onChange={onChange} + size={menuSizeToSelectorSizeSizeMap.get(args.size)} + /> + ), + }, + ]} + onChange={(item) => { + args.onChange(item); + }} + /> + ); +}; + +const menuProps: object = { + variant: MenuVariant.neutral, + classNames: 'my-menu-class', + style: { + color: 'red', + }, + itemClassNames: 'my-menu-item-class', + itemStyle: {}, + listType: 'ul', +}; + +const MenuComponent = (): JSX.Element => { + return ( + + + + ); +}; + +const LargeMenuComponent = (): JSX.Element => { + const _menuProps: object = { + ...menuProps, + size: MenuSize.large, + }; + + return ( + + + + ); +}; + +const MediumMenuComponent = (): JSX.Element => { + const _menuProps: object = { + ...menuProps, + size: MenuSize.medium, + }; + + return ( + + + + ); +}; + +const SmallMenuComponent = (): JSX.Element => { + const _menuProps: object = { + ...menuProps, + size: MenuSize.small, + }; + + return ( + + + + ); +}; + +describe('Menu', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Should render a menu button', () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + expect(dropdownButton).toBeTruthy(); + expect(() => container).not.toThrowError(); + expect(container).toMatchSnapshot(); + }); + + test('Should show the menu items when the button is clicked', async () => { + render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Date')); + const menuitem1 = screen.getByText('Date'); + const menuitem2 = screen.getByText('Disabled button'); + const menuitem3 = screen.getByText('Twitter link'); + const menuitem4 = screen.getByText('Radio1'); + const menuitem5 = screen.getByText('Radio2'); + const menuitem6 = screen.getByText('Radio3'); + expect(menuitem1).toBeTruthy(); + expect(menuitem2).toBeTruthy(); + expect(menuitem3).toBeTruthy(); + expect(menuitem4).toBeTruthy(); + expect(menuitem5).toBeTruthy(); + expect(menuitem6).toBeTruthy(); + }); + + test('Should support sub header', async () => { + render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Sub header')); + const subHeader = screen.getByText('Sub header'); + expect(subHeader).toBeTruthy(); + }); + + test('Should support props such as classNames and style', async () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Date')); + expect(container.querySelector('.menu-container')?.classList).toContain( + 'my-menu-class' + ); + expect( + (container.querySelector('.menu-container') as HTMLElement).style.color + ).toContain('red'); + }); + + test('Menu is large', async () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Date')); + expect(container.querySelector('.menu-container')?.classList).toContain( + 'large' + ); + expect(container).toMatchSnapshot(); + }); + + test('Menu is medium', async () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Date')); + expect(container.querySelector('.menu-container')?.classList).toContain( + 'medium' + ); + expect(container).toMatchSnapshot(); + }); + + test('Menu is small', async () => { + const { container } = render(); + const dropdownButton = screen.getByRole('button'); + dropdownButton.click(); + await waitFor(() => screen.getByText('Date')); + expect(container.querySelector('.menu-container')?.classList).toContain( + 'small' + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 0a55259df..c9fa766cc 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -3,38 +3,45 @@ import { MenuItemTypes, MenuProps, MenuSize, MenuVariant } from './Menu.types'; import { List } from '../List'; import { MenuItem } from './MenuItem/MenuItem'; import { MenuItemType } from './MenuItem/MenuItem.types'; -import { mergeClasses } from '../../shared/utilities'; import { Stack } from '../Stack'; import { ButtonSize, NeutralButton, PrimaryButton } from '../Button'; +import { useCanvasDirection } from '../../hooks/useCanvasDirection'; +import { mergeClasses } from '../../shared/utilities'; import styles from './menu.module.scss'; -const MENU_SIZE_TO_BUTTON_SIZE_MAP: Record = { - [MenuSize.large]: ButtonSize.Large, - [MenuSize.medium]: ButtonSize.Medium, - [MenuSize.small]: ButtonSize.Small, -}; - export const Menu: FC = ({ - items, - onChange, - variant = MenuVariant.neutral, - size = MenuSize.medium, + cancelButtonProps, classNames, - style, + header, itemClassNames, + itemProps, + items, itemStyle, - header, listType, - itemProps, - subHeader, okButtonProps, - cancelButtonProps, - onOk, onCancel, + onChange, + onOk, + role = 'menu', + size = MenuSize.medium, + style, + subHeader, + variant = MenuVariant.neutral, ...rest }) => { - const headerClasses: string = mergeClasses([ + const htmlDir: string = useCanvasDirection(); + + const footerClassNames: string = mergeClasses([ + styles.menuFooterContainer, + { + [styles.large]: size === MenuSize.large, + [styles.medium]: size === MenuSize.medium, + [styles.small]: size === MenuSize.small, + }, + ]); + + const headerClassNames: string = mergeClasses([ styles.menuHeaderContainer, { [styles.large]: size === MenuSize.large, @@ -43,8 +50,9 @@ export const Menu: FC = ({ }, ]); - const footerClasses: string = mergeClasses([ - styles.menuFooterContainer, + const menuClassNames: string = mergeClasses([ + classNames, + styles.menuContainer, { [styles.large]: size === MenuSize.large, [styles.medium]: size === MenuSize.medium, @@ -52,8 +60,19 @@ export const Menu: FC = ({ }, ]); + const menuSizeToButtonSizeMap: Map = new Map< + MenuSize, + ButtonSize + >([ + [MenuSize.large, ButtonSize.Large], + [MenuSize.medium, ButtonSize.Medium], + [MenuSize.small, ButtonSize.Small], + ]); + const getListItem = (item: MenuItemTypes, index: number): React.ReactNode => ( = ({ const getHeader = (): JSX.Element => header && ( -
+
{header}
); const getFooter = (): JSX.Element => (cancelButtonProps || okButtonProps) && ( - + {cancelButtonProps && ( )} {okButtonProps && ( )} ); - const menuClassNames = mergeClasses([ - classNames, - styles.menuContainer, - { - [styles.large]: size === MenuSize.large, - [styles.medium]: size === MenuSize.medium, - [styles.small]: size === MenuSize.small, - }, - ]); - return ( {...rest} - items={items} classNames={menuClassNames} - style={style} - header={getHeader()} footer={getFooter()} - listType={listType} - role="menu" getItem={getListItem} + header={getHeader()} + items={items} + listType={listType} + role={role} + style={style} /> ); }; diff --git a/src/components/Menu/Menu.types.ts b/src/components/Menu/Menu.types.ts index eb112f752..a0d1690c9 100644 --- a/src/components/Menu/Menu.types.ts +++ b/src/components/Menu/Menu.types.ts @@ -5,6 +5,7 @@ import { MenuItemCustomProps, MenuItemLinkProps, MenuItemSubHeaderProps, + DropdownMenuItemProps, } from './MenuItem/MenuItem.types'; import { ButtonProps } from '../Button'; @@ -24,7 +25,8 @@ export type MenuItemTypes = | MenuItemLinkProps | MenuItemButtonProps | MenuItemSubHeaderProps - | MenuItemCustomProps; + | MenuItemCustomProps + | DropdownMenuItemProps; export interface MenuProps extends Omit< @@ -52,6 +54,11 @@ export interface MenuProps * Callback when ok button is clicked */ onOk?: React.MouseEventHandler; + /** + * The menu aria role. + * @default 'menu' + */ + role?: string; /** * Size of the menu * @default MenuSize.Medium @@ -67,3 +74,5 @@ export interface MenuProps */ variant?: MenuVariant; } + +export interface DropdownMenuProps extends MenuProps {} diff --git a/src/components/Menu/MenuItem/MenuItem.types.ts b/src/components/Menu/MenuItem/MenuItem.types.ts index 8504f2622..6489fee7d 100644 --- a/src/components/Menu/MenuItem/MenuItem.types.ts +++ b/src/components/Menu/MenuItem/MenuItem.types.ts @@ -1,8 +1,16 @@ import React from 'react'; -import { MenuSize, MenuVariant } from '../Menu.types'; +import { DropdownMenuProps, MenuSize, MenuVariant } from '../Menu.types'; import { OcBaseProps } from '../../OcBase'; import { IconProps } from '../../Icon'; import { LinkProps } from '../../Link'; +import { ButtonProps } from '../../Button'; + +export interface MenuIconProps extends Omit {} + +export enum MenuItemIconAlign { + Left = 'left', + Right = 'right', +} export enum MenuItemType { button = 'button', @@ -12,11 +20,24 @@ export enum MenuItemType { } export interface MenuItemProps { + /** + * The Menu item icon alignment. + * @default MenuItemIconAlign.Left + */ + alignIcon?: MenuItemIconAlign; + /** + * The canvas direction of the Menu. + */ + direction?: string; /** * Size of the menu * @default MenuSize.Medium */ size?: MenuSize; + /** + * Display label of the menu item + */ + text?: string; /** * Type of the menu * @default MenuType.button @@ -25,12 +46,17 @@ export interface MenuItemProps { /** * Value of the menu item */ - value: any; + value?: any; /** * Variant of the menu item * @default MenuVariant.neutral */ variant?: MenuVariant; + /** + * The text should wrap + * @default false + */ + wrap?: boolean; } type NativeMenuButtonProps = Omit, 'children'>; @@ -51,23 +77,35 @@ export interface MenuItemButtonProps * If menu item is disabled or not */ disabled?: boolean; + /** + * Menu item opens a dropdown menu. + */ + dropdownMenuItems?: DropdownMenuItemProps[]; + /** + * The nested dropdown menu props + */ + dropdownMenuProps?: NestedDropdownMenuProps; /** * Menu item icon props */ - iconProps?: IconProps; + iconProps?: MenuIconProps; /** * On Click handler of the menu item * @param value */ onClick?: (value: any) => void; /** - * Display label of the menu item + * Secondary action button for the menu item */ - text?: string; + secondaryButtonProps?: Omit; /** * Display sub text of the menu item */ subText?: string; + /** + * Display label of the menu item + */ + text?: string; } export interface MenuItemLinkProps @@ -89,15 +127,15 @@ export interface MenuItemLinkProps /** * Menu item icon props */ - iconProps?: IconProps; - /** - * Display label of the menu item - */ - text?: string; + iconProps?: MenuIconProps; /** * Display sub text of the menu item */ subText?: string; + /** + * Display label of the menu item + */ + text?: string; } export interface MenuItemSubHeaderProps @@ -126,4 +164,24 @@ export interface MenuItemCustomProps * Method to render custom menu item */ render?: (menuItemRender: IMenuItemRender) => React.ReactNode; + /** + * Menu item opens a dropdown menu. + */ + dropdownMenuItems?: DropdownMenuItemProps[]; + /** + * The nested dropdown menu props + */ + dropdownMenuProps?: NestedDropdownMenuProps; } + +/** + * NOTE: Sub menus should only be triggered by buttons, not links or text. + */ +export type DropdownMenuItemProps = + | MenuItemButtonProps + | MenuItemLinkProps + | MenuItemSubHeaderProps + | MenuItemCustomProps; + +export interface NestedDropdownMenuProps + extends Omit {} diff --git a/src/components/Menu/MenuItem/MenuItemButton/MenuItemButton.tsx b/src/components/Menu/MenuItem/MenuItemButton/MenuItemButton.tsx index 9edd92b21..dfa60d32b 100644 --- a/src/components/Menu/MenuItem/MenuItemButton/MenuItemButton.tsx +++ b/src/components/Menu/MenuItem/MenuItemButton/MenuItemButton.tsx @@ -1,29 +1,41 @@ import React, { FC } from 'react'; -import { MenuItemButtonProps } from '../MenuItem.types'; +import { MenuItemButtonProps, MenuItemIconAlign } from '../MenuItem.types'; import { MenuSize, MenuVariant } from '../../Menu.types'; +import { ButtonShape, ButtonSize, NeutralButton } from '../../../Button'; +import { CascadingMenu } from '../../CascadingMenu'; +import { Icon, IconSize } from '../../../Icon'; import { mergeClasses } from '../../../../shared/utilities'; -import { Icon } from '../../../Icon'; import styles from '../menuItem.module.scss'; export const MenuItemButton: FC = ({ - iconProps, - text, - subText, - variant = MenuVariant.neutral, - size = MenuSize.medium, + active, + alignIcon = MenuItemIconAlign.Left, classNames, + counter, + direction, + disabled, + dropdownMenuItems, + dropdownMenuProps, + iconProps, onClick, + role = 'menuitem', + secondaryButtonProps, + size = MenuSize.medium, + subText, tabIndex = 0, - value, - active, - counter, + text, type, + value, + variant = MenuVariant.neutral, + wrap = false, ...rest }) => { - const menuItemClasses: string = mergeClasses([ + const menuItemClassNames: string = mergeClasses([ styles.menuItem, { + [styles.menuItemRtl]: direction === 'rtl', + [styles.wrap]: !!wrap, [styles.small]: size === MenuSize.small, [styles.medium]: size === MenuSize.medium, [styles.large]: size === MenuSize.large, @@ -31,11 +43,12 @@ export const MenuItemButton: FC = ({ [styles.primary]: variant === MenuVariant.primary, [styles.disruptive]: variant === MenuVariant.disruptive, [styles.active]: active, + [styles.disabled]: disabled, }, classNames, ]); - const itemSubTextClasses: string = mergeClasses([ + const itemSubTextClassNames: string = mergeClasses([ styles.itemSubText, { [styles.small]: size === MenuSize.small, @@ -44,22 +57,102 @@ export const MenuItemButton: FC = ({ }, ]); - return ( + const handleOnClick = ( + event: React.MouseEvent + ): void => { + if (disabled) { + event.preventDefault(); + return; + } + onClick?.(value); + }; + + const getIcon = (): JSX.Element => ( + + ); + + const menuSizeToIconSizeMap: Map = new Map< + MenuSize, + IconSize + >([ + [MenuSize.large, IconSize.Large], + [MenuSize.medium, IconSize.Medium], + [MenuSize.small, IconSize.Small], + ]); + + const menuButton = (): JSX.Element => ( ); + + const secondaryButton = (): JSX.Element => ( + <> + + + + {counter && {counter}} + {secondaryButtonProps && ( + + )} + + + {subText && {subText}} + + ); + + const dropdownMenuButton = (): JSX.Element => ( + + {menuButton()} + + ); + + const renderedItem = (): JSX.Element => { + if (secondaryButtonProps) { + return secondaryButton(); + } + + return dropdownMenuItems ? dropdownMenuButton() : menuButton(); + }; + + return
  • {renderedItem()}
  • ; }; diff --git a/src/components/Menu/MenuItem/MenuItemCustom/MenuItemCustom.tsx b/src/components/Menu/MenuItem/MenuItemCustom/MenuItemCustom.tsx index ca96fb0cf..685bb5135 100644 --- a/src/components/Menu/MenuItem/MenuItemCustom/MenuItemCustom.tsx +++ b/src/components/Menu/MenuItem/MenuItemCustom/MenuItemCustom.tsx @@ -11,7 +11,7 @@ export const MenuItemCustom: FC = ({ size = MenuSize.medium, ...item }) => { - const menuItemClasses = mergeClasses([ + const menuItemClassNames: string = mergeClasses([ styles.menuItemCustom, { [styles.large]: size === MenuSize.large, @@ -21,7 +21,7 @@ export const MenuItemCustom: FC = ({ ]); return ( -
    +
    {item.render({ index, value: item, onChange })}
    ); diff --git a/src/components/Menu/MenuItem/MenuItemLink/MenuItemLink.tsx b/src/components/Menu/MenuItem/MenuItemLink/MenuItemLink.tsx index a1de6116a..3cf7dab04 100644 --- a/src/components/Menu/MenuItem/MenuItemLink/MenuItemLink.tsx +++ b/src/components/Menu/MenuItem/MenuItemLink/MenuItemLink.tsx @@ -1,27 +1,34 @@ import React, { FC } from 'react'; -import { MenuItemLinkProps } from '../MenuItem.types'; +import { MenuItemIconAlign, MenuItemLinkProps } from '../MenuItem.types'; import { Link } from '../../../Link'; import { mergeClasses } from '../../../../shared/utilities'; import { MenuSize, MenuVariant } from '../../Menu.types'; -import { Icon } from '../../../Icon'; +import { Icon, IconSize } from '../../../Icon'; import styles from '../menuItem.module.scss'; export const MenuItemLink: FC = ({ - variant = MenuVariant.neutral, - size = MenuSize.medium, active, + alignIcon = MenuItemIconAlign.Left, classNames, - text, - subText, - iconProps, counter, + direction, + disabled, + iconProps, + role = 'menuitem', + size = MenuSize.medium, + subText, + tabIndex = 0, + text, + variant = MenuVariant.neutral, + wrap = false, ...rest }) => { - const menuItemClasses: string = mergeClasses([ + const menuItemClassNames: string = mergeClasses([ styles.menuItem, - styles.menuLink, { + [styles.menuItemRtl]: direction === 'rtl', + [styles.wrap]: !!wrap, [styles.small]: size === MenuSize.small, [styles.medium]: size === MenuSize.medium, [styles.large]: size === MenuSize.large, @@ -29,11 +36,12 @@ export const MenuItemLink: FC = ({ [styles.primary]: variant === MenuVariant.primary, [styles.disruptive]: variant === MenuVariant.disruptive, [styles.active]: active, + [styles.disabled]: disabled, }, classNames, ]); - const itemSubTextClasses: string = mergeClasses([ + const itemSubTextClassNames: string = mergeClasses([ styles.itemSubText, { [styles.small]: size === MenuSize.small, @@ -42,16 +50,39 @@ export const MenuItemLink: FC = ({ }, ]); + const menuSizeToIconSizeMap: Map = new Map< + MenuSize, + IconSize + >([ + [MenuSize.large, IconSize.Large], + [MenuSize.medium, IconSize.Medium], + [MenuSize.small, IconSize.Small], + ]); + + const getIcon = (): JSX.Element => ( + + ); + return ( - - {iconProps && } - - - {text} - {counter && {counter}} +
  • + + {iconProps && alignIcon === MenuItemIconAlign.Left && getIcon()} + + + {text} + {counter && {counter}} + + {subText && {subText}} - {subText && {subText}} - - + {iconProps && alignIcon === MenuItemIconAlign.Right && getIcon()} + +
  • ); }; diff --git a/src/components/Menu/MenuItem/MenuItemSubHeader/MenuItemSubHeader.tsx b/src/components/Menu/MenuItem/MenuItemSubHeader/MenuItemSubHeader.tsx index 588b00fe9..c84478a87 100644 --- a/src/components/Menu/MenuItem/MenuItemSubHeader/MenuItemSubHeader.tsx +++ b/src/components/Menu/MenuItem/MenuItemSubHeader/MenuItemSubHeader.tsx @@ -6,16 +6,20 @@ import { MenuSize } from '../../Menu.types'; import styles from '../menuItem.module.scss'; export const MenuItemSubHeader: FC = ({ - text, + direction, size, + text, + wrap, }) => { - const subHeaderClasses: string = mergeClasses([ + const subHeaderClassNames: string = mergeClasses([ styles.menuItemSubHeader, { + [styles.menuItemRtl]: direction === 'rtl', + [styles.wrap]: !!wrap, [styles.large]: size === MenuSize.large, [styles.medium]: size === MenuSize.medium, [styles.small]: size === MenuSize.small, }, ]); - return {text}; + return {text}; }; diff --git a/src/components/Menu/MenuItem/menuItem.module.scss b/src/components/Menu/MenuItem/menuItem.module.scss index 83450053b..d2599b656 100644 --- a/src/components/Menu/MenuItem/menuItem.module.scss +++ b/src/components/Menu/MenuItem/menuItem.module.scss @@ -12,18 +12,57 @@ margin: $space-xxs 0; white-space: nowrap; + &.wrap { + white-space: normal; + } + $text-font-size-map: ( small: $text-font-size-1, medium: $text-font-size-2, large: $text-font-size-3, ); + .menu-item-button { + display: flex; + flex: 1; + gap: $space-xs; + + // Hides the browser default keyboard focus-visible styles. + // Use the ConfigProvider instead. + &:focus-visible { + outline: none; + } + } + .menu-item-wrapper { display: flex; flex-direction: column; flex: 1; } + .menu-secondary-wrapper { + display: flex; + justify-content: space-between; + flex: 1; + + .menu-inner-button { + align-items: center; + display: flex; + gap: $space-xs; + } + + .menu-outer-button { + align-items: center; + display: flex; + flex: inherit; + gap: $space-xs; + + &:focus-visible { + outline: none; + } + } + } + .item-text { display: flex; align-items: center; @@ -44,24 +83,39 @@ min-height: 44px; font-size: $text-font-size-4; line-height: $text-line-height-3; - padding: $button-padding-vertical-medium $button-padding-horizontal-medium; margin: 0 $space-xs $space-xs; + + .menu-item-button, + .menu-secondary-wrapper, + .menu-link { + padding: $button-padding-vertical-medium $button-padding-horizontal-medium; + } } &.medium { min-height: 36px; font-size: $text-font-size-3; line-height: $text-line-height-2; - padding: $button-padding-vertical-medium $button-padding-horizontal-medium; margin: 0 $space-xxs $space-xs; + + .menu-item-button, + .menu-secondary-wrapper, + .menu-link { + padding: $button-padding-vertical-medium $button-padding-horizontal-medium; + } } &.small { min-height: 28px; font-size: $text-font-size-2; line-height: $text-line-height-1; - padding: $space-xxs $space-xs; margin: 0 $space-xxs $space-xxs; + + .menu-item-button, + .menu-secondary-wrapper, + .menu-link { + padding: $space-xxs $space-xs; + } } &.primary { @@ -104,20 +158,40 @@ } } - &[disabled] { - pointer-events: none; + &.disabled { + cursor: not-allowed; opacity: $disabled-alpha-value; + pointer-events: none; + } + + &[disabled], + > [disabled] { cursor: not-allowed; + pointer-events: none; } - &.menu-link { + .menu-link { + align-items: inherit; + color: inherit; + display: inherit; + flex: 1; + font-size: inherit; + font-weight: inherit; + gap: $space-xs; text-decoration: none; + white-space: inherit; } .label { flex: 1; display: flex; - align-content: flex-start; + text-align: left; + } + + .action-wrapper { + align-items: center; + display: flex; + gap: $space-xxs; } &:first-child { @@ -128,11 +202,15 @@ margin-bottom: $space-xs; } - // Hides the browser default keyboard focus-visible styles. - // Use the ConfigProvider instead. &:focus-visible { outline: none; } + + &-rtl { + .label { + text-align: right; + } + } } .menu-item-sub-header { @@ -148,16 +226,19 @@ } &.large { + margin: 0 $space-m $space-m; padding: $space-s $space-xxs $space-s; @include octuple-content-large(); } &.medium { + margin: 0 $space-m $space-m; padding: $space-s $space-xxxs $space-s; @include octuple-content-medium(); } &.small { + margin: 0 $space-s $space-m; padding: $space-s $space-xxxs $space-s; @include octuple-content-small(); } @@ -181,27 +262,23 @@ :global(.focus-visible) { .menu-item { - &:focus-visible { - outline-offset: -1px; - outline-width: $space-xxxs; - outline-style: solid; - } - - &.primary { - &:focus-visible { - outline-color: var(--blueviolet-color-50); - } + &:focus-visible, + &:focus-within { + box-shadow: var(--focus-visible-box-shadow); } &.disruptive { - &:focus-visible { - outline-color: var(--blueviolet-color-50); + &:focus-visible, + &:focus-within { + background-color: var(--disruptive-color-10); + box-shadow: 0 0 0 var(--focus-visible-shadow-width) + var(--disruptive-color-80); } } - &.neutral { + .menu-link { &:focus-visible { - outline-color: var(--blueviolet-color-50); + box-shadow: none; } } } diff --git a/src/components/Menu/__snapshots__/CascadingMenu.test.tsx.snap b/src/components/Menu/__snapshots__/CascadingMenu.test.tsx.snap new file mode 100644 index 000000000..414f52bb4 --- /dev/null +++ b/src/components/Menu/__snapshots__/CascadingMenu.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Menu Should render a menu button 1`] = ` +
    + +
    +`; diff --git a/src/components/Menu/__snapshots__/Menu.test.tsx.snap b/src/components/Menu/__snapshots__/Menu.test.tsx.snap new file mode 100644 index 000000000..3188a3655 --- /dev/null +++ b/src/components/Menu/__snapshots__/Menu.test.tsx.snap @@ -0,0 +1,709 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Menu Menu is large 1`] = ` +
    +
    + + +
    +
    +`; + +exports[`Menu Menu is medium 1`] = ` +
    +
    + + +
    +
    +`; + +exports[`Menu Menu is small 1`] = ` +
    +
    + + +
    +
    +`; + +exports[`Menu Should render a menu button 1`] = ` +
    +
    + +
    +
    +`; diff --git a/src/components/Menu/index.ts b/src/components/Menu/index.ts index fd5be78b3..40a9b7ea7 100644 --- a/src/components/Menu/index.ts +++ b/src/components/Menu/index.ts @@ -1,3 +1,4 @@ +export * from './CascadingMenu'; export * from './Menu'; export * from './Menu.types'; export * from './MenuItem/MenuItem.types'; diff --git a/src/components/Menu/menu.module.scss b/src/components/Menu/menu.module.scss index 358e6dd78..8f1f2fa19 100644 --- a/src/components/Menu/menu.module.scss +++ b/src/components/Menu/menu.module.scss @@ -8,7 +8,7 @@ &.large { @include octuple-h4(); - padding: $space-l $space-l $space-xs; + padding: $space-ml $space-ml $space-xs; } &.medium { @@ -18,7 +18,7 @@ &.small { @include octuple-h6(); - padding: $space-m $space-m $space-xs; + padding: $space-s $space-s $space-xs; } .heading { diff --git a/src/components/Pagination/Pager.tsx b/src/components/Pagination/Pager.tsx index 63c113bcf..f9f5dda1f 100644 --- a/src/components/Pagination/Pager.tsx +++ b/src/components/Pagination/Pager.tsx @@ -1,5 +1,8 @@ import React, { FC, useEffect, useCallback, useState, Ref } from 'react'; -import { PagerProps } from './Pagination.types'; +import { + PagerProps, + PaginationVisiblePagerCountSizeOptions, +} from './Pagination.types'; import { ButtonShape, ButtonSize, NeutralButton } from '../Button'; import { IconName } from '../Icon'; import { mergeClasses } from '../../shared/utilities'; @@ -9,12 +12,16 @@ import styles from './pagination.module.scss'; /** Represents the number of pages from each edge of the list before we show quick buttons. */ const EDGE_BUFFER_THRESHOLD: number = 5; -/** Represents that only 7 list items (pages) are visible at any given time. */ -const PAGER_COUNT: number = 7; - /** Represents a list too short to display meaningful quick buttons. */ const SHORT_LIST_THRESHOLD: number = 10; +/** Represents the number of list items (pages) visible at any given time. */ +const VISIBLE_PAGER_COUNT = { + [PaginationVisiblePagerCountSizeOptions.Small]: 3, + [PaginationVisiblePagerCountSizeOptions.Medium]: 5, + [PaginationVisiblePagerCountSizeOptions.Large]: 7, +}; + export const Pager: FC = React.forwardRef( ( { @@ -26,6 +33,7 @@ export const Pager: FC = React.forwardRef( quickPreviousIconButtonAriaLabel, simplified = false, showLast = true, + visiblePagerCountSize = PaginationVisiblePagerCountSizeOptions.Large, ...rest }, ref: Ref @@ -35,6 +43,8 @@ export const Pager: FC = React.forwardRef( const [_quickPreviousActive, setQuickPreviousActive] = useState(false); + const visiblePagerCount = VISIBLE_PAGER_COUNT?.[visiblePagerCountSize] || 7; + /** * Updates the visible range of pages in the UL based upon list * traversal and other actions that trigger currentPage and pageCount updates. @@ -69,7 +79,7 @@ export const Pager: FC = React.forwardRef( * Only the quick previous button is visible. */ if (afterQuickPrevious && !beforeQuickNext) { - const startPage = pageCount - (PAGER_COUNT - 2); + const startPage = pageCount - (visiblePagerCount - 2); for (let i: number = startPage; i < pageCount; ++i) { array.push(i); @@ -83,7 +93,7 @@ export const Pager: FC = React.forwardRef( * Only the quick next button is visible. */ } else if (!afterQuickPrevious && beforeQuickNext) { - for (let i: number = 2; i < PAGER_COUNT; ++i) { + for (let i: number = 2; i < visiblePagerCount; ++i) { array.push(i); } @@ -94,7 +104,7 @@ export const Pager: FC = React.forwardRef( * position in the visible array. Both quick buttons are visible. */ } else if (afterQuickPrevious && beforeQuickNext) { - const offset = Math.floor(PAGER_COUNT / 2) - 1; + const offset = Math.floor(visiblePagerCount / 2) - 1; for ( let i: number = currentPage - offset; @@ -145,9 +155,11 @@ export const Pager: FC = React.forwardRef( text={'1'.toLocaleString()} /> ) : ( - {`${currentPage.toLocaleString()} ${ - locale.lang!.pagerText - }`} + <> + {`${currentPage.toLocaleString()}`}{' '} + {showLast && {`${locale.lang!.pagerText}`}}{' '} + {showLast && {`${pageCount.toLocaleString()}`}} + )} )} @@ -227,25 +239,21 @@ export const Pager: FC = React.forwardRef( /> )} - {pageCount > 1 && showLast && ( + {!simplified && pageCount > 1 && showLast && (
  • - {!simplified ? ( - onCurrentChange(pageCount)} - shape={ButtonShape.Rectangle} - size={ButtonSize.Medium} - text={pageCount.toLocaleString()} - /> - ) : ( - {pageCount.toLocaleString()} - )} + onCurrentChange(pageCount)} + shape={ButtonShape.Rectangle} + size={ButtonSize.Medium} + text={pageCount.toLocaleString()} + />
  • )} diff --git a/src/components/Pagination/Pagination.stories.tsx b/src/components/Pagination/Pagination.stories.tsx index 3ecde4abf..15341068a 100644 --- a/src/components/Pagination/Pagination.stories.tsx +++ b/src/components/Pagination/Pagination.stories.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { Stories } from '@storybook/addon-docs'; import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { Pagination, PaginationLayoutOptions } from './index'; +import { + Pagination, + PaginationLayoutOptions, + PaginationVisiblePagerCountSizeOptions, +} from './index'; export default { title: 'Pagination', @@ -109,7 +113,10 @@ const paginationArgs: Object = { ], pageSize: 10, pageSizes: [10, 20, 30, 40, 50, 100], + restrictPageSizesPropToSizesLayout: false, + hideWhenSinglePage: false, total: 50, + visiblePagerCountSize: PaginationVisiblePagerCountSizeOptions.Large, 'data-test-id': 'myPaginationTestId', }; diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx index da5af57d1..eabc01bbf 100644 --- a/src/components/Pagination/Pagination.tsx +++ b/src/components/Pagination/Pagination.tsx @@ -4,6 +4,7 @@ import { PaginationLayoutOptions, PaginationLocale, PaginationProps, + PaginationVisiblePagerCountSizeOptions, } from './index'; import { ButtonIconAlign, @@ -32,6 +33,7 @@ export const Pagination: FC = React.forwardRef( currentPage = 1, dots = false, goToText: defaultGoToText, + hideWhenSinglePage = false, layout = [ PaginationLayoutOptions.Previous, PaginationLayoutOptions.Pager, @@ -51,8 +53,10 @@ export const Pagination: FC = React.forwardRef( quickNextIconButtonAriaLabel: defaultQuickNextIconButtonAriaLabel, quickPreviousIconButtonAriaLabel: defaultQuickPreviousIconButtonAriaLabel, total = 1, + restrictPageSizesPropToSizesLayout = false, totalText: defaultTotalText, selfControlled = true, + visiblePagerCountSize = PaginationVisiblePagerCountSizeOptions.Large, 'data-test-id': dataTestId, ...rest } = props; @@ -138,9 +142,21 @@ export const Pagination: FC = React.forwardRef( useEffect((): void => { setTotal(total); - onSizeChangeHandler?.( - pageSizes.indexOf(pageSize) > -1 ? pageSize : pageSizes[0] - ); + if ( + restrictPageSizesPropToSizesLayout + ? layout.includes(PaginationLayoutOptions.Sizes) + : true + ) { + onSizeChangeHandler?.( + pageSizes.indexOf(pageSize) > -1 ? pageSize : pageSizes[0] + ); + } + if ( + restrictPageSizesPropToSizesLayout && + !layout.includes(PaginationLayoutOptions.Sizes) + ) { + setPageCount(Math.ceil(total / _pageSize)); + } jumpToPage?.(currentPage); }, []); @@ -386,25 +402,29 @@ export const Pagination: FC = React.forwardRef( showLast={ !layout.includes(PaginationLayoutOptions.NoLast) } + visiblePagerCountSize={visiblePagerCountSize} /> ) : ( - + (hideWhenSinglePage ? moreThanOnePage : true) && ( + + ) )} {layout.includes(PaginationLayoutOptions.Next) && moreThanOnePage && ( diff --git a/src/components/Pagination/Pagination.types.ts b/src/components/Pagination/Pagination.types.ts index f2fc8e9c1..75a687db7 100644 --- a/src/components/Pagination/Pagination.types.ts +++ b/src/components/Pagination/Pagination.types.ts @@ -54,6 +54,12 @@ export enum PaginationLayoutOptions { NoLast = 'noLast', } +export enum PaginationVisiblePagerCountSizeOptions { + Small = 'small', + Medium = 'medium', + Large = 'large', +} + export type PaginationLocale = { lang: Locale; }; @@ -88,6 +94,11 @@ export interface PaginationProps extends OcBaseProps { * @default 'Go to' */ goToText?: string; + /** + * Hide pagination when there is a single page. + * @default false + */ + hideWhenSinglePage?: boolean; /** * The Pagination layout options. * @default {PaginationLayoutOptions.Previous, PaginationLayoutOptions.Pager, PaginationLayoutOptions.Next} @@ -132,6 +143,7 @@ export interface PaginationProps extends OcBaseProps { pageSizeButtonAriaLabel?: string; /** * The Pagination pageSizes array. + * pageSizes should be defined when layout uses PaginationLayoutOptions.Sizes * @default {[10, 20, 30, 40, 50, 100]} */ pageSizes?: number[]; @@ -155,6 +167,12 @@ export interface PaginationProps extends OcBaseProps { * @default 'Previous 5' */ quickPreviousIconButtonAriaLabel?: string; + /** + * pageSizes should only be defined for Sizes Layout. + * Recommended to turn this on as this is going to default behavior in future + * @default false + */ + restrictPageSizesPropToSizesLayout?: boolean; /** * The Page change is controlled internally. * @default true @@ -171,7 +189,7 @@ export interface PaginationProps extends OcBaseProps { */ simplified?: boolean; /** - * The Pagination total number of pages. + * The Pagination total number of items. * @default 1 */ total: number; @@ -180,4 +198,9 @@ export interface PaginationProps extends OcBaseProps { * @default 'Total' */ totalText?: string; + /** + * Represents the number of list items (pages) are visible at any given time. + * @default PaginationVisiblePagerCountSizeOptions.Large + */ + visiblePagerCountSize?: PaginationVisiblePagerCountSizeOptions; } diff --git a/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap b/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap index 0ec18f666..189a2dc75 100644 --- a/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap +++ b/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap @@ -160,10 +160,13 @@ exports[`Pagination Pagination should render as simplified 1`] = ` >
  • - 1 of + 1 -
  • -
  • + + + of + + 5 diff --git a/src/components/Pagination/pagination.module.scss b/src/components/Pagination/pagination.module.scss index 36e3d6711..194719fab 100644 --- a/src/components/Pagination/pagination.module.scss +++ b/src/components/Pagination/pagination.module.scss @@ -95,7 +95,6 @@ .page-tracker { opacity: 50%; - padding-top: $space-xxs; } &.dots { diff --git a/src/components/Panel/Panel.test.tsx b/src/components/Panel/Panel.test.tsx index c2214d16f..d8f88154f 100644 --- a/src/components/Panel/Panel.test.tsx +++ b/src/components/Panel/Panel.test.tsx @@ -29,7 +29,7 @@ describe('Panel', () => { ); }); - test('panel visibility', () => { + test('Panel visibility', () => { expect(wrapper.hasClass('visible')).not.toEqual(true); wrapper.setProps({ visible: true, @@ -37,7 +37,7 @@ describe('Panel', () => { expect(wrapper.hasClass('visible')).toBe(false); }); - test('panel content', () => { + test('Panel content', () => { wrapper.setProps({ visible: true, title, @@ -47,7 +47,7 @@ describe('Panel', () => { expect(wrapper.find('.header').text()).toBe(title); }); - test('panel actions', () => { + test('Panel actions', () => { const onClose = jest.fn(); wrapper.setProps({ visible: true, @@ -65,7 +65,7 @@ describe('Panel', () => { expect(onClose).toHaveBeenCalledTimes(2); }); - test('panel header actions exist', () => { + test('Panel header actions exist', () => { wrapper.setProps({ visible: true, headerButtonProps: { @@ -91,15 +91,36 @@ describe('Panel', () => { expect(wrapper.find('.header-action-button-3').length).toBeTruthy(); }); - test('panel no body padding', () => { + test('Panel no body padding', () => { wrapper.setProps({ visible: true, bodyPadding: false, }); expect(wrapper.find('.no-body-padding').length).toBeTruthy(); + expect(wrapper.render()).toMatchSnapshot(); }); - test('panel overlay is hidden', () => { + test('Panel no header padding', () => { + wrapper.setProps({ + visible: true, + headerPadding: false, + }); + expect(wrapper.find('.no-header-padding').length).toBeTruthy(); + expect(wrapper.render()).toMatchSnapshot(); + }); + + test('Panel no body and header padding', () => { + wrapper.setProps({ + visible: true, + bodyPadding: false, + headerPadding: false, + }); + expect(wrapper.find('.no-body-padding').length).toBeTruthy(); + expect(wrapper.find('.no-header-padding').length).toBeTruthy(); + expect(wrapper.render()).toMatchSnapshot(); + }); + + test('Panel overlay is hidden', () => { wrapper.setProps({ visible: true, overlay: false, diff --git a/src/components/Panel/__snapshots__/Panel.test.tsx.snap b/src/components/Panel/__snapshots__/Panel.test.tsx.snap new file mode 100644 index 000000000..187f7b3b7 --- /dev/null +++ b/src/components/Panel/__snapshots__/Panel.test.tsx.snap @@ -0,0 +1,3355 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Panel Panel no body and header padding 1`] = ` +LoadedCheerio { + "0": Node { + "attribs": Object { + "aria-hidden": "false", + "class": "panel-backdrop visible", + }, + "children": Array [ + Node { + "attribs": Object { + "class": "panel no-body-padding no-header-padding right medium", + "style": "transform: translateX(0px);", + }, + "children": Array [ + Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + "style": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": Node { + "children": Array [ + [Circular], + ], + "name": "root", + "next": null, + "parent": null, + "prev": null, + "type": "root", + }, + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + }, + }, + "_root": LoadedCheerio { + "0": Node { + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "head", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object {}, + "children": Array [], + "name": "body", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object {}, + "children": Array [], + "name": "body", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "head", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "html", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "root", + "next": null, + "parent": null, + "prev": null, + "type": "root", + "x-mode": "quirks", + }, + "_root": [Circular], + "length": 1, + "options": Object { + "decodeEntities": true, + "xml": false, + }, + }, + "length": 1, + "options": Object { + "decodeEntities": true, + "xml": false, + }, +} +`; + +exports[`Panel Panel no body padding 1`] = ` +LoadedCheerio { + "0": Node { + "attribs": Object { + "aria-hidden": "false", + "class": "panel-backdrop visible", + }, + "children": Array [ + Node { + "attribs": Object { + "class": "panel no-body-padding right medium", + "style": "transform: translateX(0px);", + }, + "children": Array [ + Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + "style": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": Node { + "children": Array [ + [Circular], + ], + "name": "root", + "next": null, + "parent": null, + "prev": null, + "type": "root", + }, + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + }, + }, + "_root": LoadedCheerio { + "0": Node { + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "head", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object {}, + "children": Array [], + "name": "body", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object {}, + "children": Array [], + "name": "body", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "head", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "html", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "root", + "next": null, + "parent": null, + "prev": null, + "type": "root", + "x-mode": "quirks", + }, + "_root": [Circular], + "length": 1, + "options": Object { + "decodeEntities": true, + "xml": false, + }, + }, + "length": 1, + "options": Object { + "decodeEntities": true, + "xml": false, + }, +} +`; + +exports[`Panel Panel no header padding 1`] = ` +LoadedCheerio { + "0": Node { + "attribs": Object { + "aria-hidden": "false", + "class": "panel-backdrop visible", + }, + "children": Array [ + Node { + "attribs": Object { + "class": "panel no-header-padding right medium", + "style": "transform: translateX(0px);", + }, + "children": Array [ + Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + Node { + "attribs": Object { + "class": "footer", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "body", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "This is the panel body", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": Node { + "attribs": Object { + "class": "header", + }, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object { + "class": "header-buttons", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-disabled": "false", + "aria-label": "Close", + "class": "button button-neutral button-medium round-shape icon-left", + }, + "children": Array [ + Node { + "attribs": Object { + "aria-hidden": "false", + "class": "icon icon-wrapper", + "role": "presentation", + }, + "children": Array [ + Node { + "attribs": Object { + "role": "presentation", + "style": "width: 20px; height: 20px;", + "viewBox": "0 0 24 24", + }, + "children": Array [ + Node { + "attribs": Object { + "d": "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", + "style": "fill: currentColor;", + }, + "children": Array [], + "name": "path", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "d": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "d": undefined, + "style": undefined, + }, + }, + ], + "name": "svg", + "namespace": "http://www.w3.org/2000/svg", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + "x-attribsPrefix": Object { + "role": undefined, + "style": undefined, + "viewBox": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + "role": undefined, + }, + }, + ], + "name": "button", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-disabled": undefined, + "aria-label": undefined, + "class": undefined, + }, + }, + ], + "name": "span", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "class": undefined, + "style": undefined, + }, + "x-attribsPrefix": Object { + "class": undefined, + "style": undefined, + }, + }, + ], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": Node { + "children": Array [ + [Circular], + ], + "name": "root", + "next": null, + "parent": null, + "prev": null, + "type": "root", + }, + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-hidden": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-hidden": undefined, + "class": undefined, + }, + }, + "_root": LoadedCheerio { + "0": Node { + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [ + Node { + "attribs": Object {}, + "children": Array [], + "name": "head", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object {}, + "children": Array [], + "name": "body", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + Node { + "attribs": Object {}, + "children": Array [], + "name": "body", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": Node { + "attribs": Object {}, + "children": Array [], + "name": "head", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "html", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + ], + "name": "root", + "next": null, + "parent": null, + "prev": null, + "type": "root", + "x-mode": "quirks", + }, + "_root": [Circular], + "length": 1, + "options": Object { + "decodeEntities": true, + "xml": false, + }, + }, + "length": 1, + "options": Object { + "decodeEntities": true, + "xml": false, + }, +} +`; diff --git a/src/components/Panel/panel.module.scss b/src/components/Panel/panel.module.scss index 76a9dd7b7..3b10a0503 100644 --- a/src/components/Panel/panel.module.scss +++ b/src/components/Panel/panel.module.scss @@ -113,26 +113,41 @@ &.no-body-padding { padding: 0; + .footer { + padding: $space-m $space-xl $space-l $space-xl; + } + .header { padding: $space-l $space-xl $space-m $space-xl; } - .footer { - padding: $space-m $space-xl $space-l $space-xl; + &.no-header-padding { + .header { + padding: 0; + } } } &.no-header-padding { padding: 0; - .header { - padding: 0; - } + .body { padding: 0 $space-xl; } + .footer { padding: $space-m $space-xl $space-l $space-xl; } + + .header { + padding: 0; + } + + &.no-body-padding { + .body { + padding: 0; + } + } } } diff --git a/src/components/Popup/Popup.stories.tsx b/src/components/Popup/Popup.stories.tsx new file mode 100644 index 000000000..128ea3eb7 --- /dev/null +++ b/src/components/Popup/Popup.stories.tsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { Stories } from '@storybook/addon-docs'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { ButtonSize, PrimaryButton } from '../Button'; +import { Icon, IconName, IconSize } from '../Icon'; +import { Link } from '../Link'; +import { Stack } from '../Stack'; +import { Popup, PopupTheme } from './'; + +export default { + title: 'Popup', + parameters: { + docs: { + page: (): JSX.Element => ( +
    +
    +
    +

    Popups

    +

    + Derived from Tooltip, Popups may also provide actionable + elements like links and buttons. +

    +
    +
    + +
    +
    +
    + ), + }, + }, + argTypes: { + trigger: { + options: ['click', 'hover', 'contextmenu'], + control: { type: 'radio' }, + }, + placement: { + options: [ + 'top', + 'right', + 'bottom', + 'left', + 'top-start', + 'top-end', + 'right-start', + 'right-end', + 'bottom-start', + 'bottom-end', + 'left-start', + 'left-end', + ], + control: { type: 'select' }, + }, + height: { + control: { type: 'number' }, + }, + width: { + control: { type: 'number' }, + }, + positionStrategy: { + options: ['absolute', 'fixed'], + control: { type: 'inline-radio' }, + }, + theme: { + options: ['light', 'dark'], + control: { type: 'inline-radio' }, + }, + portal: { + options: [true, false], + control: { type: 'inline-radio' }, + }, + triggerAbove: { + options: [true, false], + control: { type: 'inline-radio' }, + }, + }, +} as ComponentMeta; + +const Popup_Story: ComponentStory = (args) => { + const [visible, setVisibility] = useState(false); + return ( + setVisibility(isVisible)}> + + + ); +}; + +export const Popups = Popup_Story.bind({}); + +Popups.args = { + offset: 8, + theme: PopupTheme.light, + content: ( + <> + + +
    +
    +
    Header 5
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. +

    + + + Learn more + + + +
    +
    +
    + + ), + placement: 'bottom-start', + disabled: false, + visibleArrow: true, + classNames: 'my-popup-class', + closeOnPopupClick: false, + openDelay: 0, + hideAfter: 200, + tabIndex: 0, + trigger: 'click', + triggerAbove: false, + positionStrategy: 'absolute', + portal: false, + portalId: 'my-portal-id', + portalRoot: null, +}; diff --git a/src/components/Popup/Popup.test.tsx b/src/components/Popup/Popup.test.tsx new file mode 100644 index 000000000..2ff0b5062 --- /dev/null +++ b/src/components/Popup/Popup.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import MatchMediaMock from 'jest-matchmedia-mock'; +import { Popup, PopupSize } from './'; +import { + fireEvent, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; + +Enzyme.configure({ adapter: new Adapter() }); + +let matchMedia: any; + +describe('Popup', () => { + beforeAll(() => { + matchMedia = new MatchMediaMock(); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + test('Popup shows and hides on hover when trigger is hover', async () => { + const { container } = render( + This is a popup.
  • } + trigger="hover" + > +
    test
    + + ); + fireEvent.mouseOver(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popup-1')); + expect(container.querySelector('.popup')).toBeTruthy(); + fireEvent.mouseOut(container.querySelector('.test-div')); + await waitForElementToBeRemoved(() => screen.getByTestId('popup-1')); + expect(container.querySelector('.popup')).toBeFalsy(); + }); + + test('Popup shows and hides on keydown enter and escape when trigger is hover', async () => { + const { container, getByText } = render( + This is a popup.
    } + trigger="hover" + > +
    + test +
    + + ); + const testDiv = getByText('test'); + testDiv.focus(); + fireEvent.keyDown(testDiv, { + key: 'Enter', + code: 'Enter', + keyCode: 13, + charCode: 13, + }); + await waitFor(() => screen.getByTestId('popup-2')); + expect(container.querySelector('.popup')).toBeTruthy(); + fireEvent.keyDown(testDiv, { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + await waitForElementToBeRemoved(() => screen.getByTestId('popup-2')); + expect(container.querySelector('.popup')).toBeFalsy(); + }); + + test('Popup shows and hides on click when trigger is click', async () => { + const { container } = render( + This is a popup.
    } + trigger="click" + > +
    test
    + + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popup-3')); + expect(container.querySelector('.popup')).toBeTruthy(); + fireEvent.click(container.querySelector('.test-div')); + await waitForElementToBeRemoved(() => screen.getByTestId('popup-3')); + expect(container.querySelector('.popup')).toBeFalsy(); + }); + + test('Popup uses custom width and height', async () => { + const { container } = render( + This is a popup.
    } + height={500} + width={500} + > +
    test
    + + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popup-4')); + expect(container.querySelector('.popup')).toBeTruthy(); + expect(container.querySelector('.popup').getAttribute('style')).toContain( + 'height: 500px' + ); + expect(container.querySelector('.popup').getAttribute('style')).toContain( + 'width: 500px' + ); + }); + + test('Popup is large', async () => { + const { container } = render( + This is a popup.
    } + > +
    test
    + + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popupLarge')); + expect(container.querySelector('.large')).toBeTruthy(); + }); + + test('Popup is medium', async () => { + const { container } = render( + This is a popup.} + > +
    test
    +
    + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popupMedium')); + expect(container.querySelector('.medium')).toBeTruthy(); + }); + + test('Popup is small', async () => { + const { container } = render( + This is a popup.} + > +
    test
    +
    + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popupSmall')); + expect(container.querySelector('.small')).toBeTruthy(); + }); + + test('Popup is portaled', async () => { + const { container } = render( + <> + This is a popup.} + > +
    test
    +
    + , + { container: document.body } + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popupPortaled-1')); + expect(container.querySelector('.popup')).toBeTruthy(); + }); + + test('Popup is portaled in a defined root element', async () => { + const { container } = render( + <> + This is a popup.} + > +
    test
    +
    + , + { container: document.body } + ); + fireEvent.click(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('popupPortaled-2')); + expect(container.querySelector('.popup')).toBeTruthy(); + }); +}); diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx new file mode 100644 index 000000000..b2e1fbe52 --- /dev/null +++ b/src/components/Popup/Popup.tsx @@ -0,0 +1,65 @@ +import React, { FC } from 'react'; +import { PopupProps, PopupRef, PopupSize, PopupTheme } from './Popup.types'; +import { Tooltip, TooltipSize, TooltipType } from '../Tooltip'; +import { useCanvasDirection } from '../../hooks/useCanvasDirection'; +import { mergeClasses, uniqueId } from '../../shared/utilities'; + +import styles from './popup.module.scss'; + +export const Popup: FC = React.forwardRef( + ( + { + classNames, + closeOnPopupClick = false, + closeOnReferenceClick = true, + id, + popupOnKeydown, + referenceOnClick, + referenceOnKeydown, + showPopup, + size = PopupSize.Medium, + tabIndex = 0, + trigger = 'click', + popupStyle, + ...rest + }, + ref: React.ForwardedRef + ) => { + const htmlDir: string = useCanvasDirection(); + const popupId: string = !!id ? id : uniqueId('popup-'); + const popupClassNames: string = mergeClasses([ + styles.popup, + { [styles.small]: size === PopupSize.Small }, + { [styles.medium]: size === PopupSize.Medium }, + { [styles.large]: size === PopupSize.Large }, + { [styles.popupRtl]: htmlDir === 'rtl' }, + classNames, + ]); + + const popupSizeToTooltipSizeMap = new Map([ + [PopupSize.Large, TooltipSize.Large], + [PopupSize.Medium, TooltipSize.Medium], + [PopupSize.Small, TooltipSize.Small], + ]); + + return ( + + ); + } +); diff --git a/src/components/Popup/Popup.types.ts b/src/components/Popup/Popup.types.ts new file mode 100644 index 000000000..69757c3ea --- /dev/null +++ b/src/components/Popup/Popup.types.ts @@ -0,0 +1,59 @@ +import { TooltipProps } from '../Tooltip'; + +export enum PopupSize { + Large = 'large', + Medium = 'medium', + Small = 'small', +} + +export enum PopupTheme { + light = 'light', + dark = 'dark', +} + +export interface PopupProps + extends Omit< + TooltipProps, + | 'closeOnTooltipClick' + | 'showTooltip' + | 'size' + | 'tooltipOnKeydown' + | 'tooltipStyle' + | 'type' + > { + /** + * Should close Popup on body click. + * @default false + */ + closeOnPopupClick?: boolean; + /** + * Callback executed on popup element keydown. + * @param event + * @returns (event: React.KeyboardEvent) => void + */ + popupOnKeydown?: (event: React.KeyboardEvent) => void; + /** + * The Popup style. + */ + popupStyle?: React.CSSProperties; + /** + * Callback to control the show/hide behavior of the Popup. + * triggered before the visible change + * @param show {boolean} + * @returns true or false. + */ + showPopup?: (show: boolean) => boolean; + /** + * Size of the Popup. + * @default PopupSize.Medium + */ + size?: PopupSize; +} + +export type PopupRef = { + /** + * Helper method to manually update the position + * of the Popup. + */ + update: () => void; +}; diff --git a/src/components/Popup/index.ts b/src/components/Popup/index.ts new file mode 100644 index 000000000..43e784536 --- /dev/null +++ b/src/components/Popup/index.ts @@ -0,0 +1,2 @@ +export * from './Popup.types'; +export * from './Popup'; diff --git a/src/components/Popup/popup.module.scss b/src/components/Popup/popup.module.scss new file mode 100644 index 000000000..cde76a404 --- /dev/null +++ b/src/components/Popup/popup.module.scss @@ -0,0 +1,28 @@ +.popup { + border-radius: $border-radius-m; + padding: 0; + text-align: left; + + // Popups may be larger than Tooltips + // Unset max-width and defer width to props if needed. + &.small { + max-width: unset; + width: 140px; + } + + &.medium { + max-width: unset; + width: 240px; + } + + &.large { + max-width: unset; + width: 360px; + } +} + +.popup-rtl { + &.popup { + text-align: right; + } +} diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 324f720a8..9926d2a9c 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -23,8 +23,9 @@ const defaultOptions: SelectOption[] = [ }, { iconProps: { path: IconName.mdiFlagVariant }, - text: 'Supercalifragilisticexpialidocious', + text: 'Supercalifragilisticexpialidocious and another Supercalifragilisticexpialidocious', value: 'verylarge', + wrap: true, }, { iconProps: { path: IconName.mdiAccount }, @@ -153,6 +154,7 @@ export type SelectStory = ComponentStory>; export const Basic: SelectStory = Basic_Story.bind({}); export const Dynamic_Width: SelectStory = Basic_Story.bind({}); export const With_DefaultValue: SelectStory = Basic_Story.bind({}); +export const With_DefaultValueMultiple: SelectStory = Basic_Story.bind({}); export const Disabled: SelectStory = Basic_Story.bind({}); export const With_Clear: SelectStory = Basic_Story.bind({}); export const Options_Disabled: SelectStory = Basic_Story.bind({}); @@ -189,6 +191,12 @@ With_DefaultValue.args = { defaultValue: 'hat', }; +With_DefaultValueMultiple.args = { + ...Basic.args, + defaultValue: ['date', 'account', 'hat'], + multiple: true, +}; + Disabled.args = { ...With_DefaultValue.args, disabled: true, diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index 91fdaf9dc..1dbb23f81 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import Enzyme, { mount } from 'enzyme'; +import Enzyme from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; import MatchMediaMock from 'jest-matchmedia-mock'; import { SelectShape, SelectSize } from './Select.types'; -import { Select, SelectOption } from './'; -import { fireEvent, render } from '@testing-library/react'; +import { Select } from './'; import { sleep } from '../../tests/Utilities'; +import { fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; Enzyme.configure({ adapter: new Adapter() }); @@ -41,32 +42,284 @@ describe('Select', () => { await sleep(); } - const defaultOptions: SelectOption[] = [ - { - text: 'School', - value: 'school', - }, + const ANIMATION_DURATION: number = 200; + + const options = [ + { text: 'Option 1', value: 'option1' }, + { text: 'Option 2', value: 'option2' }, + { text: 'Option 3', value: 'option3' }, ]; - test('Select clearable', async () => { + test('Renders without crashing', () => { + const { container, getAllByPlaceholderText } = render( + ); + expect( + container.querySelector('.select-input').getAttribute('readonly') + ).toBeFalsy(); + }); + + test('Opens the dropdown when clicked', async () => { + const { getByPlaceholderText, getByText } = render( + + ); + const select = getByPlaceholderText('Select test'); + fireEvent.click(select); + await sleep(ANIMATION_DURATION); + const option = getByText('Option 1'); + fireEvent.click(option); + expect(handleChange).toHaveBeenCalledWith( + ['option1'], + [ + { + hideOption: false, + id: 'Option 1-0', + object: undefined, + selected: true, + text: 'Option 1', + value: 'option1', + }, + ] + ); + }); + + test('Selects multiple options', async () => { + const handleChange = jest.fn(); + const { getByPlaceholderText, getByText } = render( + + ); + const select = getByDisplayValue('Option 2'); + expect(() => container).not.toThrowError(); + expect(select).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('Updates the selected value', async () => { + const defaultValue = 'option2'; + const handleChange = jest.fn(); + const { getByPlaceholderText, getByText } = render( + + ); + const option2 = getByText('Option 2'); + const option3 = getByText('Option 3'); + expect(() => container).not.toThrowError(); + expect(option2).toBeTruthy(); + expect(option3).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + test('Updates the selected values when multiple', async () => { + const defaultValue = ['option2', 'option3']; + const handleChange = jest.fn(); + const { container, getByText } = render( + + ); + const clearButton = container.querySelector('.clear-icon-button'); + expect(clearButton).toBeTruthy(); + }); + + test('Handles clearing the selected value', async () => { + const defaultValue = 'option2'; + const handleChange = jest.fn(); + const { container, getByPlaceholderText } = render( + ); + await change(container, 'Option 2'); expect( (container.querySelector('.select-input') as HTMLInputElement).value - ).toBe('School'); + ).toBe('Option 2'); fireEvent.click(container.querySelector('.clear-icon-button')); expect( (container.querySelector('.select-input') as HTMLInputElement).value ).toBe(''); + expect(container.querySelector('.clear-icon-button')).toBeFalsy(); }); test('Select backspace clearable', async () => { @@ -100,12 +353,15 @@ describe('Select', () => { fireEvent.keyUp(element, sharedEventConfig); }; - const { container } = render( + ); + await change(container, 'Option 2'); expect( (container.querySelector('.select-input') as HTMLInputElement).value - ).toBe('School'); - let count = 6; + ).toBe('Option 2'); + let count = 8; do { backspace(container.querySelector('.select-input') as HTMLInputElement); } while (count--); @@ -114,44 +370,143 @@ describe('Select', () => { ).toBe(''); }); + test('Renders as disabled', () => { + const { container } = render( + ); + const select = getByPlaceholderText('Select test'); + fireEvent.click(select); + await sleep(ANIMATION_DURATION); + expect(container.querySelector('.dropdown')).toBeFalsy(); + expect(handleChange).not.toHaveBeenCalled(); + }); + + test('Renders with all options initially visible', async () => { + const { getAllByRole, getByPlaceholderText } = render( + + ); + const select = getByPlaceholderText('Select test'); + fireEvent.click(select); + userEvent.type(select, 'Option 1'); + await sleep(ANIMATION_DURATION); + const option1 = getByText('Option 1'); + const option2 = queryByText('Option 2'); + const option3 = queryByText('Option 3'); + expect(option1).toBeTruthy(); + expect(option2).toBeFalsy(); + expect(option3).toBeFalsy(); + }); + + test('Calls onFocus and onBlur callbacks when Select is focused and blurred', () => { + const handleFocus = jest.fn(); + const handleBlur = jest.fn(); + const { getByPlaceholderText } = render( + + ); + + expect( + (container.querySelector('.select-input') as HTMLInputElement).value + ).toBe(''); + }); + + test('Sets the input element autocomplete attribute to the specified value', () => { + const { container } = render( + + ); + + const input = container.querySelector('.select-input'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + test('Select is large', () => { const { container } = render( - ); expect(container.firstChild).toMatchSnapshot(); }); test('Select is medium', () => { const { container } = render( - ); expect(container.firstChild).toMatchSnapshot(); }); test('Select is small', () => { const { container } = render( - ); expect(container.firstChild).toMatchSnapshot(); }); test('Select is rectangle shaped', () => { const { container } = render( - ); expect(container.firstChild).toMatchSnapshot(); }); test('Select is pill shaped', () => { const { container } = render( - ); expect(container.firstChild).toMatchSnapshot(); }); test('Select is underline shaped', () => { const { container } = render( - ); expect(container.firstChild).toMatchSnapshot(); }); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index ecf0f6841..2dbbd111d 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -44,6 +44,7 @@ const multiSelectCountOffset: number = +styles.multiSelectCountOffset; export const Select: FC = React.forwardRef( ( { + autocomplete, classNames, clearable = false, configContextProps = { @@ -64,7 +65,10 @@ export const Select: FC = React.forwardRef( loadOptions, menuProps = {}, multiple = false, + onBlur, onClear, + onFocus, + onKeyDown, onOptionsChange, options: _options = [], pillProps = {}, @@ -101,6 +105,7 @@ export const Select: FC = React.forwardRef( selected: false, hideOption: false, id: option.text + '-' + index, + object: option.object, ...option, })) ); @@ -124,19 +129,23 @@ export const Select: FC = React.forwardRef( ? size : contextuallySized || size; - const getSelectedOptions = (): SelectOption['value'][] => { + const getSelectedOptionValues = (): SelectOption['value'][] => { return options .filter((option: SelectOption) => option.selected) .map((option: SelectOption) => option.value); }; + const getSelectedOptions = (): SelectOption['value'][] => { + return options.filter((option: SelectOption) => option.selected); + }; + const { count, filled, width } = useMaxVisibleSections( inputRef, pillRefs, 168, 8, 1, - getSelectedOptions().length + getSelectedOptionValues().length ); useEffect(() => { @@ -148,20 +157,24 @@ export const Select: FC = React.forwardRef( selected: !!selected.find((opt) => opt.value === option.value), hideOption: false, id: option.text + index, + object: option.object, ...option, })) ); }, [_options, isLoading]); useEffect(() => { - onOptionsChange?.(getSelectedOptions()); - }, [getSelectedOptions().join('')]); + onOptionsChange?.(getSelectedOptionValues(), getSelectedOptions()); + }, [getSelectedOptionValues().join('')]); useEffect(() => { - const updatedOptions = options.map((opt: SelectOption) => ({ + const updatedOptions = options.map((opt) => ({ ...opt, selected: - (defaultValue !== undefined && opt.value === defaultValue) || + (defaultValue !== undefined && + (multiple + ? defaultValue.includes(opt.value) + : opt.value === defaultValue)) || opt.selected, })); setOptions(updatedOptions); @@ -336,7 +349,7 @@ export const Select: FC = React.forwardRef( const isPillEllipsisActive = (element: HTMLElement) => { const labelElement: HTMLSpanElement = - element.firstElementChild as HTMLSpanElement; + element?.firstElementChild as HTMLSpanElement; return labelElement?.offsetWidth < labelElement?.scrollWidth; }; @@ -356,6 +369,8 @@ export const Select: FC = React.forwardRef( classNames={styles.selectTooltip} content={value.text} disabled={!isPillEllipsisActive(document?.getElementById(value.id))} + id={`selectTooltip${index}`} + key={`select-tooltip-${index}`} placement={'top'} theme={TooltipTheme.dark} > @@ -364,6 +379,7 @@ export const Select: FC = React.forwardRef( id={value.id} classNames={pillClasses} disabled={mergedDisabled} + key={`select-pill-${index}`} label={value.text} onClose={() => toggleOption(value)} size={selectSizeToPillSizeMap.get(size)} @@ -381,6 +397,7 @@ export const Select: FC = React.forwardRef( = React.forwardRef( { +export interface SelectProps + extends Omit, 'onFocus' | 'onBlur'> { + /** + * Indicates the autocomplete attribute value for the Select input field. + * @default undefined + */ + autocomplete?: string; /** * Whether the select text input is clearable. * @default false @@ -49,7 +59,7 @@ export interface SelectProps extends OcBaseProps { * The select default value. * @default '' */ - defaultValue?: string; + defaultValue?: string | string[]; /** * The select disabled state. * @default false @@ -104,15 +114,28 @@ export interface SelectProps extends OcBaseProps { * @default false */ multiple?: boolean; + /** + * The Select onBlur event handler. + */ + onBlur?: React.FocusEventHandler; /** * Callback called when the clear button is clicked. */ onClear?: () => void; + /** + * The Select onFocus event handler. + */ + onFocus?: React.FocusEventHandler; + /** + * The Select onKeyDown event handler. + */ + onKeyDown?: React.KeyboardEventHandler; /** * Callback called when options are selected/unselected. - * @param options {SelectOption[]} + * @param values {SelectOption['value'].value[]} + * @param options {SelectOption['value'][]} */ - onOptionsChange?: (options: SelectOption[]) => void; + onOptionsChange?: (values: SelectOption[], options?: SelectOption[]) => void; /** * The select options. * @default [] diff --git a/src/components/Select/__snapshots__/Select.test.tsx.snap b/src/components/Select/__snapshots__/Select.test.tsx.snap index d2775bb68..3977036d5 100644 --- a/src/components/Select/__snapshots__/Select.test.tsx.snap +++ b/src/components/Select/__snapshots__/Select.test.tsx.snap @@ -1,5 +1,287 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Select Renders with default value 1`] = ` +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`Select Renders with default values when multiple 1`] = ` +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`Select Renders without crashing 1`] = ` +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + exports[`Select Select is large 1`] = `
    = (args) => { }; return ( - + - +
    {transientSlidingAValue}
    @@ -61,7 +61,7 @@ const Slider_Story: ComponentStory = (args) => { /> - +
    {transientSlidingBValue}
    @@ -85,13 +85,13 @@ const Range_Slider_Story: ComponentStory = (args) => { }; return ( - + - +
    {transientSlidingAValues[0]}
    {transientSlidingAValues[1]}
    @@ -109,7 +109,7 @@ const Range_Slider_Story: ComponentStory = (args) => { /> - +
    {transientSlidingBValues[0]}
    {transientSlidingBValues[1]}
    @@ -134,13 +134,13 @@ const Inline_Extemity_Labels_Story: ComponentStory = (args) => { }; return ( - + - +
    {transientSlidingAValue}
    = (args) => { style={{ marginTop: 100 }} value={transientSlidingBValues} /> - +
    {transientSlidingBValues[0]}
    {transientSlidingBValues[1]}
    @@ -175,7 +175,7 @@ const Custom_Markers_Included_Story: ComponentStory = (args) => { }; return ( - + = (args) => { }; return ( - + - +
    {transientSlidingAValue}
    @@ -289,17 +289,17 @@ const Toggle_Thumb_Story: ComponentStory = (args) => { }; return ( - + - +
    {transientSlidingAValue}
    - + = (args) => { }; return ( - + = (args) => { onChange={handleChange} value={transientSlidingValue} /> - +
    {transientSlidingValue}
    @@ -842,7 +842,7 @@ const Data_Inactive_Story: ComponentStory = (args) => { }; return ( - + = (args) => { onChange={handleChangeB} value={transientSlidingBValues} /> - +
    {transientSlidingBValues[0]}
    {transientSlidingBValues[1]}
    @@ -954,7 +954,7 @@ const Data_Active_Story: ComponentStory = (args) => { name: 'blue', }} > - +
    = (args) => { value={targetSlidingValue} />
    - +
    {transientSlidingValues[0]}
    {transientSlidingValues[1]}
    - + = React.forwardRef( thumbRadius: number ): number => { const inputWidth = sliderRef.current?.offsetWidth || 0; - return ( + return Math.floor( ((val - mergedMin) / (mergedMax - mergedMin)) * (inputWidth - thumbDiameter) + - thumbRadius + thumbRadius ); }; @@ -351,27 +351,28 @@ export const Slider: FC = React.forwardRef( } if (!isRange) { - const lowerLabelOffset: number = - lowerLabelRef.current?.offsetWidth / 2 - - sliderRef.current?.offsetLeft; + const lowerLabelOffset: number = Math.floor( + lowerLabelRef.current?.offsetWidth / 2 - sliderRef.current?.offsetLeft + ); const showMarkerOffest: number = showMarkers === true ? thumbGeometry().showMarkerOffset : 0; if (htmlDir === 'rtl') { - lowerLabelRef.current.style.right = `${ + lowerLabelRef.current.style.right = `${Math.floor( lowerThumbOffset - lowerLabelOffset - showMarkerOffest - }px`; + )}px`; lowerLabelRef.current.style.left = 'unset'; } else { - lowerLabelRef.current.style.left = `${ + lowerLabelRef.current.style.left = `${Math.floor( lowerThumbOffset - lowerLabelOffset - showMarkerOffest - }px`; + )}px`; lowerLabelRef.current.style.right = 'unset'; } } else { if (labelPosition === 'inline') { - const sliderLabelsOffset: number = - sliderLabelsRef.current.offsetWidth / 2; + const sliderLabelsOffset: number = Math.floor( + sliderLabelsRef.current.offsetWidth / 2 + ); if (htmlDir === 'rtl') { sliderLabelsRef.current.style.marginRight = `-${sliderLabelsOffset}px`; @@ -439,7 +440,7 @@ export const Slider: FC = React.forwardRef( ? `${lowerThumbOffset}px` : `${thumbRadius}px`; } - trackRef.current.style.width = `${rangeWidth}px`; + trackRef.current.style.width = `${Math.floor(rangeWidth)}px`; }; const [formatValue] = useOffset(mergedMin, mergedMax, mergedStep, markers); diff --git a/src/components/Slider/Slider.types.ts b/src/components/Slider/Slider.types.ts index 4e4e6ee1d..ac50b244a 100644 --- a/src/components/Slider/Slider.types.ts +++ b/src/components/Slider/Slider.types.ts @@ -50,13 +50,6 @@ export enum SliderTrackStatus { Warning = 'warning', } -export enum MarkerType { - Benchmark = 'benchmark', - Origin = 'origin', - Delta = 'delta', - Target = 'target', -} - export interface Marker { /** * Custom Marker class names. @@ -70,10 +63,6 @@ export interface Marker { * Custom Marker style. */ style?: React.CSSProperties; - /** - * The marker type. - */ - type?: boolean; } export interface SliderMarker extends Marker { diff --git a/src/components/Slider/slider.module.scss b/src/components/Slider/slider.module.scss index 9a11e8600..fc8d837e5 100644 --- a/src/components/Slider/slider.module.scss +++ b/src/components/Slider/slider.module.scss @@ -9,6 +9,10 @@ $large-track-height: 6px; $large-vertical-center: calc($large-slider-height / 2); $large-rail-top: $large-vertical-center - calc($large-track-height / 2); $large-marker-top: 0; +$large-max-label-offset: 18px; +$large-max-label-offset-with-steps: 22px; +$large-min-label-offset: 10px; +$large-min-label-offset-with-steps: 6px; $medium-slider-height: 22px; $medium-slider-inline-margin: $space-xs; @@ -18,6 +22,10 @@ $medium-track-height: 4px; $medium-vertical-center: calc($medium-slider-height / 2); $medium-rail-top: $medium-vertical-center - calc($medium-track-height / 2); $medium-marker-top: 0; +$medium-max-label-offset: 13px; +$medium-max-label-offset-with-steps: 17px; +$medium-min-label-offset: 8px; +$medium-min-label-offset-with-steps: 4px; $small-slider-height: 16px; $small-slider-inline-margin: $space-xs; @@ -26,6 +34,10 @@ $small-track-height: 4px; $small-vertical-center: calc($small-slider-height / 2); $small-rail-top: $small-vertical-center - calc($small-track-height / 2); $small-marker-top: 0; +$small-max-label-offset: 9px; +$small-max-label-offset-with-steps: 11px; +$small-min-label-offset: 5px; +$small-min-label-offset-with-steps: 3px; // Export values for typescript consumption. :export { @@ -263,11 +275,13 @@ $small-marker-top: 0; opacity: 0; &.max-label { - right: calc($medium-slider-height / 2); + right: $medium-max-label-offset; + width: $space-xs; } &.min-label { - left: calc($medium-slider-height / 2); + left: $medium-min-label-offset; + width: $space-xs; } &-inline { @@ -392,6 +406,16 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + right: $medium-max-label-offset-with-steps; + } + + &.min-label { + left: $medium-min-label-offset-with-steps; + } + } + .thumb { margin-left: -$medium-thumb-offset; } @@ -407,6 +431,16 @@ $small-marker-top: 0; flex: 1; } + .extremity-label { + &.max-label { + width: auto; + } + + &.min-label { + width: auto; + } + } + .slider-range-labels { bottom: 0; left: 50%; @@ -479,11 +513,11 @@ $small-marker-top: 0; font-size: $text-font-size-3; &.max-label { - right: calc($large-slider-height / 2); + right: $large-max-label-offset; } &.min-label { - left: calc($large-slider-height / 2); + left: $large-min-label-offset; } &-inline { @@ -552,6 +586,16 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + right: $large-max-label-offset-with-steps; + } + + &.min-label { + left: $large-min-label-offset-with-steps; + } + } + .thumb { margin-left: -$space-xxs; } @@ -624,11 +668,11 @@ $small-marker-top: 0; font-size: $text-font-size-2; &.max-label { - right: calc($medium-slider-height / 2); + right: $medium-max-label-offset; } &.min-label { - left: calc($medium-slider-height / 2); + left: $medium-min-label-offset; } &-inline { @@ -697,6 +741,16 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + right: $medium-max-label-offset-with-steps; + } + + &.min-label { + left: $medium-min-label-offset-with-steps; + } + } + .thumb { margin-left: -$medium-thumb-offset; } @@ -772,11 +826,11 @@ $small-marker-top: 0; font-size: $text-font-size-1; &.max-label { - right: calc($small-slider-height / 2); + right: $small-max-label-offset; } &.min-label { - left: calc($small-slider-height / 2); + left: $small-min-label-offset; } &-inline { @@ -847,6 +901,16 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + right: $small-max-label-offset-with-steps; + } + + &.min-label { + left: $small-min-label-offset-with-steps; + } + } + .thumb { margin-left: -$space-xxxs; } @@ -887,12 +951,12 @@ $small-marker-top: 0; .extremity-label { &.max-label { right: unset; - left: calc($medium-slider-height / 2); + left: $medium-max-label-offset; } &.min-label { left: unset; - right: calc($medium-slider-height / 2); + right: $medium-min-label-offset; } &-inline { @@ -923,6 +987,18 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + right: unset; + left: $medium-max-label-offset-with-steps; + } + + &.min-label { + left: unset; + right: $medium-min-label-offset-with-steps; + } + } + .thumb { margin-left: unset; margin-right: -$medium-thumb-offset; @@ -963,12 +1039,12 @@ $small-marker-top: 0; .extremity-label { &.max-label { right: unset; - left: calc($large-slider-height / 2); + left: $large-max-label-offset; } &.min-label { left: unset; - right: calc($large-slider-height / 2); + right: $large-min-label-offset; } &-inline { @@ -999,6 +1075,18 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + left: $large-max-label-offset-with-steps; + right: unset; + } + + &.min-label { + left: unset; + right: $large-min-label-offset-with-steps; + } + } + .thumb { margin-left: unset; margin-right: -$space-xxs; @@ -1033,12 +1121,12 @@ $small-marker-top: 0; .extremity-label { &.max-label { right: unset; - left: calc($medium-slider-height / 2); + left: $medium-max-label-offset; } &.min-label { left: unset; - right: calc($medium-slider-height / 2); + right: $medium-min-label-offset; } &-inline { @@ -1069,6 +1157,18 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + left: $medium-max-label-offset-with-steps; + right: unset; + } + + &.min-label { + left: unset; + right: $medium-min-label-offset-with-steps; + } + } + .thumb { margin-left: unset; margin-right: -$medium-thumb-offset; @@ -1103,12 +1203,12 @@ $small-marker-top: 0; .extremity-label { &.max-label { right: unset; - left: calc($small-slider-height / 2); + left: $small-max-label-offset; } &.min-label { left: unset; - right: calc($small-slider-height / 2); + right: $small-min-label-offset; } &-inline { @@ -1139,6 +1239,18 @@ $small-marker-top: 0; } } + .extremity-label { + &.max-label { + left: $small-max-label-offset-with-steps; + right: unset; + } + + &.min-label { + left: unset; + right: $small-min-label-offset-with-steps; + } + } + .thumb { margin-left: unset; margin-right: -$space-xxxs; diff --git a/src/components/Stepper/Stepper.tsx b/src/components/Stepper/Stepper.tsx index 976da646c..ed7e96c98 100644 --- a/src/components/Stepper/Stepper.tsx +++ b/src/components/Stepper/Stepper.tsx @@ -219,12 +219,16 @@ export const Stepper: FC = React.forwardRef( setNextDisabled( htmlDir === 'rtl' ? steps?.scrollLeft === steps?.offsetWidth - steps?.scrollWidth - : steps?.scrollLeft === steps?.scrollWidth - steps?.offsetWidth + : Math.abs( + steps?.scrollWidth - steps?.offsetWidth - steps?.scrollLeft + ) < 1 ); setPreviousDisabled(steps?.scrollLeft === 0); } else { setNextDisabled( - steps?.scrollTop === steps?.scrollHeight - steps?.offsetHeight + Math.abs( + steps?.scrollHeight - steps?.offsetHeight - steps?.scrollTop + ) < 1 ); setPreviousDisabled(steps?.scrollTop === 0); } diff --git a/src/components/Table/Internal/Body/Scroller.tsx b/src/components/Table/Internal/Body/Scroller.tsx index aac4f9bea..46f2003f6 100644 --- a/src/components/Table/Internal/Body/Scroller.tsx +++ b/src/components/Table/Internal/Body/Scroller.tsx @@ -10,11 +10,11 @@ import { ButtonShape, ButtonSize, SecondaryButton } from '../../../Button'; import { IconName } from '../../../Icon'; import { ColumnType } from '../../Table.types'; import { ScrollerProps, ScrollerRef } from '../OcTable.types'; -import { useDebounce } from '../../../../hooks/useDebounce'; import styles from '../octable.module.scss'; const BUTTON_HEIGHT: number = 36; +const BUTTON_PADDING: number = 2; export const Scroller = React.forwardRef( ( @@ -24,6 +24,7 @@ export const Scroller = React.forwardRef( scrollBodyRef, stickyOffsets, scrollHeaderRef, + titleRef, scrollLeftAriaLabelText, scrollRightAriaLabelText, }: ScrollerProps, @@ -32,8 +33,8 @@ export const Scroller = React.forwardRef( const [visible, setVisible] = useState(false); const [leftButtonVisible, setLeftButtonVisible] = useState(false); const [rightButtonVisible, setRightButtonVisible] = useState(true); - const [buttonStyle, setButtonStyle] = useState({}); - + const [hoveredRowBoundingRect, setHoveredRowBoundingRect] = + useState(null); // todo @yash: handle rtl const scrollOffsets: number[] = useMemo( @@ -68,54 +69,30 @@ export const Scroller = React.forwardRef( [stickyOffsets, flattenColumns] ); - const computePosition = useCallback((): void => { + const getButtonTop = (): number => { if (!scrollBodyRef.current) { - return; + return 0; } - const { - height: scrollBodyHeight, - top: scrollBodyTop, - bottom: scrollBodyBottom, - } = scrollBodyRef.current.getBoundingClientRect(); + const { top: scrollBodyTop } = + scrollBodyRef.current.getBoundingClientRect(); + const { height: titleHeight = 0 } = + titleRef.current?.getBoundingClientRect?.() || {}; + + const { top: rowTop, height: rowHeight } = hoveredRowBoundingRect ?? {}; const { height: stickyHeaderHeight = 0 } = scrollHeaderRef?.current?.getBoundingClientRect?.() || {}; - const { height: viewportHeight } = document.body.getBoundingClientRect(); - - let buttonTop: number = 0; - - if (scrollBodyTop > 0) { - // When the top of the table is in the viewport - - if (scrollBodyBottom > viewportHeight) { - // When bottom of the table is out of the viewport - buttonTop = (viewportHeight - scrollBodyTop) / 2; - } else if (scrollBodyBottom < viewportHeight) { - // When full table is in the viewport - buttonTop = scrollBodyHeight / 2; - } - } else if (scrollBodyTop < 0) { - // When the top of the table is out the viewport - - if (scrollBodyBottom > viewportHeight) { - // When bottom of the table is out of the viewport - buttonTop = Math.abs(scrollBodyTop) + viewportHeight / 2; - } else if (scrollBodyBottom < viewportHeight) { - // When bottom of the table is in the viewport - buttonTop = - Math.abs(scrollBodyTop) + - (viewportHeight - (viewportHeight - scrollBodyBottom)) / 2; - } - } - setButtonStyle({ - top: buttonTop + stickyHeaderHeight - BUTTON_HEIGHT / 2, - }); - }, []); - - const debouncedComputePosition = useDebounce(computePosition, 500); + return ( + rowTop - + scrollBodyTop + + stickyHeaderHeight + + rowHeight / 2 - + BUTTON_HEIGHT / 2 + + titleHeight + ); + }; const onMouseEnter = useCallback((): void => { setVisible(true); - computePosition(); }, []); const onMouseLeave = useCallback((): void => setVisible(false), []); @@ -141,10 +118,18 @@ export const Scroller = React.forwardRef( }); }; + const noScroller = (): boolean => { + return ( + scrollBodyRef?.current?.clientWidth >= + scrollBodyRef?.current?.scrollWidth + ); + }; + const onBodyScroll = (): void => { const bodyScrollLeft: number = scrollBodyRef.current.scrollLeft; const bodyWidth: number = scrollBodyRef.current.clientWidth; const bodyScrollWidth: number = scrollBodyRef.current.scrollWidth; + if (bodyScrollLeft === 0) { setLeftButtonVisible(false); setRightButtonVisible(true); @@ -160,14 +145,13 @@ export const Scroller = React.forwardRef( useImperativeHandle(ref, () => ({ onBodyScroll, + onRowHover: setHoveredRowBoundingRect, })); useEffect(() => { - document.addEventListener('scroll', debouncedComputePosition); scrollBodyRef.current?.addEventListener?.('mouseenter', onMouseEnter); scrollBodyRef.current?.addEventListener?.('mouseleave', onMouseLeave); return () => { - document.removeEventListener('scroll', debouncedComputePosition); scrollBodyRef.current?.removeEventListener?.( 'mouseenter', onMouseEnter @@ -179,14 +163,16 @@ export const Scroller = React.forwardRef( }; }, []); + if (noScroller()) return null; + return ( <> { - return
    {children}
    ; -}; +export const FrameWrapper = React.forwardRef( + ( + { classNames, children }: FrameWrapperProps, + ref: ForwardedRef + ) => { + return ( +
    + {children} +
    + ); + } +); diff --git a/src/components/Table/Internal/OcTable.tsx b/src/components/Table/Internal/OcTable.tsx index 4913924cd..f8e043e86 100644 --- a/src/components/Table/Internal/OcTable.tsx +++ b/src/components/Table/Internal/OcTable.tsx @@ -272,6 +272,7 @@ function OcTable( const scrollHeaderRef = useRef(); const scrollBodyRef = useRef(); const scrollSummaryRef = useRef(); + const titleRef = useRef(null); const scrollerRef = useRef(null); const [pingedLeft, setPingedLeft] = useState(false); const [pingedRight, setPingedRight] = useState(false); @@ -495,7 +496,15 @@ function OcTable( return emptyText; }, [hasData, emptyText]); - // Body + const _onRowHoverEnter = useCallback( + (index: number, key: Key, event: React.MouseEvent) => { + const hoveredCell = event.target as HTMLElement; + scrollerRef.current?.onRowHover?.(hoveredCell.getBoundingClientRect()); + onRowHoverEnter?.(index, key, event); + }, + [] + ); + const bodyTable = ( ( onRow={onRow} emptyNode={emptyNode} childrenColumnName={mergedChildrenColumnName} - onRowHoverEnter={onRowHoverEnter} + onRowHoverEnter={_onRowHoverEnter} onRowHoverLeave={onRowHoverLeave} /> ); @@ -561,6 +570,7 @@ function OcTable( scrollBodyRef={scrollBodyRef} stickyOffsets={stickyOffsets} scrollHeaderRef={scrollHeaderRef} + titleRef={titleRef} scrollLeftAriaLabelText={scrollLeftAriaLabelText} scrollRightAriaLabelText={scrollRightAriaLabelText} /> @@ -666,6 +676,7 @@ function OcTable( ref={scrollerRef} {...columnContext} scrollBodyRef={scrollBodyRef} + titleRef={titleRef} stickyOffsets={stickyOffsets} scrollLeftAriaLabelText={scrollLeftAriaLabelText} scrollRightAriaLabelText={scrollRightAriaLabelText} @@ -730,7 +741,7 @@ function OcTable( props={{ ...props, stickyOffsets, mergedExpandedKeys }} > {title && ( - + {title(mergedData)} )} diff --git a/src/components/Table/Internal/OcTable.types.ts b/src/components/Table/Internal/OcTable.types.ts index a7ceac859..5adf84d22 100644 --- a/src/components/Table/Internal/OcTable.types.ts +++ b/src/components/Table/Internal/OcTable.types.ts @@ -417,6 +417,10 @@ export interface ScrollerProps { scrollBodyRef: RefObject; stickyOffsets: StickyOffsets; scrollHeaderRef?: RefObject; + /** + * Ref of the table title + */ + titleRef?: RefObject; /** * The Table scroller right button aria label * @default 'Scroll right' @@ -434,6 +438,10 @@ export type ScrollerRef = { * Helper method to handle body scroll changes */ onBodyScroll: () => void; + /** + * Helper method triggered on hover of a row + */ + onRowHover: (boundingRect: DOMRect) => void; }; export interface OcTableProps { diff --git a/src/components/Tooltip/Tooltip.stories.tsx b/src/components/Tooltip/Tooltip.stories.tsx index 61d31c364..771bea9cd 100644 --- a/src/components/Tooltip/Tooltip.stories.tsx +++ b/src/components/Tooltip/Tooltip.stories.tsx @@ -81,6 +81,10 @@ export default { }, }, argTypes: { + trigger: { + options: ['click', 'hover', 'contextmenu'], + control: { type: 'radio' }, + }, placement: { options: [ 'top', @@ -110,6 +114,10 @@ export default { options: [true, false], control: { type: 'inline-radio' }, }, + triggerAbove: { + options: [true, false], + control: { type: 'inline-radio' }, + }, }, } as ComponentMeta; @@ -128,8 +136,10 @@ Tooltips.args = { visibleArrow: true, classNames: 'my-tooltip-class', openDelay: 0, - hideAfter: 0, + hideAfter: 200, tabIndex: 0, + trigger: 'hover', + triggerAbove: false, positionStrategy: 'absolute', portal: false, portalId: 'my-portal-id', @@ -141,4 +151,5 @@ Tooltips.args = { text="Show Tooltip" /> ), + height: null, }; diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx index 1ce0f4697..ac58d72de 100644 --- a/src/components/Tooltip/Tooltip.test.tsx +++ b/src/components/Tooltip/Tooltip.test.tsx @@ -24,9 +24,12 @@ describe('Tooltip', () => { matchMedia.clear(); }); - test('Tooltip shows and hides', async () => { + test('Tooltip shows and hides on hover', async () => { const { container } = render( - This is a tooltip.
    }> + This is a tooltip.
    } + trigger="hover" + >
    test
    ); @@ -38,6 +41,44 @@ describe('Tooltip', () => { expect(container.querySelector('.tooltip')).toBeFalsy(); }); + test('Tooltip shows and hides on focus and blur', async () => { + const { container } = render( + This is a tooltip.
    } + trigger="hover" + > +
    test
    + + ); + fireEvent.focus(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('tooltip')); + expect(container.querySelector('.tooltip')).toBeTruthy(); + fireEvent.blur(container.querySelector('.test-div')); + await waitForElementToBeRemoved(() => screen.getByTestId('tooltip')); + expect(container.querySelector('.tooltip')).toBeFalsy(); + }); + + test('Tooltip uses custom width and height', async () => { + const { container } = render( + This is a tooltip.
    } + height={500} + width={500} + > +
    test
    +
    + ); + fireEvent.mouseOver(container.querySelector('.test-div')); + await waitFor(() => screen.getByTestId('tooltip')); + expect(container.querySelector('.tooltip')).toBeTruthy(); + expect(container.querySelector('.tooltip').getAttribute('style')).toContain( + 'height: 500px' + ); + expect(container.querySelector('.tooltip').getAttribute('style')).toContain( + 'width: 500px' + ); + }); + test('Tooltip is large', async () => { const { container } = render( = ({ - children, - offset = 8, - theme, - content, - placement = 'bottom', - portal = false, - portalId, - portalRoot, - disabled, - id, - visibleArrow = true, - classNames, - openDelay = 0, - hideAfter = 0, - tabIndex = 0, - tooltipStyle, - positionStrategy = 'absolute', - wrapperClassNames, - wrapperStyle, - size = TooltipSize.Small, - ...rest -}) => { - const tooltipSide: string = placement.split('-')?.[0]; - const [visible, setVisible] = useState(false); - const arrowRef = useRef(null); - const tooltipId = useRef(id || generateId()); - let timeout: ReturnType; - - const { - x, - y, - reference, - floating, - strategy, - update, - refs, - middlewareData: { arrow: { x: arrowX, y: arrowY } = {} }, - } = useFloating({ - placement, - strategy: positionStrategy, - middleware: [ - shift(), - arrow({ element: arrowRef, padding: TOOLTIP_ARROW_WIDTH }), - fOffset(offset), - ], - }); - - useEffect(() => { - if (!refs.reference.current || !refs.floating.current) { - return () => {}; - } +export const Tooltip: FC = React.memo( + React.forwardRef( + ( + { + children, + classNames, + closeOnOutsideClick = true, + closeOnReferenceClick = true, + closeOnTooltipClick = false, + content, + disabled, + height, + hideAfter = ANIMATION_DURATION, + id, + minHeight, + offset = TOOLTIP_DEFAULT_OFFSET, + onClickOutside, + onVisibleChange, + openDelay = 0, + placement = 'bottom', + portal = false, + portalId, + portalRoot, + positionStrategy = 'absolute', + referenceOnClick, + referenceOnKeydown, + showTooltip, + size = TooltipSize.Small, + tabIndex = 0, + theme, + tooltipOnKeydown, + tooltipStyle, + trigger = 'hover', + triggerAbove = false, + type = TooltipType.Default, + visible, + visibleArrow = true, + width, + wrapperClassNames, + wrapperStyle, + ...rest + }, + ref: React.ForwardedRef + ) => { + const tooltipSide: string = placement.split('-')?.[0]; + const [mergedVisible, setVisible] = useMergedState(false, { + value: visible, + }); + const arrowRef: React.MutableRefObject = + useRef(null); - // Only call this when the floating element is rendered - return autoUpdate(refs.reference.current, refs.floating.current, update); - }, [refs.reference, refs.floating, update]); - - const toggle: Function = - (show: boolean): Function => - (): void => { - if (!content || disabled) { - return; - } - timeout && clearTimeout(timeout); - timeout = setTimeout( - () => { - setVisible(show); - }, - show ? openDelay : hideAfter + const [hiding, setHiding] = useState(false); + const tooltipId: React.MutableRefObject = useRef( + id || uniqueId('tooltip-') + ); + const tooltipReferenceId: React.MutableRefObject = useRef( + `${tooltipId?.current}-reference` ); - }; - - const tooltipClasses: string = mergeClasses([ - classNames, - styles.tooltip, - { [styles.visible]: visible }, - { [styles.dark]: theme === TooltipTheme.dark }, - { [styles.top]: tooltipSide === 'top' }, - { [styles.bottom]: tooltipSide === 'bottom' }, - { [styles.left]: tooltipSide === 'left' }, - { [styles.right]: tooltipSide === 'right' }, - { [styles.small]: size === TooltipSize.Small }, - { [styles.medium]: size === TooltipSize.Medium }, - { [styles.large]: size === TooltipSize.Large }, - ]); - - const referenceWrapperClasses: string = mergeClasses([ - styles.referenceWrapper, - wrapperClassNames, - { [styles.disabled]: disabled }, - ]); - - const staticSide: string = { - top: 'bottom', - right: 'left', - bottom: 'top', - left: 'right', - }[tooltipSide]; - - const tooltipStyles: object = { - position: strategy, - top: y ?? '', - left: x ?? '', - ...tooltipStyle, - }; - - const arrowStyle: object = { - position: strategy, - top: arrowY ?? '', - left: arrowX ?? '', - [staticSide]: `-${TOOLTIP_ARROW_WIDTH / 2}px`, - }; - - const getChild = (node: React.ReactNode): JSX.Element | React.ReactNode => { - // Need this to handle disabled elements. - if (React.isValidElement(node) && node.props?.disabled) { - const child = React.Children.only(node) as React.ReactElement; - return cloneElement(child, { - classNames: styles.noPointerEvents, + + let timeout: ReturnType; + const { + x, + y, + reference, + floating, + strategy, + update, + refs, + middlewareData: { arrow: { x: arrowX, y: arrowY } = {} }, + context, + } = useFloating({ + placement, + strategy: positionStrategy, + middleware: [ + shift(), + arrow({ + element: arrowRef, + padding: visibleArrow ? TOOLTIP_ARROW_WIDTH : 0, + }), + fOffset(offset), + ], }); - } - return node; - }; - - return ( - <> -
    - {getChild(children)} -
    - ( - - {getChild(children)} - - )} - > - {visible && ( + + const toggle: Function = + (show: boolean, showTooltip = (show: boolean) => show): Function => + (e: SyntheticEvent): void => { + if (!content || disabled) { + return; + } + // to control the toggle behaviour + const updatedShow: boolean = showTooltip(show); + if (PREVENT_DEFAULT_TRIGGERS.includes(trigger)) { + e.preventDefault(); + } + setHiding(!updatedShow); + timeout && clearTimeout(timeout); + timeout = setTimeout( + () => { + setVisible(updatedShow); + onVisibleChange?.(updatedShow); + }, + show ? openDelay : hideAfter + ); + }; + + useEffect(() => { + if (!refs.reference.current || !refs.floating.current) { + return () => {}; + } + + // Only call this when the floating element is rendered + return autoUpdate( + refs.reference.current, + refs.floating.current, + update + ); + }, [refs.reference, refs.floating, update]); + + useImperativeHandle(ref, () => ({ + update, + })); + + useOnClickOutside( + refs.floating, + (e) => { + const referenceElement: HTMLElement = document.getElementById( + tooltipReferenceId?.current + ); + if (closeOnOutsideClick && closeOnReferenceClick) { + toggle(false)(e); + } + if ( + !closeOnReferenceClick && + !referenceElement.contains(e.target as Node) + ) { + toggle(false)(e); + } + onClickOutside?.(e); + }, + mergedVisible + ); + + const handleReferenceClick = (event: React.MouseEvent): void => { + event.stopPropagation(); + if (disabled) { + return; + } + timeout && clearTimeout(timeout); + timeout = setTimeout(() => { + if (mergedVisible && closeOnReferenceClick) { + toggle(false)(event); + } else { + toggle(true)(event); + } + }, hideAfter); + referenceOnClick?.(event); + }; + + const handleReferenceKeyDown = (event: React.KeyboardEvent): void => { + event.stopPropagation(); + if (disabled) { + return; + } + if ( + event?.key === eventKeys.ENTER && + document.activeElement === event.target + ) { + timeout && clearTimeout(timeout); + timeout = setTimeout(() => { + if (mergedVisible && closeOnReferenceClick) { + toggle(false)(event); + } else { + toggle(true)(event); + } + }, hideAfter); + } + if ( + event?.key === eventKeys.ESCAPE || + (event?.key === eventKeys.TAB && event.shiftKey) + ) { + toggle(false)(event); + } + referenceOnKeydown?.(event); + }; + + const handleFloatingKeyDown = (event: React.KeyboardEvent): void => { + event.stopPropagation(); + if (event?.key === eventKeys.ESCAPE) { + toggle(false)(event); + } + if (event?.key === eventKeys.TAB) { + timeout && clearTimeout(timeout); + timeout = setTimeout(() => { + if (!refs.floating.current.matches(':focus-within')) { + toggle(false)(event); + } + }, NO_ANIMATION_DURATION); + } + if (event?.key === eventKeys.TAB && event.shiftKey) { + timeout && clearTimeout(timeout); + timeout = setTimeout(() => { + if (refs.floating.current.matches(':focus-within')) { + toggle(true)(event); + } + }, NO_ANIMATION_DURATION); + } + tooltipOnKeydown?.(event); + }; + + // The placement type contains both `Side` and `Alignment`, joined by `-`. + // e.g. placement: `${Side}-{Alignment}` + const tooltipClassNames: string = mergeClasses([ + styles.tooltip, + { [styles.popup]: type === TooltipType.Popup }, + classNames, + { [styles.visibleArrow]: !!visibleArrow }, + { [styles.visible]: mergedVisible }, + { [styles.hiding]: hiding }, + { [styles.hidden]: !mergedVisible }, + { [styles.dark]: theme === TooltipTheme.dark }, + { [styles.bottom]: placement === 'bottom' }, + { [styles.bottomEnd]: placement === 'bottom-end' }, + { [styles.bottomStart]: placement === 'bottom-start' }, + { [styles.left]: placement === 'left' }, + { [styles.leftEnd]: placement === 'left-end' }, + { [styles.leftStart]: placement === 'left-start' }, + { [styles.right]: placement === 'right' }, + { [styles.rightEnd]: placement === 'right-end' }, + { [styles.rightStart]: placement === 'right-start' }, + { [styles.top]: placement === 'top' }, + { [styles.topEnd]: placement === 'top-end' }, + { [styles.topStart]: placement === 'top-start' }, + { [styles.small]: size === TooltipSize.Small }, + { [styles.medium]: size === TooltipSize.Medium }, + { [styles.large]: size === TooltipSize.Large }, + ]); + + const referenceWrapperClassNames: string = mergeClasses([ + styles.referenceWrapper, + wrapperClassNames, + { [styles.disabled]: disabled }, + ]); + + // Only use the `placement` type's `Side` property to determine `staticSide`. + const staticSide: string = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[tooltipSide]; + + const tooltipStyles: object = { + position: strategy, + top: Math.floor(y) ?? '', + left: Math.floor(x) ?? '', + width: width ?? '', + height: height ?? '', + minHeight: minHeight ?? '', + ...tooltipStyle, + }; + + const arrowStyle: object = { + position: strategy, + top: arrowY ?? '', + left: arrowX ?? '', + [staticSide]: `-${TOOLTIP_ARROW_WIDTH / 2}px`, + }; + + const getDefaultReferenceElement = ( + node: React.ReactNode + ): JSX.Element | React.ReactNode => { + if (React.isValidElement(node)) { + const child = React.Children.only(node) as React.ReactElement; + + // Need this to handle disabled elements of default Tooltip. + if (type === TooltipType.Default) { + if (node.props?.disabled) { + return cloneElement(child, { + className: styles.disabled, + }); + } + } + } + return node; + }; + + const getPopupReferenceElement = ( + node: React.ReactNode + ): JSX.Element | React.ReactNode => { + if (React.isValidElement(node)) { + const child = React.Children.only(node) as React.ReactElement; + + // Utilize tha same element clone pattern as Dropdown + // for more complex Popup elements. + const referenceWrapperClassNames: string = mergeClasses([ + styles.referenceWrapper, + { [styles.triggerAbove]: !!triggerAbove }, + // Add any classnames added to the reference element + { [child.props.className]: child.props.className }, + { [styles.disabled]: disabled }, + ]); + return cloneElement(child, { + ...{ + [TRIGGER_TO_HANDLER_MAP_ON_ENTER[trigger]]: toggle(true), + }, + id: tooltipReferenceId?.current, + key: tooltipId?.current, + onClick: handleReferenceClick, + onKeyDown: handleReferenceKeyDown, + className: referenceWrapperClassNames, + 'aria-controls': tooltipId?.current, + 'aria-expanded': mergedVisible, + 'aria-haspopup': true, + role: 'button', + tabIndex: `${tabIndex}`, + }); + } + return node; + }; + + const getTooltip = (): JSX.Element => + mergedVisible && ( + ( + + {children} + + )} + > +