diff --git a/README.md b/README.md index 07cfd31..320eab0 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ plugins: [windmillPlugin()] - [x] Button - [x] Card -- [ ] Dropdown +- [x] Dropdown - [x] Form - [x] Modal - [ ] Table diff --git a/__tests__/Dropdown.test.js b/__tests__/Dropdown.test.js new file mode 100644 index 0000000..ab56d13 --- /dev/null +++ b/__tests__/Dropdown.test.js @@ -0,0 +1,59 @@ +import React from 'react' +import { mount } from 'enzyme' +import Dropdown from '../src/Dropdown' + +describe('Dropdown', () => { + it('should render without crashing', () => { + const onClose = jest.fn() + mount() + }) + + it('should render with base styles', () => { + const onClose = jest.fn() + const expected = + 'absolute right-0 w-56 p-2 mt-2 text-gray-600 bg-white border border-gray-100 rounded-lg shadow-md min-w-max-content dark:text-gray-300 dark:border-gray-700 dark:bg-gray-700' + const wrapper = mount() + + expect(wrapper.find('ul').getDOMNode().getAttribute('class')).toContain(expected) + }) + + it('should call onClose when Esc is pressed', () => { + const map = {} + document.addEventListener = jest.fn((e, cb) => { + map[e] = cb + }) + const onClose = jest.fn() + mount() + + map.keydown({ key: 'Esc' }) + + expect(onClose).toHaveBeenCalled() + }) + + it('should not call onClose when other key than Esc is pressed', () => { + const map = {} + document.addEventListener = jest.fn((e, cb) => { + map[e] = cb + }) + const onClose = jest.fn() + mount() + + map.keydown({ key: 'Enter' }) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('should remove the event listener on unmount', () => { + const map = {} + const removeListener = jest.fn((e, cb) => { + map[e] = cb + }) + document.removeEventListener = removeListener + const onClose = jest.fn() + const wrapper = mount() + + wrapper.unmount() + + expect(removeListener).toHaveBeenCalled() + }) +}) diff --git a/__tests__/DropdownItem.test.js b/__tests__/DropdownItem.test.js new file mode 100644 index 0000000..b145c4b --- /dev/null +++ b/__tests__/DropdownItem.test.js @@ -0,0 +1,37 @@ +import React from 'react' +import { mount } from 'enzyme' +import DropdownItem from '../src/DropdownItem' +import Button from '../src/Button' + +describe('DropdownItem', () => { + it('should render without crashing', () => { + mount() + }) + + it('should render with base styles', () => { + const expected = 'mb-2 last:mb-0' + const wrapper = mount() + + expect(wrapper.find('li').getDOMNode().getAttribute('class')).toContain(expected) + }) + + it('should contain a Button child', () => { + const wrapper = mount() + + expect(wrapper.find(Button)).toBeTruthy() + }) + + it('should pass className to the inner button', () => { + const expected = 'bg-red-600' + const wrapper = mount() + + expect(wrapper.find(Button).getDOMNode().getAttribute('class')).toContain(expected) + }) + + it('should pass extra props to the inner button', () => { + const expected = 'test' + const wrapper = mount() + + expect(wrapper.find('a').getDOMNode().getAttribute('href')).toContain(expected) + }) +}) diff --git a/src/Dropdown.js b/src/Dropdown.js new file mode 100644 index 0000000..c1dbf10 --- /dev/null +++ b/src/Dropdown.js @@ -0,0 +1,54 @@ +import React, { useEffect, useContext } from 'react' +import classNames from 'classnames' +import PropTypes from 'prop-types' +import { ThemeContext } from './context/ThemeContext' +import defaultTheme from './themes/default' +import Transition from './Transition' +import FocusLock from 'react-focus-lock' + +function Dropdown({ children, onClose, isOpen, className, ...other }) { + const { dropdown } = useContext(ThemeContext) || defaultTheme + + const baseStyle = dropdown.base + + function handleEsc(e) { + if (e.key === 'Esc' || e.key === 'Escape') { + onClose() + } + } + + useEffect(() => { + document.addEventListener('keydown', handleEsc) + return () => { + document.removeEventListener('keydown', handleEsc) + } + }) + + const cls = classNames(baseStyle, className) + + return ( + +
+ +
    + {children} +
+
+
+
+ ) +} + +Dropdown.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + onClose: PropTypes.func.isRequired, + isOpen: PropTypes.bool.isRequired, +} + +export default Dropdown diff --git a/src/DropdownItem.js b/src/DropdownItem.js new file mode 100644 index 0000000..c31dcf7 --- /dev/null +++ b/src/DropdownItem.js @@ -0,0 +1,29 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import { ThemeContext } from './context/ThemeContext' +import defaultTheme from './themes/default' +import Button from './Button' + +const DropdownItem = React.forwardRef(function DropdownItem(props, ref) { + // Note: className is passed to the inner Button + const { className, children, ...other } = props + + const { dropdownItem } = useContext(ThemeContext) || defaultTheme + + const baseStyle = dropdownItem.base + + return ( +
  • + +
  • + ) +}) + +DropdownItem.propTypes = { + children: PropTypes.node, + className: PropTypes.string, +} + +export default DropdownItem diff --git a/src/index.js b/src/index.js index 9c1fcb9..1dae42c 100644 --- a/src/index.js +++ b/src/index.js @@ -15,3 +15,4 @@ export { default as ModalBody } from './ModalBody' export { default as ModalFooter } from './ModalFooter' export { default as ModalHeader } from './ModalHeader' export { default as Avatar } from './Avatar' +export { default as Dropdown } from './Dropdown' diff --git a/src/themes/default.js b/src/themes/default.js index cc376e0..a9a14ef 100644 --- a/src/themes/default.js +++ b/src/themes/default.js @@ -1,4 +1,15 @@ export default { + // DropdownItem + // this is the
  • that lives inside the Dropdown
      + // you're probably looking for the dropdownItem style inside button + dropdownItem: { + base: 'mb-2 last:mb-0', + }, + // Dropdown + dropdown: { + base: + 'absolute right-0 w-56 p-2 mt-2 text-gray-600 bg-white border border-gray-100 rounded-lg shadow-md min-w-max-content dark:text-gray-300 dark:border-gray-700 dark:bg-gray-700', + }, // Avatar avatar: { base: 'relative rounded-full', @@ -117,6 +128,7 @@ export default { active: 'hover:bg-gray-100 focus:shadow-outline-gray', disabled: 'opacity-50 cursor-not-allowed', }, + // this is the button that lives inside the DropdownItem dropdownItem: { base: 'inline-flex items-center cursor-pointer w-full px-2 py-1 text-sm font-medium transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200',