diff --git a/CHANGELOG.md b/CHANGELOG.md index 167e63e28b..b64c9c39f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Features - Add `TextArea` component @lucivpav ([#1897](https://github.com/stardust-ui/react/pull/1897)) - Export `bell-slash` and `bell-snooze` icon to Teams theme @musingh ([#1921](https://github.com/stardust-ui/react/pull/1921)) +- Add `navigable` `List` variant @jurokapsiar ([#1904](https://github.com/stardust-ui/react/pull/1904)) - Add the `SplitButton` component @silviuavram ([#1789](https://github.com/stardust-ui/react/pull/1798)) ### Documentation diff --git a/docs/src/components/Sidebar/Sidebar.tsx b/docs/src/components/Sidebar/Sidebar.tsx index 5614a36750..188572b01b 100644 --- a/docs/src/components/Sidebar/Sidebar.tsx +++ b/docs/src/components/Sidebar/Sidebar.tsx @@ -314,6 +314,11 @@ class Sidebar extends React.Component { title: { content: 'Mentions', as: NavLink, to: '/prototype-mentions' }, public: true, }, + { + key: 'participants-list', + title: { content: 'Participants list', as: NavLink, to: '/prototype-participants-list' }, + public: true, + }, { key: 'searchpage', title: { content: 'Search Page', as: NavLink, to: '/prototype-search-page' }, diff --git a/docs/src/examples/components/List/Types/ListExampleNavigable.shorthand.steps.ts b/docs/src/examples/components/List/Types/ListExampleNavigable.shorthand.steps.ts new file mode 100644 index 0000000000..fd8f2e5b2d --- /dev/null +++ b/docs/src/examples/components/List/Types/ListExampleNavigable.shorthand.steps.ts @@ -0,0 +1,26 @@ +import { List } from '@stardust-ui/react' + +const selectors = { + list: `.${List.className}`, + item: (itemIndex: number) => + `.${List.className} .${List.Item.className}:nth-of-type(${itemIndex})`, +} + +const config: ScreenerTestsConfig = { + themes: ['teams', 'teamsDark', 'teamsHighContrast'], + steps: [ + (builder, keys) => + builder + .hover(selectors.item(2)) + .snapshot('Highlights an item') + .click(selectors.item(2)) + .snapshot('Clicks on an item') + .hover(selectors.item(3)) + .snapshot('Highlights another item') + .keys(selectors.item(2), keys.downArrow) + .snapshot('Focuses last item using keyboard'), + (builder, keys) => builder.keys('body', keys.tab).snapshot('Focuses item'), + ], +} + +export default config diff --git a/docs/src/examples/components/List/Types/ListExampleNavigable.shorthand.tsx b/docs/src/examples/components/List/Types/ListExampleNavigable.shorthand.tsx new file mode 100644 index 0000000000..90f4c82c2a --- /dev/null +++ b/docs/src/examples/components/List/Types/ListExampleNavigable.shorthand.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { List, Image } from '@stardust-ui/react' + +const items = [ + { + key: 'irving', + media: , + header: 'Irving Kuhic', + headerMedia: '7:26:56 AM', + content: 'Program the sensor to the SAS alarm through the haptic SQL card!', + }, + { + key: 'skyler', + media: , + header: 'Skyler Parks', + headerMedia: '11:30:17 PM', + content: 'Use the online FTP application to input the multi-byte application!', + }, + { + key: 'dante', + media: , + header: 'Dante Schneider', + headerMedia: '5:22:40 PM', + content: 'The GB pixel is down, navigate the virtual interface!', + }, +] + +const ListExampleNavigable = () => + +export default ListExampleNavigable diff --git a/docs/src/examples/components/List/Types/ListExampleNavigable.tsx b/docs/src/examples/components/List/Types/ListExampleNavigable.tsx new file mode 100644 index 0000000000..4b2eb40f13 --- /dev/null +++ b/docs/src/examples/components/List/Types/ListExampleNavigable.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { List, Image } from '@stardust-ui/react' + +const ListExampleNavigable = () => ( + + } + header="Irving Kuhic" + headerMedia="7:26:56 AM" + content="Program the sensor to the SAS alarm through the haptic SQL card!" + navigable + /> + } + header="Skyler Parks" + headerMedia="11:30:17 PM" + content="Use the online FTP application to input the multi-byte application!" + navigable + /> + } + header="Dante Schneider" + headerMedia="5:22:40 PM" + content="The GB pixel is down, navigate the virtual interface!" + navigable + /> + +) + +export default ListExampleNavigable diff --git a/docs/src/examples/components/List/Types/index.tsx b/docs/src/examples/components/List/Types/index.tsx index 7483e6ff44..a91edf2684 100644 --- a/docs/src/examples/components/List/Types/index.tsx +++ b/docs/src/examples/components/List/Types/index.tsx @@ -19,6 +19,11 @@ const Types = () => ( description="List can handle selected index in controlled mode." examplePath="components/List/Types/ListExampleSelectableControlled" /> + ) diff --git a/docs/src/prototypes/ParticipantsList/index.tsx b/docs/src/prototypes/ParticipantsList/index.tsx new file mode 100644 index 0000000000..956f01625e --- /dev/null +++ b/docs/src/prototypes/ParticipantsList/index.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { List, Avatar, Flex, Text, MenuButton, Icon } from '@stardust-ui/react' +import { PrototypeSection, ComponentPrototype } from '../Prototypes' + +const menu = ['Open', 'Remove from list'] + +const ActiveBarItem = props => ( + + + + + } menu={menu} /> + + +) + +const addContextMenu = item => render => + render(item, (Component, props) => { + return } menu={menu} /> + }) + +const items3 = [ + { + key: 'irving', + content: , + }, + { + key: 'skyler', + content: , + }, + { + key: 'dante', + content: , + }, +].map(addContextMenu) + +const ParticipantsList = () => ( + <> + + +) + +const ParticipantsListPrototype: React.FC = () => { + return ( + + + + + + ) +} + +export default ParticipantsListPrototype diff --git a/docs/src/routes.tsx b/docs/src/routes.tsx index d940bb6013..2eea472396 100644 --- a/docs/src/routes.tsx +++ b/docs/src/routes.tsx @@ -41,6 +41,7 @@ import MenuButtonPrototype from './prototypes/MenuButton' import AlertsPrototype from './prototypes/alerts' import NestedPopupsAndDialogsPrototype from './prototypes/NestedPopupsAndDialogs' import CopyToClipboardPrototype from './prototypes/CopyToClipboard' +import ParticipantsListPrototype from './prototypes/ParticipantsList' const Routes = () => ( @@ -63,6 +64,7 @@ const Routes = () => ( + diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index 67f4b03b08..f4ed8789bf 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -39,6 +39,9 @@ export interface ListProps extends UIComponentProps, ChildrenComponentProps { /** A selectable list formats list items as possible choices. */ selectable?: boolean + /** A navigable list allows user to navigate through items. */ + navigable?: boolean + /** Index of the currently selected item. */ selectedIndex?: number @@ -63,7 +66,6 @@ export interface ListProps extends UIComponentProps, ChildrenComponentProps { } export interface ListState { - focusedIndex: number selectedIndex?: number } @@ -82,7 +84,8 @@ class List extends AutoControlledComponent, ListState> { }), debug: PropTypes.bool, items: customPropTypes.collectionShorthand, - selectable: PropTypes.bool, + selectable: customPropTypes.every([customPropTypes.disallow(['navigable']), PropTypes.bool]), + navigable: customPropTypes.every([customPropTypes.disallow(['selectable']), PropTypes.bool]), truncateContent: PropTypes.bool, truncateHeader: PropTypes.bool, selectedIndex: PropTypes.number, @@ -99,25 +102,25 @@ class List extends AutoControlledComponent, ListState> { static autoControlledProps = ['selectedIndex'] getInitialAutoControlledState() { - return { selectedIndex: -1, focusedIndex: 0 } + return { selectedIndex: -1 } } static Item = ListItem // List props that are passed to each individual Item props - static itemProps = ['debug', 'selectable', 'truncateContent', 'truncateHeader', 'variables'] + static itemProps = [ + 'debug', + 'selectable', + 'navigable', + 'truncateContent', + 'truncateHeader', + 'variables', + ] handleItemOverrides = (predefinedProps: ListItemProps) => { const { selectable } = this.props return { - onFocus: (e: React.SyntheticEvent, itemProps: ListItemProps) => { - _.invoke(predefinedProps, 'onFocus', e, itemProps) - - if (selectable) { - this.setState({ focusedIndex: itemProps.index }) - } - }, onClick: (e: React.SyntheticEvent, itemProps: ListItemProps) => { _.invoke(predefinedProps, 'onClick', e, itemProps) @@ -150,14 +153,13 @@ class List extends AutoControlledComponent, ListState> { renderItems() { const { items, selectable } = this.props - const { focusedIndex, selectedIndex } = this.state + const { selectedIndex } = this.state return _.map(items, (item, index) => { const maybeSelectableItemProps = {} as any if (selectable) { maybeSelectableItemProps.selected = index === selectedIndex - maybeSelectableItemProps.tabIndex = index === focusedIndex ? 0 : -1 } const itemProps = { diff --git a/packages/react/src/components/List/ListItem.tsx b/packages/react/src/components/List/ListItem.tsx index 3c4f7e86e9..0b22adcb0b 100644 --- a/packages/react/src/components/List/ListItem.tsx +++ b/packages/react/src/components/List/ListItem.tsx @@ -46,6 +46,9 @@ export interface ListItemProps /** A list item can indicate that it can be selected. */ selectable?: boolean + /** A list item can indicate that it can be navigable. */ + navigable?: boolean + /** Indicates if the current list item is selected. */ selected?: boolean truncateContent?: boolean @@ -85,6 +88,7 @@ class ListItem extends UIComponent> { media: PropTypes.any, selectable: PropTypes.bool, + navigable: PropTypes.bool, index: PropTypes.number, selected: PropTypes.bool, diff --git a/packages/react/src/components/MenuButton/MenuButton.tsx b/packages/react/src/components/MenuButton/MenuButton.tsx index 9b5a2c8f4f..73099f8abf 100644 --- a/packages/react/src/components/MenuButton/MenuButton.tsx +++ b/packages/react/src/components/MenuButton/MenuButton.tsx @@ -265,6 +265,7 @@ export default class MenuButton extends AutoControlledComponent accessibility, open: this.state.open, onOpenChange: this.handleOpenChange, content: { @@ -280,7 +281,6 @@ export default class MenuButton extends AutoControlledComponent accessibility, inline: true, autoFocus: true, }), diff --git a/packages/react/src/lib/accessibility/Behaviors/List/listBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/List/listBehavior.ts index 1137d1c6bf..646079278a 100644 --- a/packages/react/src/lib/accessibility/Behaviors/List/listBehavior.ts +++ b/packages/react/src/lib/accessibility/Behaviors/List/listBehavior.ts @@ -1,13 +1,18 @@ import { Accessibility } from '../../types' import selectableListBehavior from './selectableListBehavior' import basicListBehavior from './basicListBehavior' +import navigableListBehavior from './navigableListBehavior' /** * @description * Defines a behavior 'BasicListBehavior' or 'SelectableListBehavior' based on property 'selectable'. */ const ListBehavior: Accessibility = props => - props.selectable ? selectableListBehavior(props) : basicListBehavior(props) + props.selectable + ? selectableListBehavior(props) + : props.navigable + ? navigableListBehavior(props) + : basicListBehavior(props) export default ListBehavior @@ -15,6 +20,9 @@ export type ListBehaviorProps = { /** Indicates if a list is a selectable list. */ selectable?: boolean + /** Indicates if a list is a navigable list. */ + navigable?: boolean + /** Indicates if the list is horizontal. */ horizontal?: boolean } diff --git a/packages/react/src/lib/accessibility/Behaviors/List/listItemBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/List/listItemBehavior.ts index 537c16327f..4918931208 100644 --- a/packages/react/src/lib/accessibility/Behaviors/List/listItemBehavior.ts +++ b/packages/react/src/lib/accessibility/Behaviors/List/listItemBehavior.ts @@ -1,19 +1,26 @@ import selectableListItemBehavior from './selectableListItemBehavior' import basicListItemBehavior from './basicListItemBehavior' import { Accessibility } from '../../types' +import navigableListItemBehavior from './navigableListItemBehavior' /** * @description * Defines a behavior "BasicListItemBehavior" or "SelectableListItemBehavior" based on "selectable" property. */ const listItemBehavior: Accessibility = props => - props.selectable ? selectableListItemBehavior(props) : basicListItemBehavior(props) + props.selectable + ? selectableListItemBehavior(props) + : props.navigable + ? navigableListItemBehavior(props) + : basicListItemBehavior(props) export default listItemBehavior export type ListItemBehaviorProps = { /** Indicates if a list is a selectable list. */ selectable?: boolean + /** Indicates if a list is a navigable list. */ + navigable?: boolean /** Indicates if the current list item is selected. */ selected?: boolean } diff --git a/packages/react/src/lib/accessibility/Behaviors/List/navigableListBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/List/navigableListBehavior.ts new file mode 100644 index 0000000000..266d08f2d6 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/List/navigableListBehavior.ts @@ -0,0 +1,26 @@ +import { Accessibility, FocusZoneMode } from '../../types' +import { ListBehaviorProps } from './listBehavior' +import { FocusZoneDirection } from '../../FocusZone' + +/** + * @specification + * Adds role='menu'. + * Embeds component into FocusZone. + * Provides arrow key navigation in bidirectionalDomOrder direction. + */ +const navigableListBehavior: Accessibility = props => ({ + attributes: { + root: { + role: 'menu', + }, + }, + focusZone: { + mode: FocusZoneMode.Embed, + props: { + shouldFocusInnerElementWhenReceivedFocus: true, + direction: FocusZoneDirection.bidirectionalDomOrder, + }, + }, +}) + +export default navigableListBehavior diff --git a/packages/react/src/lib/accessibility/Behaviors/List/navigableListItemBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/List/navigableListItemBehavior.ts new file mode 100644 index 0000000000..de2e0e0810 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/List/navigableListItemBehavior.ts @@ -0,0 +1,28 @@ +import { Accessibility } from '../../types' +import * as keyboardKey from 'keyboard-key' +import { ListItemBehaviorProps } from './listItemBehavior' +import { IS_FOCUSABLE_ATTRIBUTE } from '../../FocusZone/focusUtilities' + +/** + * @specification + * Adds role='menuitem'. + * Adds attribute 'data-is-focusable=true' to 'root' slot. + * Triggers 'performClick' action with 'Enter' or 'Spacebar' on 'root'. + */ +const navigableListItemBehavior: Accessibility = props => ({ + attributes: { + root: { + role: 'menuitem', + [IS_FOCUSABLE_ATTRIBUTE]: true, + }, + }, + keyActions: { + root: { + performClick: { + keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }], + }, + }, + }, +}) + +export default navigableListItemBehavior diff --git a/packages/react/src/lib/accessibility/Behaviors/MenuButton/menuButtonBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/MenuButton/menuButtonBehavior.ts index e112bbb34a..93fa58685e 100644 --- a/packages/react/src/lib/accessibility/Behaviors/MenuButton/menuButtonBehavior.ts +++ b/packages/react/src/lib/accessibility/Behaviors/MenuButton/menuButtonBehavior.ts @@ -29,7 +29,7 @@ const menuButtonBehavior: Accessibility = props => { 'aria-expanded': props.open || undefined, 'aria-haspopup': 'true', id: props.triggerId, - tabIndex: props.open ? -1 : undefined, + ...(!props.contextMenu && props.open && { tabIndex: -1 }), }, menu: { @@ -68,6 +68,8 @@ export interface MenuButtonBehaviorProps extends PopupBehaviorProps { triggerId?: string /** Defines whether popup is displayed. */ open?: boolean + /** Determines if the MenuButton behaves as context menu */ + contextMenu?: boolean } export default menuButtonBehavior diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index f717cec8f8..91108c995f 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -12,6 +12,8 @@ export { default as basicListBehavior } from './Behaviors/List/listBehavior' export { default as basicListItemBehavior } from './Behaviors/List/basicListItemBehavior' export { default as listBehavior } from './Behaviors/List/listBehavior' export { default as listItemBehavior } from './Behaviors/List/listItemBehavior' +export { default as navigableListBehavior } from './Behaviors/List/navigableListBehavior' +export { default as navigableListItemBehavior } from './Behaviors/List/navigableListItemBehavior' export { default as selectableListBehavior } from './Behaviors/List/selectableListBehavior' export { default as selectableListItemBehavior } from './Behaviors/List/selectableListItemBehavior' export { default as loaderBehavior } from './Behaviors/Loader/loaderBehavior' diff --git a/packages/react/src/themes/teams/components/List/listItemStyles.ts b/packages/react/src/themes/teams/components/List/listItemStyles.ts index 8cb2e7e702..4304a3248e 100644 --- a/packages/react/src/themes/teams/components/List/listItemStyles.ts +++ b/packages/react/src/themes/teams/components/List/listItemStyles.ts @@ -47,7 +47,7 @@ const listItemStyles: ComponentSlotStylesPrepared = { root: ({ props: p, variables: v }): ICSSInJSStyle => ({ minHeight: v.minHeight, padding: v.rootPadding, - ...(p.selectable && { + ...((p.selectable || p.navigable) && { position: 'relative', // hide the end media by default @@ -102,7 +102,7 @@ const listItemStyles: ComponentSlotStylesPrepared = { lineHeight: v.contentMediaLineHeight, }), endMedia: ({ props: p }) => ({ - ...(p.selectable && { display: 'none' }), + ...((p.selectable || p.navigable) && { display: 'none' }), flexShrink: 0, }), main: () => ({ diff --git a/packages/react/test/specs/behaviors/behavior-test.tsx b/packages/react/test/specs/behaviors/behavior-test.tsx index dc5acdcb3f..224c50ab7d 100644 --- a/packages/react/test/specs/behaviors/behavior-test.tsx +++ b/packages/react/test/specs/behaviors/behavior-test.tsx @@ -23,6 +23,8 @@ import { dialogBehavior, radioGroupBehavior, radioGroupItemBehavior, + navigableListBehavior, + navigableListItemBehavior, selectableListBehavior, selectableListItemBehavior, sliderBehavior, @@ -83,6 +85,8 @@ testHelper.addBehavior('submenuBehavior', submenuBehavior) testHelper.addBehavior('popupBehavior', popupBehavior) testHelper.addBehavior('radioGroupBehavior', radioGroupBehavior) testHelper.addBehavior('radioGroupItemBehavior', radioGroupItemBehavior) +testHelper.addBehavior('navigableListBehavior', navigableListBehavior) +testHelper.addBehavior('navigableListItemBehavior', navigableListItemBehavior) testHelper.addBehavior('selectableListBehavior', selectableListBehavior) testHelper.addBehavior('selectableListItemBehavior', selectableListItemBehavior) testHelper.addBehavior('sliderBehavior', sliderBehavior) diff --git a/packages/react/test/specs/behaviors/listBehavior-test.tsx b/packages/react/test/specs/behaviors/listBehavior-test.tsx index eada8135d1..105c76136c 100644 --- a/packages/react/test/specs/behaviors/listBehavior-test.tsx +++ b/packages/react/test/specs/behaviors/listBehavior-test.tsx @@ -9,6 +9,15 @@ describe('ListBehavior.ts', () => { expect(expectedResult.attributes.root.role).toEqual('listbox') }) + test('use NavigableListBehavior if navigable prop is defined', () => { + const property = { + navigable: true, + } + const expectedResult = listBehavior(property) + expect(expectedResult.attributes.root.role).toEqual('menu') + expect(expectedResult.focusZone).toBeTruthy() + }) + test('use BasicListItemBehavior if selectable prop is NOT defined', () => { const property = {} const expectedResult = listBehavior(property) diff --git a/packages/react/test/specs/behaviors/listItemBehavior-test.tsx b/packages/react/test/specs/behaviors/listItemBehavior-test.tsx index 752000a625..f18626bc1b 100644 --- a/packages/react/test/specs/behaviors/listItemBehavior-test.tsx +++ b/packages/react/test/specs/behaviors/listItemBehavior-test.tsx @@ -1,4 +1,5 @@ import { listItemBehavior } from 'src/lib/accessibility' +import { IS_FOCUSABLE_ATTRIBUTE } from 'src/lib/accessibility/FocusZone' describe('ListItemBehavior.ts', () => { test('use SelectableListItemBehavior if selectable prop is defined', () => { @@ -9,6 +10,15 @@ describe('ListItemBehavior.ts', () => { expect(expectedResult.attributes.root.role).toEqual('option') }) + test('use NavigableListItemBehavior if navigable prop is defined', () => { + const property = { + navigable: true, + } + const expectedResult = listItemBehavior(property) + expect(expectedResult.attributes.root.role).toEqual('menuitem') + expect(expectedResult.attributes.root[IS_FOCUSABLE_ATTRIBUTE]).toEqual(true) + }) + test('use BasicListBehavior if selectable prop is NOT defined', () => { const property = {} const expectedResult = listItemBehavior(property)