diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b396e4015..d4c9ca7591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `avatar` prop to `Chat.Message` subcomponent @Bugaa92 ([#159](https://github.com/stardust-ui/react/pull/159)) - add `iconOnly` prop to `Button` @mnajdova ([#182](https://github.com/stardust-ui/react/pull/182)) - Add Label `image` and `imagePosition`, removed `onIconClick` prop @mnajdova ([#55](https://github.com/stardust-ui/react/pull/55/)) +- Add `ButtonGroup` component @mnajdova ([#179](https://github.com/stardust-ui/react/pull/179)) ## [v0.5.0](https://github.com/stardust-ui/react/tree/v0.5.0) (2018-08-30) diff --git a/build/gulp/plugins/util/getComponentInfo.ts b/build/gulp/plugins/util/getComponentInfo.ts index b48e4cea7b..626d1cedc6 100644 --- a/build/gulp/plugins/util/getComponentInfo.ts +++ b/build/gulp/plugins/util/getComponentInfo.ts @@ -82,13 +82,12 @@ const getComponentInfo = filepath => { : info.displayName // class name for the component - // example, the "button" in class="ui button" + // example, the "button" in class="ui-button" // name of the component, sub component, or plural parent for sub component groups info.componentClassName = (info.isChild - ? `ui-${info.parentDisplayName}__${info.subcomponentName.replace( - /Group$/, - `${info.parentDisplayName}s`, - )}` + ? _.includes(info.subcomponentName, 'Group') + ? `ui-${info.parentDisplayName}s` + : `ui-${info.parentDisplayName}__${info.subcomponentName}` : `ui-${info.displayName}` ).toLowerCase() diff --git a/docs/src/examples/components/Button/Groups/ButtonGroupCircularExample.shorthand.tsx b/docs/src/examples/components/Button/Groups/ButtonGroupCircularExample.shorthand.tsx new file mode 100644 index 0000000000..4044b4544f --- /dev/null +++ b/docs/src/examples/components/Button/Groups/ButtonGroupCircularExample.shorthand.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { Button } from '@stardust-ui/react' + +const ButtonGroupCircularExampleShorthand = () => ( + +) + +export default ButtonGroupCircularExampleShorthand diff --git a/docs/src/examples/components/Button/Groups/ButtonGroupExample.shorthand.tsx b/docs/src/examples/components/Button/Groups/ButtonGroupExample.shorthand.tsx new file mode 100644 index 0000000000..88e24307ba --- /dev/null +++ b/docs/src/examples/components/Button/Groups/ButtonGroupExample.shorthand.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { Button } from '@stardust-ui/react' + +const ButtonGroupExampleShorthand = () => ( + +) + +export default ButtonGroupExampleShorthand diff --git a/docs/src/examples/components/Button/Groups/index.tsx b/docs/src/examples/components/Button/Groups/index.tsx new file mode 100644 index 0000000000..a7710de27b --- /dev/null +++ b/docs/src/examples/components/Button/Groups/index.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection' + +const Groups = () => ( + + + + +) + +export default Groups diff --git a/docs/src/examples/components/Button/index.tsx b/docs/src/examples/components/Button/index.tsx index e28853e499..965ba00469 100644 --- a/docs/src/examples/components/Button/index.tsx +++ b/docs/src/examples/components/Button/index.tsx @@ -2,12 +2,14 @@ import React from 'react' import Types from './Types' import Variations from './Variations' import States from './States' +import Groups from './Groups' const ButtonExamples = () => (
+
) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index fab437ffc4..e1cebd5f81 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,7 +1,7 @@ import * as PropTypes from 'prop-types' import * as React from 'react' -import { UIComponent, childrenExist, customPropTypes } from '../../lib' +import { UIComponent, childrenExist, customPropTypes, createShorthandFactory } from '../../lib' import Icon from '../Icon' import { ButtonBehavior } from '../../lib/accessibility' import { Accessibility } from '../../lib/accessibility/interfaces' @@ -12,6 +12,7 @@ import { ReactChildren, ComponentEventHandler, } from '../../../types/utils' +import ButtonGroup from './ButtonGroup' export interface IButtonProps { as?: any @@ -43,6 +44,8 @@ export interface IButtonProps { * - if button includes icon only, textual representation needs to be provided by using 'title', 'aria-label', or 'aria-labelledby' attributes */ class Button extends UIComponent, any> { + static create: Function + public static displayName = 'Button' public static className = 'ui-button' @@ -121,6 +124,8 @@ class Button extends UIComponent, any> { accessibility: ButtonBehavior as Accessibility, } + static Group = ButtonGroup + public renderComponent({ ElementType, classes, @@ -179,4 +184,6 @@ class Button extends UIComponent, any> { } } +Button.create = createShorthandFactory(Button, content => ({ content })) + export default Button diff --git a/src/components/Button/ButtonGroup.tsx b/src/components/Button/ButtonGroup.tsx new file mode 100644 index 0000000000..ba7ac5dad5 --- /dev/null +++ b/src/components/Button/ButtonGroup.tsx @@ -0,0 +1,118 @@ +import * as PropTypes from 'prop-types' +import * as React from 'react' +import * as _ from 'lodash' + +import { UIComponent, childrenExist, customPropTypes } from '../../lib' +import { ComponentVariablesInput, IComponentPartStylesInput } from '../../../types/theme' +import { Extendable, ItemShorthand, ReactChildren } from '../../../types/utils' +import Button from './Button' + +export interface IButtonGroupProps { + as?: any + children?: ReactChildren + circular?: boolean + className?: string + content?: React.ReactNode + buttons?: ItemShorthand[] + styles?: IComponentPartStylesInput + variables?: ComponentVariablesInput +} + +/** + * A button group. + */ +class ButtonGroup extends UIComponent, any> { + public static displayName = 'ButtonGroup' + + public static className = 'ui-buttons' + + public static propTypes = { + /** An element type to render as (string or function). */ + as: customPropTypes.as, + + /** A button can take the width of its container. */ + buttons: customPropTypes.collectionShorthand, + + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + /** The buttons inside group can appear circular. */ + circular: PropTypes.bool, + + /** Shorthand for primary content. */ + content: customPropTypes.contentShorthand, + + /** Custom styles to be applied for component. */ + styles: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + + /** Custom variables to be applied for component. */ + variables: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + } + + static handledProps = [ + 'as', + 'buttons', + 'children', + 'circular', + 'className', + 'content', + 'styles', + 'variables', + ] + + public static defaultProps = { + as: 'div', + } + + public renderComponent({ + ElementType, + classes, + accessibility, + variables, + styles, + rest, + }): React.ReactNode { + const { children, content, buttons, circular } = this.props + if (_.isNil(buttons)) { + return ( + + {childrenExist(children) ? children : content} + + ) + } + + return ( + + {_.map(buttons, (button, idx) => + Button.create(button, { + defaultProps: { + circular, + styles: { + root: this.getStyleForButtonIndex(styles, idx === 0, idx === buttons.length - 1), + }, + }, + }), + )} + + ) + } + + getStyleForButtonIndex = (styles, isFirst, isLast) => { + let resultStyles = {} + if (isFirst) { + resultStyles = styles.firstButton + } + if (isLast) { + resultStyles = { ...resultStyles, ...styles.lastButton } + } + if (!isFirst && !isLast) { + resultStyles = styles.middleButton + } + return resultStyles + } +} + +export default ButtonGroup diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index 3389ecb836..5947882a6f 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1 +1,2 @@ export { default } from './Button' +export { default as ButtonGroup } from './ButtonGroup' diff --git a/src/components/List/List.tsx b/src/components/List/List.tsx index 340501bf04..2d903b1003 100644 --- a/src/components/List/List.tsx +++ b/src/components/List/List.tsx @@ -41,7 +41,7 @@ class List extends UIComponent, any> { debug: PropTypes.bool, /** Shorthand array of props for ListItem. */ - items: PropTypes.arrayOf(PropTypes.any), + items: customPropTypes.collectionShorthand, /** A selection list formats list items as possible choices. */ selection: PropTypes.bool, diff --git a/src/index.ts b/src/index.ts index fba413fa45..4ef2d0f467 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,9 @@ export { themes } export { default as Accordion } from './components/Accordion' export { default as Button } from './components/Button' +export { ButtonGroup } from './components/Button' export { default as Chat } from './components/Chat' -export { default as ChatMessage } from './components/Chat' +export { ChatMessage } from './components/Chat' export { default as Divider } from './components/Divider' export { default as Grid } from './components/Grid' export { default as Image } from './components/Image' diff --git a/src/themes/teams/componentStyles.ts b/src/themes/teams/componentStyles.ts index 1cb7516ed9..a1ac3be585 100644 --- a/src/themes/teams/componentStyles.ts +++ b/src/themes/teams/componentStyles.ts @@ -5,6 +5,7 @@ export { default as AccordionTitle } from './components/Accordion/accordionTitle export { default as Avatar } from './components/Avatar/avatarStyles' export { default as Button } from './components/Button/buttonStyles' +export { default as ButtonGroup } from './components/Button/buttonGroupStyles' export { default as Chat } from './components/Chat/chatStyles' export { default as ChatMessage } from './components/Chat/chatMessageStyles' diff --git a/src/themes/teams/componentVariables.ts b/src/themes/teams/componentVariables.ts index 107bca360d..a129cdb742 100644 --- a/src/themes/teams/componentVariables.ts +++ b/src/themes/teams/componentVariables.ts @@ -3,6 +3,7 @@ export { default as AccordionContent } from './components/Accordion/accordionCon export { default as Avatar } from './components/Avatar/avatarVariables' export { default as Button } from './components/Button/buttonVariables' +export { default as ButtonGroup } from './components/Button/buttonVariables' export { default as ChatMessage } from './components/Chat/chatMessageVariables' diff --git a/src/themes/teams/components/Button/buttonGroupStyles.ts b/src/themes/teams/components/Button/buttonGroupStyles.ts new file mode 100644 index 0000000000..66ecaa362e --- /dev/null +++ b/src/themes/teams/components/Button/buttonGroupStyles.ts @@ -0,0 +1,32 @@ +import { IComponentPartStylesInput, ICSSInJSStyle } from '../../../../../types/theme' +import { IButtonGroupProps } from '../../../../components/Button/ButtonGroup' + +const commonButtonsStyles = (circular: boolean) => ({ + ...(!circular && { + margin: '0px', + borderRadius: '0px', + }), +}) + +const buttonGroupStyles: IComponentPartStylesInput = { + root: (): ICSSInJSStyle => ({}), + middleButton: ({ props: p }: { props: IButtonGroupProps; variables: any }) => ({ + ...commonButtonsStyles(p.circular), + }), + firstButton: ({ props: p, variables: v }: { props: IButtonGroupProps; variables: any }) => ({ + ...commonButtonsStyles(p.circular), + ...(!p.circular && { + borderTopLeftRadius: v.borderRadius, + borderBottomLeftRadius: v.borderRadius, + }), + }), + lastButton: ({ props: p, variables: v }: { props: IButtonGroupProps; variables: any }) => ({ + ...commonButtonsStyles(p.circular), + ...(!p.circular && { + borderTopRightRadius: v.borderRadius, + borderBottomRightRadius: v.borderRadius, + }), + }), +} + +export default buttonGroupStyles diff --git a/src/themes/teams/components/Button/buttonStyles.ts b/src/themes/teams/components/Button/buttonStyles.ts index 9fa8e9572c..31dc01ebee 100644 --- a/src/themes/teams/components/Button/buttonStyles.ts +++ b/src/themes/teams/components/Button/buttonStyles.ts @@ -12,6 +12,7 @@ const buttonStyles: IComponentPartStylesInput = { height, minWidth, maxWidth, + borderRadius, color, backgroundColor, backgroundColorHover, @@ -33,6 +34,7 @@ const buttonStyles: IComponentPartStylesInput = { maxWidth, color, backgroundColor, + borderRadius, display: 'inline-flex', justifyContent: 'center', alignItems: 'center', @@ -40,7 +42,6 @@ const buttonStyles: IComponentPartStylesInput = { padding: `0 ${pxToRem(paddingLeftRightValue)}`, margin: `0 ${pxToRem(8)} 0 0`, verticalAlign: 'middle', - borderRadius: pxToRem(2), borderWidth: `${secondary ? (circular ? 1 : 2) : 0}px`, cursor: 'pointer', diff --git a/src/themes/teams/components/Button/buttonVariables.ts b/src/themes/teams/components/Button/buttonVariables.ts index 87fae0d70f..7805c48f38 100644 --- a/src/themes/teams/components/Button/buttonVariables.ts +++ b/src/themes/teams/components/Button/buttonVariables.ts @@ -6,6 +6,7 @@ export interface IButtonVariables { height: string minWidth: string maxWidth: string + borderRadius: string color: string backgroundColor: string backgroundColorHover: string @@ -26,6 +27,7 @@ export default (siteVars: any): IButtonVariables => { height: pxToRem(32), minWidth: pxToRem(96), maxWidth: pxToRem(280), + borderRadius: pxToRem(2), color: siteVars.black, backgroundColor: siteVars.gray08, backgroundColorHover: siteVars.gray06, diff --git a/test/specs/commonTests/implementsCollectionShorthandProp.tsx b/test/specs/commonTests/implementsCollectionShorthandProp.tsx new file mode 100644 index 0000000000..9689abb82a --- /dev/null +++ b/test/specs/commonTests/implementsCollectionShorthandProp.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { mount } from './isConformant' +import * as _ from 'lodash' +import { DefaultShorthandTestOptions, ShorthandTestOptions } from './implementsShorthandProp' + +export default Component => { + return function implementsCollectionShorthandProp( + shorthandPropertyName: string, + ShorthandComponent: React.ComponentType, + options: ShorthandTestOptions = DefaultShorthandTestOptions, + ) { + const { mapsValueToProp } = options + + describe(`shorthand property for '${ShorthandComponent.displayName}'`, () => { + test(`is defined`, () => { + expect(Component.propTypes[shorthandPropertyName]).toBeTruthy() + }) + + test(`array of string values is spread as ${ + ShorthandComponent.displayName + }s' ${mapsValueToProp}`, () => { + const shorthandValue = ['some value', 'some other value'] + const props = { [shorthandPropertyName]: shorthandValue } + const wrapper = mount() + + const shorthandComponents = wrapper.find(ShorthandComponent.displayName) + + expect(shorthandComponents.first().prop(mapsValueToProp)).toEqual(_.first(shorthandValue)) + expect(shorthandComponents.last().prop(mapsValueToProp)).toEqual(_.last(shorthandValue)) + }) + + test(`object value is spread as ${ShorthandComponent.displayName}'s props`, () => { + const shorthandValue = [ + { key: 'first', foo: 'foo value', bar: 'bar value' }, + { key: 'last', foo: 'foo last value', bar: 'bar last value' }, + ] + + const props = { [shorthandPropertyName]: shorthandValue } + const wrapper = mount() + + const shorthandComponents = wrapper.find(ShorthandComponent.displayName) + + const allShorthandPropertiesArePassedToFirstShorthandComponent = Object.keys( + _.first(shorthandValue), + ).every( + propertyName => + propertyName === 'key' || + _.first(shorthandValue)[propertyName] === + shorthandComponents.first().prop(propertyName), + ) + + const allShorthandPropertiesArePassedToLastShorthandComponent = Object.keys( + _.last(shorthandValue), + ).every( + propertyName => + propertyName === 'key' || + _.last(shorthandValue)[propertyName] === shorthandComponents.last().prop(propertyName), + ) + + expect(allShorthandPropertiesArePassedToFirstShorthandComponent).toBe(true) + expect(allShorthandPropertiesArePassedToLastShorthandComponent).toBe(true) + }) + }) + } +} diff --git a/test/specs/commonTests/implementsShorthandProp.tsx b/test/specs/commonTests/implementsShorthandProp.tsx index 473a994248..90f31b1fb5 100644 --- a/test/specs/commonTests/implementsShorthandProp.tsx +++ b/test/specs/commonTests/implementsShorthandProp.tsx @@ -1,11 +1,11 @@ import * as React from 'react' import { mount } from './isConformant' -type ShorthandTestOptions = { +export type ShorthandTestOptions = { mapsValueToProp?: string } -const DefaultShorthandTestOptions: ShorthandTestOptions = { +export const DefaultShorthandTestOptions: ShorthandTestOptions = { mapsValueToProp: 'content', } diff --git a/test/specs/components/Button/ButtonGroup-test.tsx b/test/specs/components/Button/ButtonGroup-test.tsx new file mode 100644 index 0000000000..06372d767a --- /dev/null +++ b/test/specs/components/Button/ButtonGroup-test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +import { isConformant } from 'test/specs/commonTests' +import ButtonGroup from 'src/components/Button/ButtonGroup' +import implementsCollectionShorthandProp from '../../commonTests/implementsCollectionShorthandProp' +import Button from 'src/components/Button' + +const buttonGroupImplementsCollectionShorthandProp = implementsCollectionShorthandProp(ButtonGroup) + +describe('ButtonGroup', () => { + isConformant(ButtonGroup) + buttonGroupImplementsCollectionShorthandProp('buttons', Button) +}) diff --git a/test/specs/components/List/List-test.ts b/test/specs/components/List/List-test.ts index 6f29d0dcdf..01de79309c 100644 --- a/test/specs/components/List/List-test.ts +++ b/test/specs/components/List/List-test.ts @@ -1,8 +1,13 @@ import { isConformant, handlesAccessibility } from 'test/specs/commonTests' import List from 'src/components/List/List' +import implementsCollectionShorthandProp from '../../commonTests/implementsCollectionShorthandProp' +import ListItem from 'src/components/List/ListItem' + +const listImplementsCollectionShorthandProp = implementsCollectionShorthandProp(List) describe('List', () => { isConformant(List) handlesAccessibility(List, { defaultRootRole: 'list' }) + listImplementsCollectionShorthandProp('items', ListItem, { mapsValueToProp: 'main' }) }) diff --git a/test/specs/components/Menu/Menu-test.tsx b/test/specs/components/Menu/Menu-test.tsx index 6f20df9eda..58cb32726a 100644 --- a/test/specs/components/Menu/Menu-test.tsx +++ b/test/specs/components/Menu/Menu-test.tsx @@ -4,9 +4,14 @@ import Menu from 'src/components/Menu/Menu' import { isConformant, handlesAccessibility, getRenderedAttribute } from 'test/specs/commonTests' import { mountWithProvider, getTestingRenderedComponent } from 'test/utils' import { ToolbarBehavior, TabListBehavior } from '../../../../src/lib/accessibility' +import implementsCollectionShorthandProp from '../../commonTests/implementsCollectionShorthandProp' +import MenuItem from 'src/components/Menu/MenuItem' + +const menuImplementsCollectionShorthandProp = implementsCollectionShorthandProp(Menu) describe('Menu', () => { isConformant(Menu) + menuImplementsCollectionShorthandProp('items', MenuItem) const getItems = () => [ { key: 'home', content: 'home', onClick: jest.fn(), 'data-foo': 'something' },