diff --git a/src/components/InputList/InputList.js b/src/components/InputList/InputList.js new file mode 100644 index 0000000..f4146a3 --- /dev/null +++ b/src/components/InputList/InputList.js @@ -0,0 +1,58 @@ +import classnames from 'classnames'; +import { func, oneOf, string, arrayOf, shape, oneOfType } from 'prop-types'; +import React from 'react'; +import * as olt from '@lightelligence/styles'; +import { InputListItem } from './InputListItem'; + +/** + * Input list is a list of items, used as the body of a dropdown. Can be used + * for any custom component + * + * This component uses a semantic `ul` html tag name and forwards refs + * + * The component also passes all other `props` to the underlying `ul` element + */ +export const InputList = React.forwardRef( + ({ className, children, onChange, value, ...props }, ref) => ( + + ), +); + +InputList.displayName = 'InputList'; + +InputList.propTypes = { + /** + * Forward an additional className to the underlying element + */ + className: string, + /** + * Content of the element should always be consisted of + * [InputListItem](/#/Components/InputListItem) components. + */ + children: oneOfType([ + shape({ type: oneOf([InputListItem]) }), + arrayOf(shape({ type: oneOf([InputListItem]) })), + ]), + /** + * Callback when the value of the input list was changed + */ + onChange: func, + /** + * The current value of the input list + */ + value: string, +}; + +InputList.defaultProps = { + className: null, + children: null, + onChange: () => {}, + value: null, +}; diff --git a/src/components/InputList/InputList.md b/src/components/InputList/InputList.md new file mode 100644 index 0000000..486cb71 --- /dev/null +++ b/src/components/InputList/InputList.md @@ -0,0 +1,14 @@ +### Example + +```jsx +import { InputList, InputListItem } from '@lightelligence/react'; +const [value, setValue] = React.useState('2'); +const onChange = (value) => { + setValue(value); +}; + + Item 1 + Item 2 + Item 3 +; +``` diff --git a/src/components/InputList/InputList.test.js b/src/components/InputList/InputList.test.js new file mode 100644 index 0000000..c2930ec --- /dev/null +++ b/src/components/InputList/InputList.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { InputList } from './InputList'; +import { InputListItem } from './InputListItem'; + +const renderComponent = (props) => { + return render(); +}; + +describe('InputList', () => { + test('forwards className', () => { + const { getByTestId } = renderComponent({ + className: 'myClass', + value: '1', + children: [ + {}} value="1" />, + {}} value="2" />, + ], + }); + const component = getByTestId('component'); + expect(component.classList.contains('myClass')).toBe(true); + }); + test('properly sets value', () => { + const { getByText } = renderComponent({ + className: 'myClass', + value: '1', + children: [ + {}} value="1"> + Item Foo + , + {}} value="2"> + Item Bar + , + ], + }); + const itemFoo = getByText('Item Foo'); + const itemBar = getByText('Item Bar'); + expect(itemFoo.classList.contains('is-active')).toBe(true); + expect(itemBar.classList.contains('is-active')).toBe(false); + }); + test('properly propagates onChange', () => { + const onChange = jest.fn(); + const { getByText } = renderComponent({ + className: 'myClass', + onChange, + value: '1', + children: [ + {}} value="1"> + Item Foo + , + {}} value="2"> + Item Bar + , + ], + }); + const itemBar = getByText('Item Bar'); + fireEvent.click(itemBar); + expect(onChange).toHaveBeenCalledWith('2', expect.anything()); + }); +}); diff --git a/src/components/InputList/InputListItem.js b/src/components/InputList/InputListItem.js new file mode 100644 index 0000000..938d3a2 --- /dev/null +++ b/src/components/InputList/InputListItem.js @@ -0,0 +1,106 @@ +import classnames from 'classnames'; +import { bool, node, string, func, number } from 'prop-types'; +import React, { useCallback } from 'react'; +import * as olt from '@lightelligence/styles'; + +/** + * List item for the [InputList](#/Components/InputList) Component + * + * This component uses a semantic `li` html tag name and forwards refs + * + * The component also passes all other `props` to the underlying `li` element + */ +export const InputListItem = React.forwardRef( + ( + { + className, + children, + active, + onClick, + onKeyPress, + value, + tabIndex, + ...props + }, + ref, + ) => { + const handleClick = useCallback((event) => onClick(value, event), [ + value, + onClick, + ]); + const handleKeyPress = useCallback((event) => onKeyPress(value, event), [ + value, + onKeyPress, + ]); + + return ( +
  • + + {children} + +
  • + ); + }, +); + +InputListItem.displayName = 'InputListItem'; + +InputListItem.propTypes = { + /** + * Forward an additional className to the underlying element + */ + className: string, + /** + * Content of the element. Can be any valid React node. + */ + children: node, + /** + * The value of the item when it's being selected + */ + value: string.isRequired, + /** + * Tab index + * + * @hidden + */ + tabIndex: number, + /** + * Is the item currently active + * + * @hidden + */ + active: bool, + /** + * Specifies what happens when the item is clicked + * + * @hidden + */ + onClick: func, + /** + * Specifies what happens on key press + * + * @hidden + */ + onKeyPress: func, +}; + +InputListItem.defaultProps = { + className: null, + children: null, + active: false, + onKeyPress: null, + tabIndex: null, + onClick: () => {}, +}; diff --git a/src/components/InputList/InputListItem.md b/src/components/InputList/InputListItem.md new file mode 100644 index 0000000..6c5e152 --- /dev/null +++ b/src/components/InputList/InputListItem.md @@ -0,0 +1,14 @@ + +### Example + +```jsx +import { InputList, InputListItem } from '@lightelligence/react'; +const onChange = (value) => { + console.log(`changed to ${value}`); +}; + + Item 1 + Item 2 + Item 3 +; +``` diff --git a/src/components/InputList/InputListItem.test.js b/src/components/InputList/InputListItem.test.js new file mode 100644 index 0000000..95ac457 --- /dev/null +++ b/src/components/InputList/InputListItem.test.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { InputListItem } from './InputListItem'; + +const renderComponent = (props) => { + return render(); +}; + +describe('InputListItem', () => { + test('forwards className', () => { + const { getByText } = renderComponent({ + className: 'myClass', + onClick: () => {}, + value: '1', + children: 'Component', + }); + + const component = getByText('Component'); + expect(component.classList.contains('myClass')).toBe(true); + }); + test('forwards onClick', () => { + const onClick = jest.fn(); + const { getByText } = renderComponent({ + className: 'myClass', + onClick, + value: '1', + children: 'Component', + }); + const component = getByText('Component'); + fireEvent.click(component); + expect(onClick).toHaveBeenCalledWith('1', expect.anything()); + }); + test('forwards active', () => { + const { getByText } = renderComponent({ + className: 'myClass', + onClick: () => {}, + value: '1', + active: true, + children: 'Component', + }); + const component = getByText('Component'); + expect(component.classList.contains('is-active')).toBe(true); + }); +}); diff --git a/src/components/InputList/index.js b/src/components/InputList/index.js new file mode 100644 index 0000000..5979b72 --- /dev/null +++ b/src/components/InputList/index.js @@ -0,0 +1,2 @@ +export { InputList } from './InputList'; +export { InputListItem } from './InputListItem'; diff --git a/src/components/V2Button/V2Button.js b/src/components/V2Button/V2Button.js index ff68d63..bfb5b68 100644 --- a/src/components/V2Button/V2Button.js +++ b/src/components/V2Button/V2Button.js @@ -47,6 +47,8 @@ const V2Button = React.forwardRef( }, ); +V2Button.displayName = 'V2Button'; + V2Button.propTypes = { /** * The html tag that should be rendered for this button. diff --git a/src/components/V2Dropdown/V2Dropdown.js b/src/components/V2Dropdown/V2Dropdown.js new file mode 100644 index 0000000..c452d65 --- /dev/null +++ b/src/components/V2Dropdown/V2Dropdown.js @@ -0,0 +1,123 @@ +import classnames from 'classnames'; +import { string, oneOfType, shape, oneOf, arrayOf, func } from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import * as olt from '@lightelligence/styles'; +import { V2Label } from '../../controls/V2Label'; +import { InputListItem, InputList } from '../InputList'; + +export const V2Dropdown = React.forwardRef( + ( + { + className, + label, + children, + value, + onChange, + labelProps, + selectedContentProps, + inputListProps, + ...props + }, + ref, + ) => { + const [isOpen, setOpen] = useState(false); + + const onClick = useCallback(() => { + setOpen(!isOpen); + }, [isOpen, setOpen]); + + const selectedChild = React.Children.toArray(children).find( + (child) => child.props.value === value, + ); + const selectedElement = selectedChild && React.cloneElement(selectedChild); + + return ( + +
    + {selectedElement && ( +
    + {selectedElement.props.children} +
    + )} + + {children} + +
    +
    + ); + }, +); + +V2Dropdown.displayName = 'V2Dropdown'; + +V2Dropdown.propTypes = { + /** + * The floating label + */ + label: string.isRequired, + /** + * Forward an additional className to the underlying element + */ + className: string, + /** + * Content of the element should always be consisted of + * [InputListItem](/#/Components/InputListItem) components. + */ + children: oneOfType([ + shape({ type: oneOf([InputListItem]) }), + arrayOf(shape({ type: oneOf([InputListItem]) })), + ]), + /** + * The current value of the input list + */ + value: string, + /** + * Callback when the value of the input list was changed + */ + onChange: func, + /** + * Additional label props + */ + labelProps: shape({}), + /** + * Additional selected content props + */ + selectedContentProps: shape({}), + /** + * Additional input list props + */ + inputListProps: shape({}), +}; + +V2Dropdown.defaultProps = { + className: null, + children: null, + value: null, + labelProps: {}, + selectedContentProps: {}, + inputListProps: {}, + onChange: () => {}, +}; diff --git a/src/components/V2Dropdown/V2Dropdown.md b/src/components/V2Dropdown/V2Dropdown.md new file mode 100644 index 0000000..0902c0f --- /dev/null +++ b/src/components/V2Dropdown/V2Dropdown.md @@ -0,0 +1,17 @@ + +### Example + +```jsx +import { InputListItem } from "@lightelligence/react"; +const [value, setValue] = React.useState('2'); +const onChange = (value) => { + setValue(value); +}; +
    + + Item 1 + Item 2 + Item 3 + +
    +``` diff --git a/src/components/V2Dropdown/V2Dropdown.test.js b/src/components/V2Dropdown/V2Dropdown.test.js new file mode 100644 index 0000000..479907c --- /dev/null +++ b/src/components/V2Dropdown/V2Dropdown.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { V2Dropdown } from './V2Dropdown'; +import { InputListItem } from '../InputList'; + +const renderComponent = (props) => { + return render(); +}; + +describe('V2Dropdown', () => { + test('forwards className', () => { + const { getByTestId } = renderComponent({ + className: 'myClass', + value: '1', + label: 'Dropdown', + children: [ + , + , + ], + }); + const component = getByTestId('component'); + expect(component.classList.contains('myClass')).toBe(true); + }); + test('able to open', () => { + const { getByTestId } = renderComponent({ + value: '1', + label: 'Dropdown', + children: [ + , + , + ], + }); + const component = getByTestId('component'); + fireEvent.click(component); + expect(component.classList.contains('is-open')).toBe(true); + fireEvent.click(component); + expect(component.classList.contains('is-open')).toBe(false); + }); + test('properly sets value', () => { + const { getAllByText } = renderComponent({ + value: '1', + label: 'Dropdown', + children: [ + + Item Foo + , + + Item Bar + , + ], + }); + const itemFoo = getAllByText('Item Foo'); + const itemBar = getAllByText('Item Bar'); + expect(itemFoo).toHaveLength(2); + expect(itemBar).toHaveLength(1); + }); + test('properly propagates onChange', () => { + const onChange = jest.fn(); + const { getByText, getByTestId } = renderComponent({ + onChange, + value: '1', + label: 'Dropdown', + children: [ + + Item Foo + , + + Item Bar + , + ], + }); + const component = getByTestId('component'); + fireEvent.click(component); + const itemBar = getByText('Item Bar'); + fireEvent.click(itemBar); + expect(onChange).toHaveBeenCalledWith('2', expect.anything()); + }); +}); diff --git a/src/components/V2Dropdown/index.js b/src/components/V2Dropdown/index.js new file mode 100644 index 0000000..da94c9b --- /dev/null +++ b/src/components/V2Dropdown/index.js @@ -0,0 +1 @@ +export * from './V2Dropdown'; diff --git a/src/index.js b/src/index.js index 2a3a4d9..82aab99 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,8 @@ export * from './components/V2Tabs'; export * from './components/Tag'; export * from './components/Tooltip'; export * from './components/Pagination'; +export * from './components/V2Dropdown'; +export * from './components/InputList'; // content export * from './content/BasicDataCards';