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 (
+
+
+
+ )
+}
+
+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',