From 8b2044d97b7ebb67143e4cdcd6917c0198336be7 Mon Sep 17 00:00:00 2001 From: Conrad Chan Date: Wed, 12 May 2021 13:33:21 -0700 Subject: [PATCH] feat(settings): Add Dropdown component (#1373) * feat(settings): Add Checkbox and Listbox components * refactor(controls): Move list logic into SettingsList * chore: pr comments * chore: pr comments + updated tests * chore: add unit tests * chore: pr comments Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../hooks/__tests__/useClickOutside-test.tsx | 51 +++++ .../viewers/controls/hooks/useClickOutside.ts | 19 ++ .../viewers/controls/settings/Settings.tsx | 35 ++- .../controls/settings/SettingsContext.ts | 1 - .../controls/settings/SettingsDropdown.scss | 85 ++++++++ .../controls/settings/SettingsDropdown.tsx | 116 ++++++++++ .../controls/settings/SettingsFlyout.scss | 2 +- .../controls/settings/SettingsFlyout.tsx | 13 +- .../controls/settings/SettingsList.tsx | 54 +++++ .../controls/settings/SettingsMenu.tsx | 39 +--- .../settings/__tests__/Settings-test.tsx | 11 + .../__tests__/SettingsDropdown-test.tsx | 201 ++++++++++++++++++ .../__tests__/SettingsFlyout-test.tsx | 8 +- .../settings/__tests__/SettingsList-test.tsx | 104 +++++++++ 14 files changed, 676 insertions(+), 63 deletions(-) create mode 100644 src/lib/viewers/controls/hooks/__tests__/useClickOutside-test.tsx create mode 100644 src/lib/viewers/controls/hooks/useClickOutside.ts create mode 100644 src/lib/viewers/controls/settings/SettingsDropdown.scss create mode 100644 src/lib/viewers/controls/settings/SettingsDropdown.tsx create mode 100644 src/lib/viewers/controls/settings/SettingsList.tsx create mode 100644 src/lib/viewers/controls/settings/__tests__/SettingsDropdown-test.tsx create mode 100644 src/lib/viewers/controls/settings/__tests__/SettingsList-test.tsx diff --git a/src/lib/viewers/controls/hooks/__tests__/useClickOutside-test.tsx b/src/lib/viewers/controls/hooks/__tests__/useClickOutside-test.tsx new file mode 100644 index 000000000..872a6dc91 --- /dev/null +++ b/src/lib/viewers/controls/hooks/__tests__/useClickOutside-test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import noop from 'lodash/noop'; +import { mount, ReactWrapper } from 'enzyme'; +import useClickOutside from '../useClickOutside'; + +describe('useClickOutside', () => { + function TestComponent({ callback = noop }: { callback?: () => void }): JSX.Element { + const ref = React.createRef(); + + useClickOutside(ref, callback); + + return ( +
+ +
+ ); + } + + const getWrapper = (props: { callback?: () => void }): ReactWrapper => + mount( +
+ +
, + { attachTo: document.getElementById('test') }, + ); + + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + test.each` + elementId | isCalled + ${'test'} | ${true} + ${'container'} | ${true} + ${'test-button'} | ${false} + ${'test-span'} | ${false} + `('should callback be called if click target is $elementId? $isCalled', ({ elementId, isCalled }) => { + const callback = jest.fn(); + getWrapper({ callback }); + + const element: HTMLElement | null = document.getElementById(elementId); + if (element) { + element.click(); + } + + expect(element).toBeDefined(); + expect(callback.mock.calls.length).toBe(isCalled ? 1 : 0); + }); +}); diff --git a/src/lib/viewers/controls/hooks/useClickOutside.ts b/src/lib/viewers/controls/hooks/useClickOutside.ts new file mode 100644 index 000000000..7bb80c57c --- /dev/null +++ b/src/lib/viewers/controls/hooks/useClickOutside.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; + +export default function useClickOutside(element: React.RefObject | null, callback: () => void): void { + React.useEffect(() => { + const handleDocumentClick = ({ target }: MouseEvent): void => { + if (element && element.current && element.current.contains(target as Node)) { + return; + } + + callback(); + }; + + document.addEventListener('click', handleDocumentClick); + + return (): void => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [callback, element]); +} diff --git a/src/lib/viewers/controls/settings/Settings.tsx b/src/lib/viewers/controls/settings/Settings.tsx index 36d8a7b62..2557c0759 100644 --- a/src/lib/viewers/controls/settings/Settings.tsx +++ b/src/lib/viewers/controls/settings/Settings.tsx @@ -2,12 +2,14 @@ import React from 'react'; import classNames from 'classnames'; import SettingsCheckboxItem from './SettingsCheckboxItem'; import SettingsContext, { Menu, Rect } from './SettingsContext'; +import SettingsDropdown from './SettingsDropdown'; import SettingsFlyout from './SettingsFlyout'; import SettingsGearToggle, { Ref as SettingsToggleRef } from './SettingsToggle'; import SettingsMenu from './SettingsMenu'; import SettingsMenuBack from './SettingsMenuBack'; import SettingsMenuItem from './SettingsMenuItem'; import SettingsRadioItem from './SettingsRadioItem'; +import useClickOutside from '../hooks/useClickOutside'; import { decodeKeydown } from '../../../util'; export type Props = React.PropsWithChildren<{ @@ -33,6 +35,7 @@ export default function Settings({ setIsFocused(false); setIsOpen(false); }, []); + const { height, width } = activeRect || { height: 'auto', width: 'auto' }; const handleClick = (): void => { setActiveMenu(Menu.MAIN); @@ -59,23 +62,7 @@ export default function Settings({ event.stopPropagation(); }; - React.useEffect(() => { - const handleDocumentClick = ({ target }: MouseEvent): void => { - const { current: controlsEl } = controlsElRef; - - if (controlsEl && controlsEl.contains(target as Node)) { - return; - } - - resetControls(); - }; - - document.addEventListener('click', handleDocumentClick); - - return (): void => { - document.removeEventListener('click', handleDocumentClick); - }; - }, [resetControls]); + useClickOutside(controlsElRef, resetControls); return (
- - - {children} + + + + {children} +
); @@ -95,6 +89,7 @@ export default function Settings({ Settings.CheckboxItem = SettingsCheckboxItem; Settings.Context = SettingsContext; +Settings.Dropdown = SettingsDropdown; Settings.Menu = SettingsMenu; Settings.MenuBack = SettingsMenuBack; Settings.MenuItem = SettingsMenuItem; diff --git a/src/lib/viewers/controls/settings/SettingsContext.ts b/src/lib/viewers/controls/settings/SettingsContext.ts index d21655f08..608fc95d9 100644 --- a/src/lib/viewers/controls/settings/SettingsContext.ts +++ b/src/lib/viewers/controls/settings/SettingsContext.ts @@ -3,7 +3,6 @@ import noop from 'lodash/noop'; export type Context = { activeMenu: Menu; - activeRect?: Rect; setActiveMenu: (menu: Menu) => void; setActiveRect: (activeRect: Rect) => void; }; diff --git a/src/lib/viewers/controls/settings/SettingsDropdown.scss b/src/lib/viewers/controls/settings/SettingsDropdown.scss new file mode 100644 index 000000000..5b34e6741 --- /dev/null +++ b/src/lib/viewers/controls/settings/SettingsDropdown.scss @@ -0,0 +1,85 @@ +@import './styles'; + +$bp-SettingsDropdown-spacing: 5px; + +.bp-SettingsDropdown { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + color: $bdl-gray-62; +} + +.bp-SettingsDropdown-flyout { + top: initial; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + flex: 1 1 auto; + padding: 8px 0; + transform: translateY(100%); +} + +.bp-SettingsDropdown-button { + position: relative; + height: 34px; + margin: $bp-SettingsDropdown-spacing 0; + padding: $bp-SettingsDropdown-spacing 0 $bp-SettingsDropdown-spacing $bp-SettingsDropdown-spacing * 2; + color: #808080; + font-weight: normal; + font-size: 13px; + letter-spacing: 1px; + text-align: left; + background-color: $white; + border: 1px solid $sf-fog; + border-radius: 2px; + cursor: pointer; + transition: background-color .05s ease-in-out, border-color .05s ease-in-out; + + &:hover, + &:focus { + background-color: #f7f7f7; + } + + &::after { + position: absolute; + top: 15px; + right: 11px; + width: 0; + height: 0; + border-top: 3px solid $better-black; + border-right: 3px solid transparent; + border-left: 3px solid transparent; + transform: rotate(0); + transition: transform 200ms linear; + content: ''; + } + + &.bp-is-open { + &::after { + transform: rotate(-180deg); + } + } +} + +.bp-SettingsDropdown-label { + margin: 4px 0; +} + +.bp-SettingsDropdown-listitem { + display: block; + padding: $bp-SettingsDropdown-spacing 35px $bp-SettingsDropdown-spacing 15px; + color: $better-black; + line-height: 20px; + outline: none; + + &.bp-is-active, + &:hover, + &:focus { + color: $dark-cerulean; + background: $hover-blue-background; + cursor: pointer; + fill: $dark-cerulean; + } +} diff --git a/src/lib/viewers/controls/settings/SettingsDropdown.tsx b/src/lib/viewers/controls/settings/SettingsDropdown.tsx new file mode 100644 index 000000000..42ce9b087 --- /dev/null +++ b/src/lib/viewers/controls/settings/SettingsDropdown.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import classNames from 'classnames'; +import uniqueId from 'lodash/uniqueId'; +import SettingsFlyout from './SettingsFlyout'; +import SettingsList from './SettingsList'; +import useClickOutside from '../hooks/useClickOutside'; +import { decodeKeydown } from '../../../util'; +import './SettingsDropdown.scss'; + +export type ListItem = { + label: string; + value: string; +}; + +export type Props = { + className?: string; + label: string; + listItems: Array; + onSelect: (value: string) => void; + value?: string; +}; + +export default function SettingsDropdown({ className, label, listItems, onSelect, value }: Props): JSX.Element { + const { current: id } = React.useRef(uniqueId('bp-SettingsDropdown_')); + const buttonElRef = React.useRef(null); + const dropdownElRef = React.useRef(null); + const listElRef = React.useRef(null); + const [isOpen, setIsOpen] = React.useState(false); + + const handleKeyDown = (event: React.KeyboardEvent): void => { + const key = decodeKeydown(event); + + if (key === 'Escape') { + setIsOpen(false); + + if (buttonElRef.current) { + buttonElRef.current.focus(); // Prevent focus from falling back to the body on flyout close + } + } + + if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'Escape') { + // Prevent the event from bubbling up and triggering any upstream keydown handling logic + event.stopPropagation(); + } + }; + const handleSelect = (selectedOption: string): void => { + setIsOpen(false); + onSelect(selectedOption); + }; + const createClickHandler = (selectedOption: string) => (event: React.MouseEvent): void => { + handleSelect(selectedOption); + + // Prevent the event from bubbling up and triggering any upstream click handling logic, + // i.e. if the dropdown is nested inside a menu flyout + event.stopPropagation(); + }; + const createKeyDownHandler = (selectedOption: string) => (event: React.KeyboardEvent): void => { + const key = decodeKeydown(event); + + if (key !== 'Space' && key !== 'Enter') { + return; + } + + handleSelect(selectedOption); + }; + + useClickOutside(dropdownElRef, () => setIsOpen(false)); + + return ( +
+
+ {label} +
+ + + + {listItems.map(({ label: itemLabel, value: itemValue }) => { + return ( +
+ {itemLabel} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/lib/viewers/controls/settings/SettingsFlyout.scss b/src/lib/viewers/controls/settings/SettingsFlyout.scss index 6025772b4..5aa9c25de 100644 --- a/src/lib/viewers/controls/settings/SettingsFlyout.scss +++ b/src/lib/viewers/controls/settings/SettingsFlyout.scss @@ -10,7 +10,7 @@ overflow-x: hidden; overflow-y: auto; // Prevent scrollbar from showing on hover in IE/Edge color: $fours; - font-size: 10px; + font-size: 13px; line-height: normal; background-color: $white; border-radius: 2px; diff --git a/src/lib/viewers/controls/settings/SettingsFlyout.tsx b/src/lib/viewers/controls/settings/SettingsFlyout.tsx index 59573074c..10efe8983 100644 --- a/src/lib/viewers/controls/settings/SettingsFlyout.tsx +++ b/src/lib/viewers/controls/settings/SettingsFlyout.tsx @@ -1,18 +1,23 @@ import React from 'react'; import classNames from 'classnames'; -import SettingsContext from './SettingsContext'; import './SettingsFlyout.scss'; export type Props = React.PropsWithChildren<{ className?: string; + height?: number | string; isOpen: boolean; + width?: number | string; }>; -export default function SettingsFlyout({ children, className, isOpen }: Props): JSX.Element { +export default function SettingsFlyout({ + children, + className, + height = 'auto', + isOpen, + width = 'auto', +}: Props): JSX.Element { const [isTransitioning, setIsTransitioning] = React.useState(false); const flyoutElRef = React.useRef(null); - const { activeRect } = React.useContext(SettingsContext); - const { height, width } = activeRect || { height: 'auto', width: 'auto' }; React.useEffect(() => { const { current: flyoutEl } = flyoutElRef; diff --git a/src/lib/viewers/controls/settings/SettingsList.tsx b/src/lib/viewers/controls/settings/SettingsList.tsx new file mode 100644 index 000000000..e506182b1 --- /dev/null +++ b/src/lib/viewers/controls/settings/SettingsList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import classNames from 'classnames'; +import noop from 'lodash/noop'; +import { decodeKeydown } from '../../../util'; + +export type Props = React.HTMLAttributes & { isActive?: boolean }; + +function SettingsList(props: Props, ref: React.Ref): JSX.Element { + const { children, className, isActive = true, onKeyDown = noop, ...rest } = props; + const [activeIndex, setActiveIndex] = React.useState(0); + const [activeItem, setActiveItem] = React.useState(null); + + const handleKeyDown = (event: React.KeyboardEvent): void => { + const key = decodeKeydown(event); + const max = React.Children.toArray(children).length - 1; + + if (key === 'ArrowUp' && activeIndex > 0) { + setActiveIndex(activeIndex - 1); + } + + if (key === 'ArrowDown' && activeIndex < max) { + setActiveIndex(activeIndex + 1); + } + + onKeyDown(event); + }; + + React.useEffect(() => { + if (activeItem && isActive) { + activeItem.focus(); + } + }, [activeItem, isActive]); + + return ( +
+ {React.Children.map(children, (listItem, itemIndex) => { + if (React.isValidElement(listItem) && itemIndex === activeIndex) { + return React.cloneElement(listItem, { ref: setActiveItem, ...listItem.props }); + } + + return listItem; + })} +
+ ); +} + +export default React.forwardRef(SettingsList); diff --git a/src/lib/viewers/controls/settings/SettingsMenu.tsx b/src/lib/viewers/controls/settings/SettingsMenu.tsx index 7431179c4..0b04689a7 100644 --- a/src/lib/viewers/controls/settings/SettingsMenu.tsx +++ b/src/lib/viewers/controls/settings/SettingsMenu.tsx @@ -1,7 +1,7 @@ import React from 'react'; import classNames from 'classnames'; import SettingsContext, { Menu } from './SettingsContext'; -import { decodeKeydown } from '../../../util'; +import SettingsList from './SettingsList'; import './SettingsMenu.scss'; export type Props = React.PropsWithChildren<{ @@ -9,26 +9,11 @@ export type Props = React.PropsWithChildren<{ name: Menu; }>; -export default function SettingsMenu({ children, className, name }: Props): JSX.Element | null { - const [activeIndex, setActiveIndex] = React.useState(0); - const [activeItem, setActiveItem] = React.useState(null); +export default function SettingsMenu({ children, className, name }: Props): JSX.Element { const { activeMenu, setActiveRect } = React.useContext(SettingsContext); const isActive = activeMenu === name; const menuElRef = React.useRef(null); - const handleKeyDown = (event: React.KeyboardEvent): void => { - const key = decodeKeydown(event); - const max = React.Children.toArray(children).length - 1; - - if (key === 'ArrowUp' && activeIndex > 0) { - setActiveIndex(activeIndex - 1); - } - - if (key === 'ArrowDown' && activeIndex < max) { - setActiveIndex(activeIndex + 1); - } - }; - React.useEffect(() => { const { current: menuEl } = menuElRef; @@ -37,27 +22,15 @@ export default function SettingsMenu({ children, className, name }: Props): JSX. } }, [isActive, setActiveRect]); - React.useEffect(() => { - if (activeItem && isActive) { - activeItem.focus(); - } - }, [activeItem, isActive]); - return ( -
- {React.Children.map(children, (menuItem, menuIndex) => { - if (React.isValidElement(menuItem) && menuIndex === activeIndex) { - return React.cloneElement(menuItem, { ref: setActiveItem, ...menuItem.props }); - } - - return menuItem; - })} -
+ {children} + ); } diff --git a/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx b/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx index 4b667ac82..d49262bc9 100644 --- a/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx +++ b/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx @@ -94,6 +94,17 @@ describe('Settings', () => { expect(wrapper.exists(SettingsGearToggle)).toBe(true); }); + describe('flyout dimensions', () => { + test('should apply activeRect dimensions if present', () => { + const wrapper = getWrapper(); + + wrapper.find(SettingsGearToggle).simulate('click'); + + expect(wrapper.find(SettingsFlyout).prop('height')).toBe('auto'); + expect(wrapper.find(SettingsFlyout).prop('width')).toBe('auto'); + }); + }); + describe('toggle prop', () => { function CustomToggle( // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/lib/viewers/controls/settings/__tests__/SettingsDropdown-test.tsx b/src/lib/viewers/controls/settings/__tests__/SettingsDropdown-test.tsx new file mode 100644 index 000000000..31b2cb7f2 --- /dev/null +++ b/src/lib/viewers/controls/settings/__tests__/SettingsDropdown-test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; +import SettingsDropdown, { Props } from '../SettingsDropdown'; +import SettingsFlyout from '../SettingsFlyout'; +import SettingsList from '../SettingsList'; + +describe('SettingsDropdown', () => { + const listItems = [ + { label: 'first', value: 'first' }, + { label: 'second', value: 'second' }, + { label: 'third', value: 'third' }, + ]; + const getDefaults = (): Props => ({ + label: 'Dropdown Label', + listItems, + onSelect: jest.fn(), + value: 'first', + }); + const getHostNode = (): HTMLDivElement => { + return document.body.appendChild(document.createElement('div')); + }; + const getWrapper = (props = {}): ReactWrapper => + mount(, { + attachTo: getHostNode(), + }); + + describe('toggling', () => { + test('should toggle open the flyout and render the list', () => { + const wrapper = getWrapper(); + + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + const renderedItems = wrapper.find('.bp-SettingsDropdown-listitem'); + + expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(true); + expect(renderedItems.length).toBe(listItems.length); + }); + + test('should select the specified value', () => { + const wrapper = getWrapper({ value: 'second' }); + + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + const renderedItems = wrapper.find('.bp-SettingsDropdown-listitem'); + + expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(true); + expect(renderedItems.length).toBe(listItems.length); + expect(renderedItems.get(0).props['aria-selected']).toBe(false); + expect(renderedItems.get(1).props['aria-selected']).toBe(true); + expect(renderedItems.get(2).props['aria-selected']).toBe(false); + }); + }); + + describe('events', () => { + test('should call onSelect with the list item value when clicked', () => { + const mockEvent = { stopPropagation: jest.fn() }; + const onSelect = jest.fn(); + const wrapper = getWrapper({ onSelect }); + + // Open the flyout + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + act(() => { + wrapper + .find('.bp-SettingsDropdown-listitem') + .get(1) + .props.onClick(mockEvent); + }); + wrapper.update(); + + expect(onSelect).toBeCalledWith('second'); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + test.each(['Space', 'Enter'])('should call onSelect with the list item value when keydown %s', key => { + const mockEvent = { key }; + const onSelect = jest.fn(); + const wrapper = getWrapper({ onSelect }); + + // Open the flyout + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + act(() => { + wrapper + .find('.bp-SettingsDropdown-listitem') + .get(1) + .props.onKeyDown(mockEvent); + }); + wrapper.update(); + + expect(onSelect).toBeCalledWith('second'); + }); + + test.each(['Escape', 'ArrowLeft'])('should not call onSelect with the list item value when keydown %s', key => { + const mockEvent = { key }; + const onSelect = jest.fn(); + const wrapper = getWrapper({ onSelect }); + + // Open the flyout + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + act(() => { + wrapper + .find('.bp-SettingsDropdown-listitem') + .get(1) + .props.onKeyDown(mockEvent); + }); + wrapper.update(); + + expect(onSelect).not.toBeCalled(); + }); + + test('should close dropdown after making selection', () => { + const mockEvent = { stopPropagation: jest.fn() }; + const onSelect = jest.fn(); + const wrapper = getWrapper({ onSelect }); + + // Open the flyout + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + act(() => { + wrapper + .find('.bp-SettingsDropdown-listitem') + .get(1) + .props.onClick(mockEvent); + }); + wrapper.update(); + + expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false); + }); + + test('should close dropdown if Escape is pressed', () => { + const mockEvent = { key: 'Escape', stopPropagation: jest.fn() }; + const wrapper = getWrapper(); + + // Open the flyout + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + act(() => { + wrapper.find(SettingsList).simulate('keydown', mockEvent); + }); + wrapper.update(); + + expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + test.each(['ArrowUp', 'ArrowDown', 'Escape'])('should prevent propagation of keydown events for %s', key => { + const mockEvent = { key, stopPropagation: jest.fn() }; + const wrapper = getWrapper(); + + // Open the flyout + act(() => { + wrapper.find('.bp-SettingsDropdown-button').simulate('click'); + }); + wrapper.update(); + + act(() => { + wrapper.find(SettingsList).simulate('keydown', mockEvent); + }); + wrapper.update(); + + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + const element = wrapper.getDOMNode(); + + expect(element).toHaveClass('bp-SettingsDropdown'); + expect(wrapper.find('.bp-SettingsDropdown-label').text()).toBe('Dropdown Label'); + expect(wrapper.find('.bp-SettingsDropdown-button').text()).toBe('first'); + expect(wrapper.exists(SettingsFlyout)); + expect(wrapper.exists(SettingsList)); + }); + }); +}); diff --git a/src/lib/viewers/controls/settings/__tests__/SettingsFlyout-test.tsx b/src/lib/viewers/controls/settings/__tests__/SettingsFlyout-test.tsx index 6243f0579..130f29f7e 100644 --- a/src/lib/viewers/controls/settings/__tests__/SettingsFlyout-test.tsx +++ b/src/lib/viewers/controls/settings/__tests__/SettingsFlyout-test.tsx @@ -5,7 +5,7 @@ import SettingsContext, { Context } from '../SettingsContext'; import SettingsFlyout from '../SettingsFlyout'; describe('SettingsFlyout', () => { - const getContext = (): Partial => ({ activeRect: undefined }); + const getContext = (): Partial => ({}); const getWrapper = (props = {}, context = getContext()): ReactWrapper => mount(, { wrappingComponent: SettingsContext.Provider, @@ -36,9 +36,9 @@ describe('SettingsFlyout', () => { expect(wrapper.childAt(0).hasClass('bp-is-open')).toBe(isOpen); }); - test('should set styles based on the activeRect, if present', () => { + test('should set styles based on the provided height and width, if present', () => { const activeRect = { bottom: 0, left: 0, height: 100, right: 0, top: 0, width: 100 }; - const wrapper = getWrapper({}, { activeRect }); + const wrapper = getWrapper({ height: activeRect.height, width: activeRect.width }, {}); expect(wrapper.childAt(0).prop('style')).toEqual({ height: 100, @@ -46,7 +46,7 @@ describe('SettingsFlyout', () => { }); }); - test('should set styles based on defaults if activeRect is not present', () => { + test('should set styles based on defaults if height and width is not present', () => { const wrapper = getWrapper(); expect(wrapper.childAt(0).prop('style')).toEqual({ diff --git a/src/lib/viewers/controls/settings/__tests__/SettingsList-test.tsx b/src/lib/viewers/controls/settings/__tests__/SettingsList-test.tsx new file mode 100644 index 000000000..14ad2edf1 --- /dev/null +++ b/src/lib/viewers/controls/settings/__tests__/SettingsList-test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; +import SettingsList from '../SettingsList'; + +describe('SettingsList', () => { + const getHostNode = (): HTMLDivElement => { + return document.body.appendChild(document.createElement('div')); + }; + const getWrapper = (props = {}): ReactWrapper => + mount( + +
+
+
+ , + { + attachTo: getHostNode(), + }, + ); + + describe('Event handling', () => { + test('should handle navigating the list and setting focus on the active item', () => { + const wrapper = getWrapper(); + + // index 0 has focus + expect(document.querySelector('[data-testid="test1"]')).toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).not.toHaveFocus(); + + // index 1 has focus + act(() => { + wrapper.find('.bp-SettingsList').simulate('keydown', { key: 'ArrowDown' }); + }); + wrapper.update(); + + expect(document.querySelector('[data-testid="test1"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).not.toHaveFocus(); + + // index 2 has focus + act(() => { + wrapper.find('.bp-SettingsList').simulate('keydown', { key: 'ArrowDown' }); + }); + wrapper.update(); + + expect(document.querySelector('[data-testid="test1"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).toHaveFocus(); + + // index 2 should keep focus because we are at the end of the list + act(() => { + wrapper.find('.bp-SettingsList').simulate('keydown', { key: 'ArrowDown' }); + }); + wrapper.update(); + + expect(document.querySelector('[data-testid="test1"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).toHaveFocus(); + + // index 1 has focus + act(() => { + wrapper.find('.bp-SettingsList').simulate('keydown', { key: 'ArrowUp' }); + }); + wrapper.update(); + + expect(document.querySelector('[data-testid="test1"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).not.toHaveFocus(); + + // index 0 has focus + act(() => { + wrapper.find('.bp-SettingsList').simulate('keydown', { key: 'ArrowUp' }); + }); + wrapper.update(); + + expect(document.querySelector('[data-testid="test1"]')).toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).not.toHaveFocus(); + + // index 0 should keep focus because we are at the top of the list + act(() => { + wrapper.find('.bp-SettingsList').simulate('keydown', { key: 'ArrowUp' }); + }); + wrapper.update(); + + expect(document.querySelector('[data-testid="test1"]')).toHaveFocus(); + expect(document.querySelector('[data-testid="test2"]')).not.toHaveFocus(); + expect(document.querySelector('[data-testid="test3"]')).not.toHaveFocus(); + }); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const onKeyDown = jest.fn(); + const wrapper = getWrapper({ onKeyDown }); + const element = wrapper.getDOMNode(); + + expect(element).toHaveClass('bp-SettingsList'); + expect(element).toHaveAttribute('role', 'listbox'); + expect(element).toHaveAttribute('tabIndex', '0'); + }); + }); +});