diff --git a/src/components/buttons.js b/src/components/buttons.js new file mode 100644 index 00000000..47149cc5 --- /dev/null +++ b/src/components/buttons.js @@ -0,0 +1,131 @@ +import classnames from 'classnames'; + +import { SvgIcon } from './SvgIcon'; + +/** + * @typedef ButtonProps + * @prop {import("preact").ComponentChildren} [children] + * @prop {string} [className] + * @prop {string} [icon] - Name of `SvgIcon` to render in the button + * @prop {'left'|'right'} [iconPosition] - Icon positioned to left or to + * right of button text + * @prop {boolean} [disabled] + * @prop {boolean} [expanded] - Is the element associated with this button + * expanded (set `aria-expanded`) + * @prop {boolean} [pressed] - Is this button currently "active?" (set + * `aria-pressed`) + * @prop {() => any} [onClick] + * @prop {'small'|'medium'|'large'} [size='medium'] - Relative button size: + * affects padding + * @prop {Object} [style] - Optional inline styles + * @prop {string} [title] - Button title; used for `aria-label` attribute + * @prop {'normal'|'primary'|'light'|'dark'} [variant='normal'] - For styling: element variant + */ + +/** + * @typedef IconButtonBaseProps + * @prop {string} icon - Icon is required for icon buttons + * @prop {string} title - Title is required for icon buttons + */ + +/** + * @typedef {ButtonProps & IconButtonBaseProps} IconButtonProps + */ + +/** + * @param {ButtonProps} props + */ +function ButtonBase({ + children, + className, + icon, + iconPosition = 'left', + disabled, + expanded, + pressed, + onClick = () => {}, + size = 'medium', + style = {}, + title, + variant = 'normal', +}) { + const otherAttributes = {}; + if (typeof disabled === 'boolean') { + otherAttributes.disabled = disabled; + } + if (typeof title !== 'undefined') { + otherAttributes.title = title; + otherAttributes['aria-label'] = title; + } + + if (typeof expanded === 'boolean') { + otherAttributes['aria-expanded'] = expanded; + } + if (typeof pressed === 'boolean') { + otherAttributes['aria-pressed'] = pressed; + } + + return ( + + ); +} + +/** + * An icon-only button + * + * @param {IconButtonProps} props + */ +export function IconButton(props) { + const { className = 'IconButton', ...restProps } = props; + const { icon } = props; + return ( + + + + ); +} + +/** + * A labeled button, with or without an icon + * + * @param {ButtonProps} props + */ +export function LabeledButton(props) { + const { icon, iconPosition = 'left' } = props; + const { children, className = 'LabeledButton', ...restProps } = props; + return ( + + {icon && iconPosition === 'left' && } + {children} + {icon && iconPosition === 'right' && } + + ); +} + +/** + * A button styled to appear as an HTML link () + * + * @param {ButtonProps} props + */ +export function LinkButton(props) { + const { children } = props; + return ( + + {children} + + ); +} diff --git a/src/components/test/buttons-test.js b/src/components/test/buttons-test.js new file mode 100644 index 00000000..1b7afe2b --- /dev/null +++ b/src/components/test/buttons-test.js @@ -0,0 +1,257 @@ +import { mount } from 'enzyme'; + +import { IconButton, LabeledButton, LinkButton } from '../buttons.js'; +import { $imports } from '../buttons.js'; + +import { checkAccessibility } from '../../../test/util/accessibility'; +import mockImportedComponents from '../../../test/util/mock-imported-components'; + +// Add common tests for a button component for stuff provided by `ButtonBase` +function addCommonTests({ componentName, createComponentFn, withIcon = true }) { + describe(`${componentName} common support`, () => { + if (withIcon) { + it('renders the indicated icon', () => { + const wrapper = createComponentFn({ icon: 'fakeIcon' }); + const button = wrapper.find('button'); + const icon = wrapper.find('SvgIcon'); + assert.equal(icon.prop('name'), 'fakeIcon'); + // Icon is positioned "left" even if it is the only element in the