diff --git a/package-lock.json b/package-lock.json index 32ae7f2f9..5a0a1000a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -692,6 +692,21 @@ "tslib": "^1.9.3" } }, + "@material/list": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@material/list/-/list-0.41.0.tgz", + "integrity": "sha512-HhYN0I02CTT8j91c1eeeI+L2KXVKdfzj0Zuapp2SdeCmQZLJO2tu2NYj0W6REBDTVBWBccr12Sn8o71CodEScQ==", + "dev": true, + "requires": { + "@material/base": "^0.41.0", + "@material/dom": "^0.41.0", + "@material/ripple": "^0.41.0", + "@material/rtl": "^0.40.1", + "@material/shape": "^0.41.0", + "@material/theme": "^0.41.0", + "@material/typography": "^0.41.0" + } + }, "@material/ripple": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-1.0.1.tgz", @@ -911,35 +926,62 @@ } }, "@material/list": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@material/list/-/list-0.41.0.tgz", - "integrity": "sha512-HhYN0I02CTT8j91c1eeeI+L2KXVKdfzj0Zuapp2SdeCmQZLJO2tu2NYj0W6REBDTVBWBccr12Sn8o71CodEScQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@material/list/-/list-1.0.1.tgz", + "integrity": "sha512-u9Bx+mghPy2qCHcSH2b0+0jbjTtkXNdiWjb8JDQcYmOUTfFoeQ16GqvjTybRPwoKA0UC9Hc/4U8gYvnK+bReRw==", "dev": true, "requires": { - "@material/base": "^0.41.0", - "@material/dom": "^0.41.0", - "@material/ripple": "^0.41.0", - "@material/rtl": "^0.40.1", - "@material/shape": "^0.41.0", - "@material/theme": "^0.41.0", - "@material/typography": "^0.41.0" + "@material/base": "^1.0.0", + "@material/dom": "^1.0.1", + "@material/feature-targeting": "^0.44.1", + "@material/ripple": "^1.0.1", + "@material/rtl": "^0.42.0", + "@material/shape": "^1.0.0", + "@material/theme": "^1.0.0", + "@material/typography": "^1.0.0", + "tslib": "^1.9.3" }, "dependencies": { - "@material/base": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@material/base/-/base-0.41.0.tgz", - "integrity": "sha512-tEyzwBRu3d1H120SfKsDVYZHcqT5lKohh/7cWKR93aAaPDkSvjpKJIjyu2yuSkjpDduVZGzVocYbOvhUKhhzXQ==", + "@material/dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@material/dom/-/dom-1.0.1.tgz", + "integrity": "sha512-7gb9Tk8YBn2fLEa5fJfvDexG0QxvRGDb8c6uZEhvK4bTd2ZHCfHg9KrO+smC6Trbn5jC+FsBvdRZBbMjtS/E4g==", + "dev": true, + "requires": { + "tslib": "^1.9.3" + } + }, + "@material/rtl": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-0.42.0.tgz", + "integrity": "sha512-VrnrKJzhmspsN8WXHuxxBZ69yM5IwhCUqWr1t1eNfw3ZEvEj7i1g3P31HGowKThIN1dc1Wh4LE14rCISWCtv5w==", "dev": true }, - "@material/ripple": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-0.41.0.tgz", - "integrity": "sha512-rxEUVWM4AByDlTCH0kkthZQmUuY6eeN0X6cOHBoioFN2vUDk0D0Nfzz/N9FF2AlAf8C2lDDLrTuqnJPVIn+NHA==", + "@material/shape": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@material/shape/-/shape-1.0.0.tgz", + "integrity": "sha512-zfXEacPQZmH+ujVtaFyfAsYiF46j1QCcFzJeZVouG4pznrbA7XD6614Ywg0wbyWX5iB6hD52ld/IN+R/6oxKqA==", "dev": true, "requires": { - "@material/animation": "^0.41.0", - "@material/base": "^0.41.0", - "@material/theme": "^0.41.0" + "@material/feature-targeting": "^0.44.1" + } + }, + "@material/theme": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@material/theme/-/theme-1.0.0.tgz", + "integrity": "sha512-Bg/BQLU5MmCwtQ3DHcSs9DodZB8PTvuItv1wXrP54S/wBVwryIB5uMDmERhnItbNnAFbkKhlAuhn1asMmMzfkQ==", + "dev": true, + "requires": { + "@material/feature-targeting": "^0.44.1" + } + }, + "@material/typography": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@material/typography/-/typography-1.0.0.tgz", + "integrity": "sha512-Oeqbjci1cC7jTE8/n3dwnkqKe9ZeWiaE+rgMtRYtRFw1HvAw14SpGA5EEAS/Li2Hu2KZ50FYCe3HYqShfxtChA==", + "dev": true, + "requires": { + "@material/feature-targeting": "^0.44.1" } } } diff --git a/package.json b/package.json index eea626b97..d4c85d40e 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@material/layout-grid": "^0.41.0", "@material/line-ripple": "^1.0.0", "@material/linear-progress": "^0.41.0", - "@material/list": "^0.41.0", + "@material/list": "^1.0.0", "@material/menu-surface": "^0.41.0", "@material/notched-outline": "^0.41.0", "@material/radio": "^0.41.0", diff --git a/packages/checkbox/index.tsx b/packages/checkbox/index.tsx index cb17f3acf..7304a29c5 100644 --- a/packages/checkbox/index.tsx +++ b/packages/checkbox/index.tsx @@ -187,7 +187,7 @@ export class Checkbox extends React.Component { id={nativeControlId} checked={this.state.checked} disabled={disabled} - aria-checked={this.state['aria-checked']} + aria-checked={this.state['aria-checked'] || this.state.checked} name={name} onChange={this.onChange} rippleActivatorRef={this.inputElement} diff --git a/packages/list/ListItem.tsx b/packages/list/ListItem.tsx index 73a9f4189..b0f75abea 100644 --- a/packages/list/ListItem.tsx +++ b/packages/list/ListItem.tsx @@ -22,43 +22,33 @@ import * as React from 'react'; import classnames from 'classnames'; +import {MDCListFoundation} from '@material/list/foundation'; export interface ListItemProps extends React.HTMLProps { - className: string; - classNamesFromList: string[]; - attributesFromList: object; - childrenTabIndex: number; - tabIndex: number; - shouldFocus: boolean; - shouldFollowHref: boolean; - shouldToggleCheckbox: boolean; - tag: string; - children: React.ReactNode; + checkboxList?: boolean; + radioList?: boolean; + onKeyDown?: React.KeyboardEventHandler; + onClick?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + tag?: string; + activated?: boolean; + selected?: boolean; }; -function isAnchorElement(element: any): element is HTMLAnchorElement { - return !!element.href; -} - -function isFocusableElement(element: any): element is HTMLElement { - return typeof element.focus === 'function'; -} - -export default class ListItem extends React.Component< +// TODO: convert to functional component +// https://github.com/material-components/material-components-web-react/issues/729 +export default class ListItem extends React.Component< ListItemProps, {} > { - listItemElement_: React.RefObject = React.createRef(); + private listItemElement = React.createRef(); static defaultProps: Partial> = { + checkboxList: false, + radioList: false, className: '', - classNamesFromList: [], - attributesFromList: {}, - childrenTabIndex: -1, tabIndex: -1, - shouldFocus: false, - shouldFollowHref: false, - shouldToggleCheckbox: false, onKeyDown: () => {}, onClick: () => {}, onFocus: () => {}, @@ -66,79 +56,50 @@ export default class ListItem extends React.Componen tag: 'li', }; - componentDidUpdate(prevProps: ListItemProps) { - const {shouldFocus, shouldFollowHref, shouldToggleCheckbox} = this.props; - if (shouldFocus && !prevProps.shouldFocus) { - this.focus(); - } - if (shouldFollowHref && !prevProps.shouldFollowHref) { - this.followHref(); - } - if (shouldToggleCheckbox && !prevProps.shouldToggleCheckbox) { - this.toggleCheckbox(); - } - } - get classes() { - const {className, classNamesFromList} = this.props; - return classnames('mdc-list-item', className, classNamesFromList); + const {className, activated, selected} = this.props; + return classnames('mdc-list-item', className, { + [MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS]: activated, + [MDCListFoundation.cssClasses.LIST_ITEM_SELECTED_CLASS]: selected, + }); } - focus() { - const element = this.listItemElement_.current; - if (isFocusableElement(element)) { - element.focus(); + get role() { + const {checkboxList, radioList, role} = this.props; + if (role) { + return role; + } else if (checkboxList) { + return 'checkbox'; + } else if (radioList) { + return 'radio'; } - } - - followHref() { - const element = this.listItemElement_.current; - if (isAnchorElement(element)) { - element.click(); - } - } - - toggleCheckbox() { - // TODO(bonniez): implement - // https://github.com/material-components/material-components-web-react/issues/352 + return null; } render() { const { - /* eslint-disable */ + /* eslint-disable no-unused-vars */ className, - classNamesFromList, - childrenTabIndex, - shouldFocus, - shouldFollowHref, - shouldToggleCheckbox, - /* eslint-enable */ - attributesFromList, children, + role, + checkboxList, + radioList, + /* eslint-enable no-unused-vars */ tag: Tag, + ...otherProps } = this.props; return ( // https://github.com/Microsoft/TypeScript/issues/28892 // @ts-ignore - {React.Children.map(children, this.renderChild)} + {this.props.children} ); } - - renderChild = (child: React.ReactChild) => { - if (typeof child === 'string' || typeof child === 'number' || child === null) { - return child; - } - - const tabIndex = this.props.childrenTabIndex; - const props = {...child.props, tabIndex}; - return React.cloneElement(child, props); - }; } diff --git a/packages/list/README.md b/packages/list/README.md index fef21ee49..1cbb1d67f 100644 --- a/packages/list/README.md +++ b/packages/list/README.md @@ -119,7 +119,7 @@ Multiple related lists can be grouped together using the `ListGroup` component. ```js import React, {Component} from 'react'; import List, { - ListItem, ListItemText, ListGroup, + ListItem, ListItemText, ListGroup, ListGroupSubheader,ListDivider } from '@material/react-list'; @@ -185,91 +185,177 @@ class MyApp extends Component { } ``` +### Checkbox Lists + +You can use the `checkboxList` Boolean prop for `List` to enable the checkbox list logic. You must also set `selectedIndex` of the list to an array. `selectedIndex` will be an empty array if there is no initial selection. Setting `selectedIndex` will initialize the list with the correct `tabIndex`. Changing this programatically will not affect the checkboxes -- you must programatically change the checkbox via it's `checked` prop and update `selectedIndex` to keep a11y up to date. See the [Checkbox Readme](../checkbox/README.md) for more details. + +```jsx +import React, {Component} from 'react'; +import List, {ListItem, ListItemText} from '@material/react-list'; +import Checkbox from '@material/react-checkbox'; + +class MyApp extends Component { + state = { + selectedIndex: [1], + }; + + render() { + return ( + this.setState({selectedIndex: allSelected})} + > + + + + + + + + + + + + + + ); + } +} +``` + +### Radio Lists + +You can use the `radioList` Boolean prop for `List` to enable the radio list logic. Set `selectedIndex` to the index of the listItem initially selected to accurately setup the component for a11y. Changing `selectedIndex` programatically will not affect the radio element -- you must instead programatically change the radio via it's `checked` prop. Interactions (click/arrow keys) will update the radios as expected. To get the selectedIndex, setup the radio's `onChange` method as shown below. See the [Radio Readme](../radio/README.md) for more details. + +> Note: We know this API is inconsistent with checkbox list. This is because the implementations of radio and checkbox differ. In the coming months, we will try to normalize the design. + +```jsx +import React, {Component} from 'react'; +import List, {ListItem, ListItemText} from '@material/react-list'; +import Radio, {NativeRadioControl} from '@material/react-radio'; + +class MyApp extends Component { + state = { + selectedItem: 'Milk', + }; + + handleChange = (e) => { + this.setState({selectedItem: e.target.value}); + } + + render() { + const listItems = ['Photos', 'Recipes', 'Work']; + + return ( + + { + listItems.map((item, index) => { + return ( + + + + + + + ); + }) + } + + ); + } +} +``` + ## Props ### List -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list element -nonInteractive | Boolean | Disables interactivity affordances -dense | Boolean | Styles the density of the list, making it appear more compact -avatarList | Boolean | Configures the leading tiles of each row to display images instead of icons. This will make the graphics of the list items larger -twoLine | Boolean | Styles the list with two lines -singleSelection | Boolean | Allows for single selection of list items -wrapFocus | Boolean | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa -selectedIndex | Number | Toggles the selected state of the list item at the given index -handleSelect | Function(selectedIndex: Number) => void | Callback for handling a list item selection event -aria-orientation | String | Indicates the list orientation -tag | String | Customizes the list tag type (defaults to `'ul'`) +| Prop Name | Type | Description | +| ---------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| className | String | Classes to be applied to the list element | +| nonInteractive | Boolean | Disables interactivity affordances | +| dense | Boolean | Styles the density of the list, making it appear more compact | +| avatarList | Boolean | Configures the leading tiles of each row to display images instead of icons. This will make the graphics of the list items larger | +| twoLine | Boolean | Styles the list with two lines | +| singleSelection | Boolean | Allows for single selection of list items | +| wrapFocus | Boolean | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa | +| checkboxList | Boolean | Set the list to act as a checkbox list | +| radioList | Boolean | Set the list to act as a radio list | +| selectedIndex | Number | Array | Toggles the selected state of the list item at the given index. Behaves differently for checkboxList and radioList (see sections above for more detail). | +| handleSelect | Function(activatedItemIndex: Number, selected: Number | Array) => void | Callback for handling a list item selection event. `selected` will be an Array,Number> for checkbox lists. | +| aria-orientation | String | Indicates the list orientation | +| tag | String | Customizes the list tag type (defaults to `'ul'`) | ### ListItem -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list item element -classNamesFromList | Array | Additional classes to be applied to the list item element, passed down from list -attributesFromList | Array | Additional attributes to be applied to the list item element, passed down from list -childrenTabIndex | Number | Tab index to be applied to all children of the list item -shouldFocus | Boolean | Whether to focus the list item -shouldFollowHref | Boolean | Whether to follow the link indicated by the list item -shouldToggleCheckbox | Boolean | Whether to toggle the checkbox on the list item -onClick | Function(evt: Event) => void | Callback for handling a click event -onKeyDown | Function(evt: Event) => void | Callback for handling a keydown event -onFocus | Function(evt: Event) => void | Callback for handling a focus event -onBlur | Function(evt: Event) => void | Callback for handling a blur event -tag | String | Customizes the list tag type (defaults to `'li'`) +| Prop Name | Type | Description | +| -------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | +| tag | String | Customizes the list tag type (defaults to `'li'`) | +| checkboxList | Boolean | Set the list item to act as a checkbox list | +| radioList | Boolean | Set the list item to act as a radio list | +| activated | Boolean | Sets the list item to the activated state | +| selected | Boolean | Sets the list item to the selected state | ### ListItemText -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list item text element -tabIndex | Number | Tab index of the list item text -tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item text. If false, the tab index will always be -1 -primaryText | String | Primary text for the list item -secondaryText | String | Secondary text for the list item +| Prop Name | Type | Description | +| ----------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | +| className | String | Classes to be applied to the list item text element | +| tabIndex | Number | Tab index of the list item text | +| tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item text. If false, the tab index will always be -1 | +| primaryText | String | Primary text for the list item | +| secondaryText | String | Secondary text for the list item | ### ListItemGraphic -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list item graphic element -tabIndex | Number | Tab index of the list item graphic -tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item graphic. If false, the tab index will always be -1 -graphic | Element | The graphic element to be displayed in front of list item text +| Prop Name | Type | Description | +| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | +| className | String | Classes to be applied to the list item graphic element | +| tabIndex | Number | Tab index of the list item graphic | +| tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item graphic. If false, the tab index will always be -1 | +| graphic | Element | The graphic element to be displayed in front of list item text | ### ListItemMeta -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list item meta element -tabIndex | Number | Tab index of the list item meta -tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item meta. If false, the tab index will always be -1 -meta | Element or String | The meta element or string to be displayed behind list item text +| Prop Name | Type | Description | +| ----------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------- | +| className | String | Classes to be applied to the list item meta element | +| tabIndex | Number | Tab index of the list item meta | +| tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item meta. If false, the tab index will always be -1 | +| meta | Element or String | The meta element or string to be displayed behind list item text | ### ListDivider -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list divider -tag | String | Element tag of the list divider, defaults to `li` -role | String | ARIA role of the list divider, defaults to `separator` +| Prop Name | Type | Description | +| --------- | ------ | ------------------------------------------------------ | +| className | String | Classes to be applied to the list divider | +| tag | String | Element tag of the list divider, defaults to `li` | +| role | String | ARIA role of the list divider, defaults to `separator` | ### ListGroup -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list group -tag | String | Element tag of the list group, defaults to `div` +| Prop Name | Type | Description | +| --------- | ------ | ------------------------------------------------ | +| className | String | Classes to be applied to the list group | +| tag | String | Element tag of the list group, defaults to `div` | ### ListGroupSubheader -Prop Name | Type | Description ---- | --- | --- -className | String | Classes to be applied to the list group subheader -tag | String | Element tag of the list group subheader, defaults to `h3` +| Prop Name | Type | Description | +| --------- | ------ | --------------------------------------------------------- | +| className | String | Classes to be applied to the list group subheader | +| tag | String | Element tag of the list group subheader, defaults to `h3` | ## Sass Mixins diff --git a/packages/list/index.tsx b/packages/list/index.tsx index 1f1e2a55c..3b598b7f0 100644 --- a/packages/list/index.tsx +++ b/packages/list/index.tsx @@ -22,8 +22,9 @@ import * as React from 'react'; import classnames from 'classnames'; -// @ts-ignore no .d.ts file -import {MDCListFoundation} from '@material/list/dist/mdc.list'; +import {MDCListFoundation} from '@material/list/foundation'; +import {MDCListIndex} from '@material/list/types'; +import {MDCListAdapter} from '@material/list/adapter'; import ListItem, {ListItemProps} from './ListItem'; // eslint-disable-line no-unused-vars import ListItemGraphic from './ListItemGraphic'; import ListItemText from './ListItemText'; @@ -33,35 +34,23 @@ import ListGroup from './ListGroup'; import ListGroupSubheader from './ListGroupSubheader'; const ARIA_ORIENTATION = 'aria-orientation'; const VERTICAL = 'vertical'; -const CHECKBOX_TYPE = 'checkbox'; -export interface ListProps extends React.HTMLProps { +export interface ListProps extends React.HTMLProps { className?: string; + checkboxList?: boolean; + radioList?: boolean; nonInteractive?: boolean; dense?: boolean; avatarList?: boolean; twoLine?: boolean; singleSelection?: boolean; - selectedIndex?: number; - handleSelect?: (selectedIndex: number) => void; + selectedIndex?: MDCListIndex; + handleSelect?: (activatedItemIndex: number, selected: MDCListIndex) => void; wrapFocus?: boolean; tag?: string; children: ListItem | ListItem[] | React.ReactNode; }; -interface ListState { - focusListItemAtIndex: number; - followHrefAtIndex: number; - toggleCheckboxAtIndex: number; - listItemAttributes: {[N: number]: any}; - listItemClassNames: {[N: number]: string[]}; - listItemChildrenTabIndex: {[N: number]: number}; -}; - -function isCheckbox(element: any): element is HTMLInputElement { - return element.type === CHECKBOX_TYPE; -} - function isReactText(element: any): element is React.ReactText { return typeof element === 'string' || typeof element === 'number'; } @@ -70,28 +59,21 @@ function isListItem(element: any): element is ListItem { return element && element.type === ListItem; } -export default class List extends React.Component, ListState> { +function isSelectedIndexType(selectedIndex: unknown): selectedIndex is MDCListIndex { + return typeof selectedIndex === 'number' && !isNaN(selectedIndex) || Array.isArray(selectedIndex); +} + +export default class List extends React.Component, {}> { listItemCount = 0; - foundation = MDCListFoundation; + foundation!: MDCListFoundation; + hasInitializedListItem = false; - state: ListState = { - focusListItemAtIndex: -1, - followHrefAtIndex: -1, - toggleCheckboxAtIndex: -1, - // listItemAttributes: {index: {attr: value}} - listItemAttributes: { - 0: { - tabIndex: 0, - }, - }, - // listItemClassNames: {index: Array} - listItemClassNames: {}, - // listItemChildrenTabIndex: {index: Number} - listItemChildrenTabIndex: {}, - }; + private listElement = React.createRef(); static defaultProps: Partial> = { 'className': '', + 'checkboxList': false, + 'radioList': false, 'nonInteractive': false, 'dense': false, 'avatarList': false, @@ -108,34 +90,29 @@ export default class List extends React.Com const {singleSelection, wrapFocus, selectedIndex} = this.props; this.foundation = new MDCListFoundation(this.adapter); this.foundation.init(); - this.foundation.setSingleSelection(singleSelection); - if ( - singleSelection && - typeof selectedIndex === 'number' && - !isNaN(selectedIndex) - ) { + this.foundation.setSingleSelection(singleSelection!); + this.foundation.layout(); + if (isSelectedIndexType(selectedIndex)) { this.foundation.setSelectedIndex(selectedIndex); } - this.foundation.setWrapFocus(wrapFocus); + this.foundation.setWrapFocus(wrapFocus!); this.foundation.setVerticalOrientation( this.props[ARIA_ORIENTATION] === VERTICAL ); + this.initializeListType(); } componentDidUpdate(prevProps: ListProps) { const {singleSelection, wrapFocus, selectedIndex} = this.props; + const hasSelectedIndexUpdated = selectedIndex !== prevProps.selectedIndex; if (singleSelection !== prevProps.singleSelection) { - this.foundation.setSingleSelection(singleSelection); + this.foundation.setSingleSelection(singleSelection!); } - if ( - selectedIndex !== prevProps.selectedIndex && - typeof selectedIndex === 'number' && - !isNaN(selectedIndex) - ) { + if (hasSelectedIndexUpdated && isSelectedIndexType(selectedIndex)) { this.foundation.setSelectedIndex(selectedIndex); } if (wrapFocus !== prevProps.wrapFocus) { - this.foundation.setWrapFocus(wrapFocus); + this.foundation.setWrapFocus(wrapFocus!); } if (this.props[ARIA_ORIENTATION] !== prevProps[ARIA_ORIENTATION]) { this.foundation.setVerticalOrientation( @@ -148,6 +125,40 @@ export default class List extends React.Com this.foundation.destroy(); } + initializeListType = () => { + const {singleSelection} = this.props; + const {cssClasses, strings} = MDCListFoundation; + + if (!this.listElement.current) return; + const checkboxListItems = this.listElement.current.querySelectorAll(strings.ARIA_ROLE_CHECKBOX_SELECTOR); + const radioSelectedListItem = this.listElement.current.querySelector(strings.ARIA_CHECKED_RADIO_SELECTOR); + + if (checkboxListItems.length) { + const preselectedItems = this.listElement.current.querySelectorAll(strings.ARIA_CHECKED_CHECKBOX_SELECTOR); + const selectedIndex = + [].map.call(preselectedItems, (listItem: Element) => this.listElements.indexOf(listItem)) as number[]; + this.foundation.setSelectedIndex(selectedIndex); + } else if (singleSelection) { + const isActivated = this.listElement.current.querySelector(cssClasses.LIST_ITEM_ACTIVATED_CLASS); + if (isActivated) { + this.foundation.setUseActivatedClass(true); + } + } else if (radioSelectedListItem) { + this.foundation.setSelectedIndex(this.listElements.indexOf(radioSelectedListItem)); + } + } + + get listElements(): Element[] { + if (this.listElement.current) { + return [].slice.call( + this.listElement.current.querySelectorAll( + MDCListFoundation.strings.ENABLED_ITEMS_SELECTOR + ) + ); + } + return []; + } + get classes() { const { className, @@ -164,118 +175,123 @@ export default class List extends React.Com }); } - get adapter() { + get adapter(): MDCListAdapter { return { - getListItemCount: () => this.listItemCount, - // Remove when MDC Web issue resolves: - // https://github.com/material-components/material-components-web/issues/4058 - getFocusedElementIndex: () => -1, - setAttributeForElementIndex: (index: number, attr: string, value: string) => { - const {listItemAttributes} = this.state; - attr = attr === 'tabindex' ? 'tabIndex' : attr; - if (!listItemAttributes[index]) { - listItemAttributes[index] = {}; + getListItemCount: () => this.listElements.length, + getFocusedElementIndex: () => this.listElements.indexOf(document.activeElement as HTMLLIElement), + setAttributeForElementIndex: (index, attr, value) => { + const listItem = this.listElements[index]; + if (listItem) { + listItem.setAttribute(attr, value); } - listItemAttributes[index][attr] = value; - this.setState({listItemAttributes}); }, - removeAttributeForElementIndex: (index: number, attr: string) => { - const {listItemAttributes} = this.state; - attr = attr === 'tabindex' ? 'tabIndex' : attr; - if (!listItemAttributes[index]) { - return; + addClassForElementIndex: (index, className) => { + const listItem = this.listElements[index]; + if (listItem) { + listItem.classList.add(className); } - delete listItemAttributes[index][attr]; - this.setState({listItemAttributes}); }, - addClassForElementIndex: (index: number, className: string) => { - const {listItemClassNames} = this.state; - if (!listItemClassNames[index]) { - listItemClassNames[index] = []; + removeClassForElementIndex: (index, className) => { + const listItem = this.listElements[index]; + if (listItem) { + listItem.classList.remove(className); } - listItemClassNames[index].push(className); - this.setState({listItemClassNames}); }, - removeClassForElementIndex: (index: number, className: string) => { - const {listItemClassNames} = this.state; - if (!listItemClassNames[index]) { - return; - } - const i = listItemClassNames[index].indexOf(className); - if (i >= 0) { - listItemClassNames[index].splice(i, 1); - this.setState({listItemClassNames}); + setTabIndexForListItemChildren: (listItemIndex, tabIndexValue) => { + const listItem = this.listElements[listItemIndex]; + const selector = MDCListFoundation.strings.CHILD_ELEMENTS_TO_TOGGLE_TABINDEX; + const listItemChildren: Element[] = + [].slice.call(listItem.querySelectorAll(selector)); + listItemChildren.forEach((el) => el.setAttribute('tabindex', tabIndexValue)); + }, + focusItemAtIndex: (index) => { + const element = this.listElements[index] as HTMLElement | undefined; + if (element) { + element.focus(); } }, - setTabIndexForListItemChildren: (listItemIndex: number, tabIndexValue: number) => { - const {listItemChildrenTabIndex} = this.state; - listItemChildrenTabIndex[listItemIndex] = tabIndexValue; - this.setState({listItemChildrenTabIndex}); + setCheckedCheckboxOrRadioAtIndex: () => { + // TODO: implement when this issue is fixed: + // https://github.com/material-components/material-components-web-react/issues/438 + // not implemented since MDC React Radio/Checkbox has events to + // handle toggling checkbox to correct state + }, + hasCheckboxAtIndex: (index) => { + const listItem = this.listElements[index]; + return !!listItem.querySelector(MDCListFoundation.strings.CHECKBOX_SELECTOR); }, - focusItemAtIndex: (index: number) => { - this.setState({focusListItemAtIndex: index}); + hasRadioAtIndex: (index) => { + const listItem = this.listElements[index]; + return !!listItem.querySelector(MDCListFoundation.strings.RADIO_SELECTOR); }, - followHref: (index: number) => { - this.setState({followHrefAtIndex: index}); + isCheckboxCheckedAtIndex: (index) => { + const listItem = this.listElements[index]; + const selector = MDCListFoundation.strings.CHECKBOX_SELECTOR; + const toggleEl = listItem.querySelector(selector); + return toggleEl!.checked; }, - toggleCheckbox: (index: number) => { - this.setState({toggleCheckboxAtIndex: index}); + isFocusInsideList: () => { + if (!this.listElement.current) return false; + return this.listElement.current.contains(document.activeElement); + }, + notifyAction: (index) => { + this.props.handleSelect!(index, this.foundation.getSelectedIndex()); }, }; } + get role() { + const {checkboxList, radioList, role} = this.props; + if (role) return role; + if (checkboxList) { + return 'group'; + } else if (radioList) { + return 'radiogroup'; + } + return null; + } + handleKeyDown = (e: React.KeyboardEvent, index: number) => { e.persist(); // Persist the synthetic event to access its `key` this.foundation.handleKeydown( - e, + e.nativeEvent, true /* isRootListItem is true if index >= 0 */, index ); - // Work around until MDC Web issue is resolved: - // https://github.com/material-components/material-components-web/issues/4053 - if ( - index >= 0 && - (e.key === 'Enter' || - e.keyCode === 13 || - e.key === 'Space' || - e.keyCode === 32) - ) { - this.props.handleSelect!(index); - } }; - handleClick = (e: React.MouseEvent, index: number) => { - // Toggle the checkbox only if it's not the target of the event, or the checkbox will have 2 change events. - const toggleCheckbox = isCheckbox(e.target); - this.foundation.handleClick(index, toggleCheckbox); - // Work around until MDC Web issue is resolved: - // https://github.com/material-components/material-components-web/issues/4053 - if (index >= 0) { - this.props.handleSelect!(index); - } + handleClick = (_e: React.MouseEvent, index: number) => { + // TODO: fix https://github.com/material-components/material-components-web-react/issues/728 + // Hardcoding toggleCheckbox to false for now since we want the checkbox to handle checkbox logic. + // The List Foundation tries to toggle the checkbox and radio, but its difficult to turn that off for checkbox + // or radio. + this.foundation.handleClick(index, false); }; // Use onFocus as workaround because onFocusIn is not yet supported in React // https://github.com/facebook/react/issues/6410 handleFocus = (e: React.FocusEvent, index: number) => { - this.foundation.handleFocusIn(e, index); + this.foundation.handleFocusIn(e.nativeEvent, index); }; // Use onBlur as workaround because onFocusOut is not yet supported in React // https://github.com/facebook/react/issues/6410 handleBlur = (e: React.FocusEvent, index: number) => { - this.foundation.handleFocusOut(e, index); + this.foundation.handleFocusOut(e.nativeEvent, index); }; render() { const { /* eslint-disable no-unused-vars */ className, + checkboxList, + radioList, nonInteractive, dense, avatarList, twoLine, singleSelection, + role, selectedIndex, handleSelect, wrapFocus, @@ -288,7 +304,12 @@ export default class List extends React.Com return ( // https://github.com/Microsoft/TypeScript/issues/28892 // @ts-ignore - + {React.Children.map(children, this.renderChild)} ); @@ -302,6 +323,8 @@ export default class List extends React.Com }; renderListItem = (listItem: React.ReactElement>, index: number) => { + const {checkboxList, radioList, selectedIndex} = this.props; + let tabIndex = {}; const { onKeyDown, onClick, @@ -309,16 +332,31 @@ export default class List extends React.Com onBlur, ...otherProps } = listItem.props; - const { - focusListItemAtIndex, - followHrefAtIndex, - toggleCheckboxAtIndex, - listItemAttributes, - listItemClassNames, - listItemChildrenTabIndex, - } = this.state; + + if (!this.hasInitializedListItem) { + const isSelectedIndexArray = Array.isArray(selectedIndex) && selectedIndex.length > 0; + // if selectedIndex is populated then check if its a checkbox/radioList + if (selectedIndex && (isSelectedIndexArray || selectedIndex > -1)) { + const isCheckboxListSelected + = checkboxList && Array.isArray(selectedIndex) && selectedIndex.indexOf(index) > -1; + const isNonCheckboxListSelected = selectedIndex === index; + if (isCheckboxListSelected || isNonCheckboxListSelected) { + tabIndex = {tabIndex: 0}; + this.hasInitializedListItem = true; + } + // set tabIndex=0 to first listItem if selectedIndex is not populated + } else { + tabIndex = {tabIndex: 0}; + this.hasInitializedListItem = true; + } + } + + const props = { + // otherProps must come first ...otherProps, + checkboxList, + radioList, onKeyDown: (e: React.KeyboardEvent) => { onKeyDown!(e); this.handleKeyDown(e, index); @@ -335,12 +373,7 @@ export default class List extends React.Com onBlur!(e); this.handleBlur(e, index); }, - shouldFocus: focusListItemAtIndex === index, - shouldFollowHref: followHrefAtIndex === index, - shouldToggleCheckbox: toggleCheckboxAtIndex === index, - attributesFromList: listItemAttributes[index], - classNamesFromList: listItemClassNames[index], - childrenTabIndex: listItemChildrenTabIndex[index], + ...tabIndex, }; return React.cloneElement(listItem, props); }; diff --git a/packages/list/package.json b/packages/list/package.json index 31efd3464..4dd10a784 100644 --- a/packages/list/package.json +++ b/packages/list/package.json @@ -16,7 +16,9 @@ "url": "https://github.com/material-components/material-components-web-react.git" }, "dependencies": { - "@material/list": "^0.41.0", + "@material/list": "^1.0.0", + "@material/react-checkbox": "^0.10.0", + "@material/react-radio": "^0.10.0", "classnames": "^2.2.6", "react": "^16.4.2" }, diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json index 69529c0ae..f8d32efa7 100644 --- a/test/screenshot/golden.json +++ b/test/screenshot/golden.json @@ -9,7 +9,7 @@ "layout-grid": "fe40f7a34853bc2a1d9a836e812599d4d47b5b26b839a8eaed7f98ea91946790", "line-ripple": "56b136db2dc7e09260849447e6bde9b55a837af332a05d9f52506ab1c95e2e57", "linear-progress": "f7a23058842b37875a02b3562e0b9c82f7fb24f9b88e9727d10b921b001d115e", - "list": "a53f22639b6eae05613ffd0bb943ede1e7edba4a50d28463389a00828896a644", + "list": "4dcdfeb03d781eca5367f6cbf4c7ebd13d1fe8932ca5758520b994d541f49095", "material-icon": "442b39fb22d2c7a74efb23ca098429b471501ce21df8662327bbf9871fe0bcb0", "menu-surface": "f5face1a24fe166e86e8a3dc35ea85b2d4431469a3d06bf6fc1a30fbdc175aff", "notched-outline": "7770dd381c27608a1f43b6f83da92507fe53963f5e4409bd73184b86275538fe", @@ -40,7 +40,7 @@ "top-app-bar/prominentDense": "cc8af934f9187ffd8f250834ef7c73e5c53c5ace10126bb855f74878ba125149", "top-app-bar/twoRows": "a7c025c8c04123d377f53a3e3e888eb4de78439b9dcd76c306e67712934f9ed1", "dialog/alert": "e0c9b5fd4468c5fff9902b4e8e79c488b0fe8300b08a9620f32ed380b5c2af79", - "dialog/simple": "2b72930b7711c258d4fead83055ec02b10edebda722901e7ed3ecb7365827dd1", + "dialog/simple": "3cc0622d5a4976418ae24deebe523cebf31318569a3710a823279fa6f94a371a", "dialog/confirmation": "0d548366130de2aeb85d993a0e9d2030840db0ba10bc49b2721ae05cbd42b058", "dialog/scrollable": "91bf3789cc22928df667c96a8a7f1cfdcaad4868c55ab5d830d132249a620f1c", "drawer/permanentBelowTopAppBar": "587ee2605c4b3e3f0408c6130b824b58587e05cedf9b964e14fc481b9de1e4c2", diff --git a/test/screenshot/list/index.tsx b/test/screenshot/list/index.tsx index db96a2f3e..3c1e6e0ee 100644 --- a/test/screenshot/list/index.tsx +++ b/test/screenshot/list/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import './index.scss'; import '../../../packages/list/index.scss'; -import MaterialIcon from '../../../packages/material-icon'; +import MaterialIcon from '../../../packages/material-icon/index'; import List, { ListItem, ListItemGraphic, @@ -11,12 +11,16 @@ import List, { ListGroup, ListGroupSubheader, } from '../../../packages/list/index'; +import Checkbox from '../../../packages/checkbox/index'; +import Radio, {NativeRadioControl} from '../../../packages/radio/index'; import {ListItemTextProps} from '../../../packages/list/ListItemText'; // eslint-disable-line no-unused-vars +import {MDCListIndex} from '@material/list/types'; -// no .d.ts file -// @ts-ignore +// @ts-ignore no .d.ts file import * as uuidv4 from 'uuid/v4'; +const groceryItems = ['Milk', 'Eggs', 'Barley']; + interface SelectionListTestState { selectedIndex: number; listItems: string[]; @@ -65,6 +69,77 @@ class SelectionListTest extends React.Component<{}, SelectionListTestState> { } } +class CheckboxList extends React.Component<{}, {selectedIndex: MDCListIndex}> { + state = { + selectedIndex: [1], + }; + + handleSelect = (_selectedIndex: number, selected: MDCListIndex) => { + this.setState({selectedIndex: selected}); + } + + render() { + return ( + +
+ Selected index: {this.state.selectedIndex} +
+ + {groceryItems.map((item, index) => ( + + + + + ))} + +
+ ); + } +} + +class RadioList extends React.Component<{}, {selectedItem: string}> { + state = { + selectedItem: 'Milk', + }; + + handleChange = (e: React.ChangeEvent) => { + this.setState({selectedItem: e.target.value}); + } + + render() { + return ( + +
+ Selected index: {this.state.selectedItem} +
+ + {groceryItems.map((item, index) => ( + + + + + + + ))} + +
+ ); + } +} + const ListScreenshotTest = () => { return (
@@ -95,6 +170,11 @@ const ListScreenshotTest = () => { {renderListItem({primaryText: 'Kitchen remodel'})} + +

Checkbox List

+ +

Radio List

+
); }; diff --git a/test/unit/list/ListItem.test.tsx b/test/unit/list/ListItem.test.tsx index 23d601dfa..9e4292049 100644 --- a/test/unit/list/ListItem.test.tsx +++ b/test/unit/list/ListItem.test.tsx @@ -1,9 +1,7 @@ import * as React from 'react'; import {assert} from 'chai'; import {mount, shallow} from 'enzyme'; -import * as td from 'testdouble'; import {ListItem} from '../../../packages/list/index'; -import {coerceForTesting} from '../helpers/types'; suite('ListItem'); @@ -12,61 +10,44 @@ test('classNames adds classes', () => { assert.isTrue(wrapper.hasClass('test-class-name')); }); -test('classNamesFromList adds classes', () => { - const wrapper = shallow( -
meow
- ); - assert.isTrue(wrapper.hasClass('test-class-name')); +test('has mdc-list-item classname', () => { + const wrapper = shallow(
meow
); + assert.isTrue(wrapper.hasClass('mdc-list-item')); }); -test('attributesFromList adds props', () => { - const wrapper = shallow(
meow
); - assert.equal(wrapper.props().tabIndex, 0); +test('has activated class if props.activated = true', () => { + const wrapper = shallow(
meow
); + assert.isTrue(wrapper.hasClass('mdc-list-item--activated')); }); -test('calls focus when props.shouldFocus changes from false to true', () => { - const wrapper = mount>(
meow
); - wrapper.instance().focus = coerceForTesting<() => void>(td.func()); - wrapper.setProps({shouldFocus: true}); - td.verify(wrapper.instance().focus(), {times: 1}); +test('has selected class if props.selected = true', () => { + const wrapper = shallow(
meow
); + assert.isTrue(wrapper.hasClass('mdc-list-item--selected')); }); -test('calls followHref when props.shouldFollowHref changes from false to true', () => { - const wrapper = mount>(
meow
); - wrapper.instance().followHref = coerceForTesting<() => void>(td.func()); - wrapper.setProps({shouldFollowHref: true}); - td.verify(wrapper.instance().followHref(), {times: 1}); +test('has role=checkbox if props.checkboxList = true', () => { + const wrapper = shallow(
meow
); + assert.equal(wrapper.props().role, 'checkbox'); }); -test('calls toggleCheckbox when props.shouldToggleCheckbox changes from false to true', () => { - const wrapper = mount>(
meow
); - wrapper.instance().toggleCheckbox = coerceForTesting<() => void>(td.func()); - wrapper.setProps({shouldToggleCheckbox: true}); - td.verify(wrapper.instance().toggleCheckbox(), {times: 1}); +test('has role=radio if props.radioList = true', () => { + const wrapper = shallow(
meow
); + assert.equal(wrapper.props().role, 'radio'); }); -test('#focus focuses the listItemElement_', () => { - const wrapper = mount>(
meow
); - wrapper.instance().listItemElement_.current!.focus = coerceForTesting<() => void>(td.func()); - wrapper.instance().focus(); - td.verify(wrapper.instance().listItemElement_.current!.focus(), {times: 1}); +test('has role=menu if props.role = menu', () => { + const wrapper = shallow(
meow
); + assert.equal(wrapper.props().role, 'menu'); }); -test('#followHref simulates a click on the listItemElement_ if it has href', () => { - const wrapper = mount>(
meow
); - wrapper.instance().listItemElement_.current!.href = 'https://google.com'; - wrapper.instance().listItemElement_.current!.click = coerceForTesting<() => void>(td.func()); - wrapper.instance().followHref(); - td.verify(wrapper.instance().listItemElement_.current!.click(), {times: 1}); +test('is anchor tag if tag=a', () => { + const wrapper = mount>(
meow
); + assert.equal(wrapper.find('a').length, 1); }); -test('passes props.childrenTabIndex to children as props.tabIndex', () => { - const wrapper = mount( - -
- - ); - assert.equal(wrapper.find('.list-item-child').props().tabIndex, 2); +test('listitem can have href tag', () => { + const wrapper = mount>(
meow
); + assert.equal(wrapper.props().href, 'google.com'); }); test('renders a list item with default tag', () => { diff --git a/test/unit/list/index.test.tsx b/test/unit/list/index.test.tsx index bc57390e3..5209798af 100644 --- a/test/unit/list/index.test.tsx +++ b/test/unit/list/index.test.tsx @@ -6,14 +6,23 @@ import List, { ListItem, ListItemProps, // eslint-disable-line no-unused-vars } from '../../../packages/list/index'; import {coerceForTesting} from '../helpers/types'; +import {MDCListIndex} from '@material/list/types'; suite('List'); -const children: (key?: number) => React.ReactElement> - = (key: number = 0) => (
meow
); +const children = (opts?: { + key?: number, + hasCheckbox?: boolean + }): React.ReactElement> => ( + +
meow
+ {opts && opts.hasCheckbox ? : null} +
+); -const threeChildren: () => React.ReactElement>[] - = () => [0, 1, 2].map((key) => children(key)); +const threeChildren = (): React.ReactElement>[] => ( + [0, 1, 2].map((key) => children({key})) +); test('creates foundation', () => { const wrapper = shallow( @@ -25,14 +34,14 @@ test('creates foundation', () => { test('#componentWillUnmount destroys foundation', () => { const wrapper = shallow({children()}); const foundation = wrapper.instance().foundation; - foundation.destroy = td.func(); + foundation.destroy = coerceForTesting<() => void>(td.func()); wrapper.unmount(); td.verify(foundation.destroy()); }); test('calls foundation.setSingleSelection when props.singleSelection changes from false to true', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setSingleSelection = td.func(); + wrapper.instance().foundation.setSingleSelection = coerceForTesting<(arg: boolean) => void>(td.func()); wrapper.setProps({singleSelection: true}); td.verify(wrapper.instance().foundation.setSingleSelection(true), { times: 1, @@ -41,7 +50,7 @@ test('calls foundation.setSingleSelection when props.singleSelection changes fro test('calls foundation.setSingleSelection when props.singleSelection changes from true to false', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setSingleSelection = td.func(); + wrapper.instance().foundation.setSingleSelection = coerceForTesting<(arg: boolean) => void>(td.func()); wrapper.setProps({singleSelection: false}); td.verify(wrapper.instance().foundation.setSingleSelection(false), { times: 1, @@ -50,28 +59,28 @@ test('calls foundation.setSingleSelection when props.singleSelection changes fro test('calls foundation.setWrapFocus when props.wrapFocus changes from false to true', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setWrapFocus = td.func(); + wrapper.instance().foundation.setWrapFocus = coerceForTesting<(arg: boolean) => void>(td.func()); wrapper.setProps({wrapFocus: true}); td.verify(wrapper.instance().foundation.setWrapFocus(true), {times: 1}); }); test('calls foundation.setWrapFocus when props.wrapFocus changes from true to false', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setWrapFocus = td.func(); + wrapper.instance().foundation.setWrapFocus = coerceForTesting<(arg: boolean) => void>(td.func()); wrapper.setProps({wrapFocus: false}); td.verify(wrapper.instance().foundation.setWrapFocus(false), {times: 1}); }); test('calls foundation.setSelectedIndex when props.selectedIndex changes', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setSelectedIndex = td.func(); + wrapper.instance().foundation.setSelectedIndex = coerceForTesting<(arg: MDCListIndex) => void>(td.func()); wrapper.setProps({selectedIndex: 1}); td.verify(wrapper.instance().foundation.setSelectedIndex(1), {times: 1}); }); test('calls foundation.setVerticalOrientation when \'aria-orientation\' changes from vertical to horizontal', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setVerticalOrientation = td.func(); + wrapper.instance().foundation.setVerticalOrientation = coerceForTesting<(arg: boolean) => void>(td.func()); wrapper.setProps({'aria-orientation': 'horizontal'}); td.verify(wrapper.instance().foundation.setVerticalOrientation(false), { times: 1, @@ -80,7 +89,7 @@ test('calls foundation.setVerticalOrientation when \'aria-orientation\' changes test('calls foundation.setVerticalOrientation when \'aria-orientation\' changes from horizontal to vertical', () => { const wrapper = mount({children()}); - wrapper.instance().foundation.setVerticalOrientation = td.func(); + wrapper.instance().foundation.setVerticalOrientation = coerceForTesting<(arg: boolean) => void>(td.func()); wrapper.setProps({'aria-orientation': 'vertical'}); td.verify(wrapper.instance().foundation.setVerticalOrientation(true), { times: 1, @@ -132,266 +141,308 @@ test('#adapter.getListItemCount returns correct number of list items if List has assert.equal(wrapper.instance().adapter.getListItemCount(), 3); }); -test('#adapter.setAttributeForElementIndex updates state.listItemAttributes', () => { +test('#adapter.setAttributeForElementIndex calls setAttribute on listItem', () => { const wrapper = mount({children()}); - wrapper.instance().adapter.setAttributeForElementIndex(1, 'tabindex', '0'); - assert.equal(wrapper.state().listItemAttributes[1]['tabIndex'], 0); + wrapper.instance().listElements[0].setAttribute = coerceForTesting<(key: string, value: string) => void>(td.func()); + wrapper.instance().adapter.setAttributeForElementIndex(0, 'role', 'menu'); + td.verify(wrapper.instance().listElements[0].setAttribute('role', 'menu'), {times: 1}); }); -test('#adapter.removeAttributeForElementIndex removes attribute from state.listItemAttributes if it exists', () => { - const wrapper = mount({children()}); - wrapper.setState({listItemAttributes: {1: {tabIndex: 0}}}); - wrapper.instance().adapter.removeAttributeForElementIndex(1, 'tabindex'); - assert.isFalse( - wrapper.state().listItemAttributes[1].hasOwnProperty('tabIndex') - ); +test('#adapter.setAttributeForElementIndex call nothing when no children exist', () => { + const wrapper = mount(); + assert.doesNotThrow(() => wrapper.instance().adapter.setAttributeForElementIndex(0, 'role', 'menu')); }); -test('#adapter.removeAttributeForElementIndex does nothing if attribute is not in state.listItemAttributes', () => { +test('#adapter.addClassForElementIndex adds class to classList', () => { const wrapper = mount({children()}); - wrapper.instance().adapter.removeAttributeForElementIndex(1, 'tabindex'); - assert.isFalse(wrapper.state().listItemAttributes.hasOwnProperty(1)); + wrapper.instance().adapter.addClassForElementIndex = + coerceForTesting<(index: number, className: string) => void>(td.func()); + wrapper.instance().adapter.addClassForElementIndex(0, 'class4321'); + const listItem = wrapper.childAt(0).childAt(0); + assert.isTrue(listItem.html().includes('class4321')); + assert.isTrue(listItem.html().includes('mdc-list-item')); }); -test('#adapter.addClassForElementIndex updates state.listItemClassNames if no classes have been added', () => { - const wrapper = mount({children()}); - wrapper.instance().adapter.addClassForElementIndex(1, 'class1'); - assert.isTrue(wrapper.state().listItemClassNames[1].indexOf('class1') >= 0); +test('#adapter.addClassForElementIndex adds class to classList and keeps props.className', () => { + const wrapper = mount( +
meow
+
); + wrapper.instance().adapter.addClassForElementIndex = + coerceForTesting<(index: number, className: string) => void>(td.func()); + wrapper.instance().adapter.addClassForElementIndex(0, 'class4321'); + const listItem = wrapper.childAt(0).childAt(0); + assert.isTrue(listItem.html().includes('class4321')); + assert.isTrue(listItem.html().includes('class987')); + assert.isTrue(listItem.html().includes('mdc-list-item')); +}); + +test('#adapter.removeClassForElementIndex adds class to classList and keeps props.className', () => { + const wrapper = mount( +
meow
+
); + wrapper.instance().adapter.removeClassForElementIndex = + coerceForTesting<(index: number, className: string) => void>(td.func()); + wrapper.instance().adapter.removeClassForElementIndex(0, 'class4321'); + const listItem = wrapper.childAt(0).childAt(0); + assert.isFalse(listItem.html().includes('class4321')); + assert.isTrue(listItem.html().includes('class987')); + assert.isTrue(listItem.html().includes('mdc-list-item')); +}); + +test('#adapter.setTabIndexForListItemChildren updates the button and anchor tag to have tabindex=0', () => { + const wrapper = mount( + + + link + + ); + wrapper.instance().adapter.setTabIndexForListItemChildren(0, '0'); + const listItem = wrapper.childAt(0).childAt(0); + assert.isTrue(listItem.find('a').html().includes('tabindex="0"')); + assert.isTrue(listItem.find('button').html().includes('tabindex="0"')); }); -test('#adapter.addClassForElementIndex updates state.listItemClassNames if other classes have been added', () => { +test('#adapter.focusItemAtIndex calls focus on the listitem', () => { const wrapper = mount({children()}); - wrapper.state().listItemClassNames[1] = ['test']; - wrapper.instance().adapter.addClassForElementIndex(1, 'class1'); - assert.isTrue(wrapper.state().listItemClassNames[1].indexOf('class1') >= 0); - assert.isTrue(wrapper.state().listItemClassNames[1].indexOf('test') >= 0); + (wrapper.instance().listElements[0] as HTMLElement).focus + = coerceForTesting<(opts?: any) => void>(td.func()); + wrapper.instance().adapter.focusItemAtIndex(0); + td.verify((wrapper.instance().listElements[0] as HTMLElement).focus(), {times: 1}); }); -test('#adapter.removeClassForElementIndex removes class from state.listItemClassNames if it exists', () => { - const wrapper = mount({children()}); - wrapper.state().listItemClassNames[1] = ['class1']; - wrapper.instance().adapter.removeClassForElementIndex(1, 'class1'); - assert.isFalse(wrapper.state().listItemClassNames[1].indexOf('class1') >= 0); +test('#adapter.focusItemAtIndex call nothing when no children exist', () => { + const wrapper = mount(); + assert.doesNotThrow(() => wrapper.instance().adapter.focusItemAtIndex(0)); }); -test('#adapter.removeClassForElementIndex does nothing if class is not in state.listItemClassNames', () => { - const wrapper = mount({children()}); - wrapper.state().listItemClassNames[1] = []; - wrapper.instance().adapter.removeClassForElementIndex(1, 'class1'); - assert.isFalse(wrapper.state().listItemClassNames[1].indexOf('class1') >= 0); +test('#adapter.setCheckedCheckboxOrRadioAtIndex does not throw', () => { + const wrapper = shallow({children()}); + assert.doesNotThrow(() => wrapper.instance().adapter.setCheckedCheckboxOrRadioAtIndex(0, true)); }); -test('#adapter.removeClassForElementIndex does nothing if index is not in state.listItemClassNames', () => { +test('#adapter.hasCheckboxAtIndex returns false with no checkbox', () => { const wrapper = mount({children()}); - wrapper.instance().adapter.removeClassForElementIndex(1, 'class1'); - assert.isFalse(wrapper.state().listItemClassNames.hasOwnProperty(1)); + assert.isFalse(wrapper.instance().adapter.hasCheckboxAtIndex(0)); }); -test('#adapter.setTabIndexForListItemChildren updates state.listItemChildrenTabIndex', () => { +test('#adapter.hasCheckboxAtIndex returns true with checkbox', () => { + const wrapper = mount( + + + + ); + assert.isTrue(wrapper.instance().adapter.hasCheckboxAtIndex(0)); +}); + +test('#adapter.hasRadioAtIndex returns false with no checkbox', () => { const wrapper = mount({children()}); - // wrapper.state().listItemClassNames[1] = -1; - wrapper.instance().adapter.setTabIndexForListItemChildren(1, 0); - assert.equal(wrapper.state().listItemChildrenTabIndex[1], 0); + assert.isFalse(wrapper.instance().adapter.hasRadioAtIndex(0)); }); -test('#adapter.focusItemAtIndex sets state.focusListItemAtIndex', () => { - const wrapper = shallow({children()}); - wrapper.instance().adapter.focusItemAtIndex(1); - assert.equal(wrapper.state().focusListItemAtIndex, 1); +test('#adapter.hasRadioAtIndex returns true with checkbox', () => { + const wrapper = mount( + + + + ); + assert.isTrue(wrapper.instance().adapter.hasRadioAtIndex(0)); }); -test('#adapter.followHref sets state.followHrefAtIndex', () => { - const wrapper = shallow({children()}); - wrapper.instance().adapter.followHref(1); - assert.equal(wrapper.state().followHrefAtIndex, 1); +test('#adapter.isCheckboxCheckedAtIndex returns false with a non-checked checkbox', () => { + const wrapper = mount( + + + + ); + assert.isFalse(wrapper.instance().adapter.isCheckboxCheckedAtIndex(0)); }); -test('#adapter.toggleCheckbox sets state.toggleCheckboxAtIndex', () => { - const wrapper = shallow({children()}); - wrapper.instance().adapter.toggleCheckbox(1); - assert.equal(wrapper.state().toggleCheckboxAtIndex, 1); +test('#adapter.isCheckboxCheckedAtIndex returns true with a checked checkbox', () => { + const wrapper = mount( + + {/* empty onChange to avoid a warning */} + {}} /> + + ); + assert.isTrue(wrapper.instance().adapter.isCheckboxCheckedAtIndex(0)); +}); + +test('#adapter.isFocusInsideList returns true if the list element has focus inside', () => { + const div = document.createElement('div'); + // needs to be attached to real DOM to get width + // https://github.com/airbnb/enzyme/issues/1525 + document.body.append(div); + const options = {attachTo: div}; + const wrapper = mount( + + + + , options); + wrapper.instance().listElements[0].querySelector('button')!.focus(); + assert.isTrue(wrapper.instance().adapter.isFocusInsideList()); + div.remove(); +}); + +test('#adapter.isFocusInsideList returns true if the list element has focus inside', () => { + const wrapper = mount( + + + + ); + assert.isFalse(wrapper.instance().adapter.isFocusInsideList()); +}); + +test('#adapter.notifyAction calls props.handleSelect with args', () => { + const handleSelect = coerceForTesting<(selectedIndex: number, selected: MDCListIndex) => void>(td.func()); + const wrapper = mount({children}); + wrapper.instance().adapter.notifyAction(0); + td.verify(handleSelect(0, -1), {times: 1}); +}); + +test('renders with mdc-list class', () => { + const wrapper = shallow({children}); + assert.isTrue(wrapper.hasClass('mdc-list')); +}); + +test('renders with className', () => { + const wrapper = shallow({children}); + assert.isTrue(wrapper.hasClass('test-class')); + assert.isTrue(wrapper.hasClass('mdc-list')); +}); + +test('renders with mdc-list--non-interactive when non-interactive', () => { + const wrapper = shallow({children}); + assert.isTrue(wrapper.hasClass('mdc-list--non-interactive')); + assert.isTrue(wrapper.hasClass('mdc-list')); +}); + +test('renders with mdc-list--dense when dense', () => { + const wrapper = shallow({children}); + assert.isTrue(wrapper.hasClass('mdc-list--dense')); + assert.isTrue(wrapper.hasClass('mdc-list')); +}); + +test('renders with mdc-list--avatar-list when avatar-list', () => { + const wrapper = shallow({children}); + assert.isTrue(wrapper.hasClass('mdc-list--avatar-list')); + assert.isTrue(wrapper.hasClass('mdc-list')); +}); + +test('renders with mdc-list--two-line when two-line', () => { + const wrapper = shallow({children}); + assert.isTrue(wrapper.hasClass('mdc-list--two-line')); + assert.isTrue(wrapper.hasClass('mdc-list')); +}); + +test('renders with role=group if props.checkboxList', () => { + const wrapper = shallow({children}); + assert.equal(wrapper.props().role, 'group'); +}); + +test('renders with role=radiogroup if props.radioList', () => { + const wrapper = shallow({children}); + assert.equal(wrapper.props().role, 'radiogroup'); +}); + +test('renders with role=menu if props.role=menu', () => { + const wrapper = shallow({children}); + assert.equal(wrapper.props().role, 'menu'); +}); + +test('renders with role=menu if props.role=menu and props.checkboxList', () => { + const wrapper = shallow({children}); + assert.equal(wrapper.props().role, 'menu'); }); + test('#handleKeyDown calls #foudation.handleKeydown', () => { const wrapper = shallow({children()}); - wrapper.instance().foundation.handleKeydown = td.func(); + wrapper.instance().foundation.handleKeydown + = coerceForTesting<(evt: KeyboardEvent, isItem: boolean, itemIndex: number) => void>(td.func()); const evt = coerceForTesting({persist: () => {}}); wrapper.instance().handleKeyDown(evt, 1); - td.verify(wrapper.instance().foundation.handleKeydown(evt, true, 1), { + td.verify(wrapper.instance().foundation.handleKeydown(evt.nativeEvent, true, 1), { times: 1, }); }); -test('#handleKeyDown calls #props.handleSelect if key is enter', () => { - const handleSelect = coerceForTesting<(selectedIndex: number) => void>(td.func()); - const wrapper = shallow({children()}); - const evt = coerceForTesting({persist: () => {}, key: 'Enter'}); - wrapper.instance().handleKeyDown(evt, 1); - td.verify(handleSelect(1), {times: 1}); -}); - test('#handleClick calls #foudation.handleClick', () => { const wrapper = shallow({children()}); const target = {type: 'span'}; const evt = coerceForTesting>({target}); - wrapper.instance().foundation.handleClick = td.func(); + wrapper.instance().foundation.handleClick + = coerceForTesting<(index: number, toggleCheckbox: boolean) => void>(td.func()); wrapper.instance().handleClick(evt, 1); td.verify(wrapper.instance().foundation.handleClick(1, false), {times: 1}); }); -test('#handleClick calls #props.handleSelect', () => { - const handleSelect = coerceForTesting<(selectedIndex: number) => void>(td.func()); - const target = {type: 'span'}; - const evt = coerceForTesting>({target}); - const wrapper = shallow({children()}); - wrapper.instance().handleClick(evt, 1); - td.verify(handleSelect(1), {times: 1}); -}); - test('#handleFocus calls #foudation.handleFocusIn', () => { const wrapper = shallow({children()}); - wrapper.instance().foundation.handleFocusIn = td.func(); + wrapper.instance().foundation.handleFocusIn + = coerceForTesting<(e: FocusEvent, itemIndex: number) => void>(td.func()); const evt = coerceForTesting({}); wrapper.instance().handleFocus(evt, 1); - td.verify(wrapper.instance().foundation.handleFocusIn(evt, 1), {times: 1}); + td.verify(wrapper.instance().foundation.handleFocusIn(evt.nativeEvent, 1), {times: 1}); }); test('#handleBlur calls #foudation.handleFocusOut', () => { const wrapper = shallow({children()}); - wrapper.instance().foundation.handleFocusOut = td.func(); + wrapper.instance().foundation.handleFocusOut + = coerceForTesting<(e: FocusEvent, itemIndex: number) => void>(td.func()); const evt = coerceForTesting({}); wrapper.instance().handleBlur(evt, 1); - td.verify(wrapper.instance().foundation.handleFocusOut(evt, 1), { + td.verify(wrapper.instance().foundation.handleFocusOut(evt.nativeEvent, 1), { times: 1, }); }); -test('#renderListItem renders default list item at index 0', () => { +test('renders 3 list items', () => { const wrapper = mount( {threeChildren()} ); - const listItemProps = wrapper.children().props().children[0].props; - assert.isFalse(listItemProps.shouldFocus); - assert.isFalse(listItemProps.shouldFollowHref); - assert.isFalse(listItemProps.shouldToggleCheckbox); - assert.equal(listItemProps.attributesFromList['tabIndex'], 0); - assert.isEmpty(listItemProps.classNamesFromList); - assert.equal(listItemProps.childrenTabIndex, -1); + assert.equal(wrapper.childAt(0).children().length, 3); }); -test('#renderListItem renders default list item at index not 0', () => { +test('renders list items with tabindex=-1 and first with tabindex=0', () => { const wrapper = mount( {threeChildren()} ); - const listItemProps = wrapper.children().props().children[1].props; - assert.isFalse(listItemProps.shouldFocus); - assert.isFalse(listItemProps.shouldFollowHref); - assert.isFalse(listItemProps.shouldToggleCheckbox); - assert.isEmpty(listItemProps.attributesFromList); - assert.isEmpty(listItemProps.classNamesFromList); - assert.equal(listItemProps.childrenTabIndex, -1); + const list = wrapper.childAt(0); + assert.equal(list.childAt(0).props().tabIndex, 0); + assert.equal(list.childAt(1).props().tabIndex, -1); + assert.equal(list.childAt(2).props().tabIndex, -1); }); -test('#renderListItem renders list item with prop.shouldFocus true if its index is state.focusListItemAtIndex', () => { +test('renders list items with tabindex=-1 and child at props.selectedIndex tabindex=0', () => { const wrapper = mount( - + {threeChildren()} ); - wrapper.setState({focusListItemAtIndex: 1}); - const children = wrapper.children().props().children; - assert.isFalse(children[0].props.shouldFocus); - assert.isTrue(children[1].props.shouldFocus); - assert.isFalse(children[2].props.shouldFocus); + const list = wrapper.childAt(0); + assert.equal(list.childAt(0).props().tabIndex, -1); + assert.equal(list.childAt(1).props().tabIndex, 0); + assert.equal(list.childAt(2).props().tabIndex, -1); }); -test( - '#renderListItem renders list item with prop.shouldFollowHref true ' + - 'if its index is state.followHrefAtIndex', +test('renders list items with tabindex=-1 and child at props.selectedIndex tabindex=0 as an array', () => { const wrapper = mount( - - {threeChildren()} + + {children({key: 0, hasCheckbox: true})} + {children({key: 1, hasCheckbox: true})} + {children({key: 2, hasCheckbox: true})} ); - wrapper.setState({followHrefAtIndex: 1}); - const children = wrapper.children().props().children; - assert.isFalse(children[0].props.shouldFollowHref); - assert.isTrue(children[1].props.shouldFollowHref); - assert.isFalse(children[2].props.shouldFollowHref); - } -); - -test( - '#renderListItem renders list item with prop.shouldToggleCheckbox true ' + - 'if its index is state.toggleCheckboxAtIndex', - () => { - const wrapper = mount( - - {threeChildren()} - - ); - wrapper.setState({toggleCheckboxAtIndex: 1}); - const children = wrapper.children().props().children; - assert.isFalse(children[0].props.shouldToggleCheckbox); - assert.isTrue(children[1].props.shouldToggleCheckbox); - assert.isFalse(children[2].props.shouldToggleCheckbox); - } -); - -test('#renderListItem renders list item with state.listItemAttributes at index as prop.attributesFromList', () => { - const wrapper = mount( - - {threeChildren()} - - ); - const attributes = {tabIndex: 0}; - wrapper.setState({listItemAttributes: {1: attributes}}); - const children = wrapper.children().props().children; - assert.isEmpty(children[0].props.attributesFromList); - assert.equal(children[1].props.attributesFromList, attributes); - assert.isEmpty(children[2].props.attributesFromList); -}); - -test('#renderListItem renders list item with state.listItemClassNames at index as prop.classNamesFromList', () => { - const wrapper = mount( - - {threeChildren()} - - ); - const classes = ['test-class']; - wrapper.setState({listItemClassNames: {1: classes}}); - const children = wrapper.children().props().children; - assert.isEmpty(children[0].props.classNamesFromList); - assert.equal(children[1].props.classNamesFromList, classes); - assert.isEmpty(children[2].props.classNamesFromList); -}); - -test('#renderListItem renders list item with state.listItemChildrenTabIndex at index as prop.childrenTabIndex', () => { - const wrapper = mount( - - {threeChildren()} - - ); - wrapper.setState({listItemChildrenTabIndex: {1: 0}}); - const children = wrapper.children().props().children; - assert.equal(children[0].props.childrenTabIndex, -1); - assert.equal(children[1].props.childrenTabIndex, 0); - assert.equal(children[2].props.childrenTabIndex, -1); -}); - -test('first item is selected if props.selectedIndex is 0', () => { - const wrapper = mount( - - {threeChildren()} - - ); - assert.isTrue(wrapper.state().listItemAttributes[0]['aria-selected']); -}); + const list = wrapper.childAt(0); + assert.equal(list.childAt(0).props().tabIndex, -1); + assert.equal(list.childAt(1).props().tabIndex, 0); + assert.equal(list.childAt(2).props().tabIndex, -1); + }); test('renders a list with default tag', () => { const wrapper = shallow({children()});