Skip to content

Commit

Permalink
[Joy UI] Add ListSubheader component (#34191)
Browse files Browse the repository at this point in the history
  • Loading branch information
siriwatknp committed Sep 8, 2022
1 parent 7bff521 commit 04027ec
Show file tree
Hide file tree
Showing 19 changed files with 458 additions and 74 deletions.
23 changes: 20 additions & 3 deletions docs/data/joy/components/list/ListUsage.js
Expand Up @@ -6,7 +6,9 @@ import ListItemDecorator from '@mui/joy/ListItemDecorator';
import ListItemButton from '@mui/joy/ListItemButton';
import Home from '@mui/icons-material/Home';
import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight';
import JoyUsageDemo from 'docs/src/modules/components/JoyUsageDemo';
import JoyUsageDemo, {
prependLinesSpace,
} from 'docs/src/modules/components/JoyUsageDemo';

export default function ListUsage() {
return (
Expand All @@ -27,10 +29,25 @@ export default function ListUsage() {
{
propName: 'selected',
knob: 'switch',
defaultValue: true,
codeBlockDisplay: true,
defaultValue: false,
},
{
propName: 'disabled',
knob: 'switch',
defaultValue: false,
},
{
propName: 'children',
defaultValue: `<ListItemDecorator><Home /></ListItemDecorator>
Home
<KeyboardArrowRight />`,
},
]}
getCodeBlock={(code) => `<List>
<ListItem>
${prependLinesSpace(code, 3)}
</ListItem>
</List>`}
renderDemo={(props) => (
<List sx={{ width: 240, my: 5 }}>
<ListItem>
Expand Down
28 changes: 6 additions & 22 deletions docs/data/joy/components/list/NestedList.js
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import Box from '@mui/joy/Box';
import List from '@mui/joy/List';
import ListItem from '@mui/joy/ListItem';
import ListSubheader from '@mui/joy/ListSubheader';
import ListItemButton from '@mui/joy/ListItemButton';
import Typography from '@mui/joy/Typography';
import Switch from '@mui/joy/Switch';
Expand All @@ -21,23 +22,15 @@ export default function NestedList() {
/>
<List
variant="outlined"
size={small ? 'sm' : undefined}
sx={{
width: 200,
borderRadius: 'sm',
}}
>
<ListItem nested>
<ListItem component="div">
<Typography
id="nested-list-demo-1"
level="body3"
textTransform="uppercase"
fontWeight="lg"
>
Category 1
</Typography>
</ListItem>
<List aria-labelledby="nested-list-demo-1" size={small ? 'sm' : undefined}>
<ListSubheader>Category 1</ListSubheader>
<List>
<ListItem>
<ListItemButton>Subitem 1</ListItemButton>
</ListItem>
Expand All @@ -47,17 +40,8 @@ export default function NestedList() {
</List>
</ListItem>
<ListItem nested>
<ListItem component="div">
<Typography
id="nested-list-demo-2"
level="body3"
textTransform="uppercase"
fontWeight="lg"
>
Category 2
</Typography>
</ListItem>
<List aria-labelledby="nested-list-demo-2" size={small ? 'sm' : undefined}>
<ListSubheader>Category 2</ListSubheader>
<List>
<ListItem>
<ListItemButton>Subitem 1</ListItemButton>
</ListItem>
Expand Down
15 changes: 3 additions & 12 deletions docs/data/joy/components/list/StickyList.js
@@ -1,8 +1,8 @@
import * as React from 'react';
import List from '@mui/joy/List';
import ListItem from '@mui/joy/ListItem';
import ListSubheader from '@mui/joy/ListSubheader';
import ListItemButton from '@mui/joy/ListItemButton';
import Typography from '@mui/joy/Typography';
import Sheet from '@mui/joy/Sheet';

export default function StickyList() {
Expand All @@ -17,17 +17,8 @@ export default function StickyList() {
<List>
{[...Array(5)].map((_, categoryIndex) => (
<ListItem nested key={categoryIndex}>
<ListItem component="div" sticky>
<Typography
id={`sticky-list-demo-${categoryIndex}`}
level="body3"
textTransform="uppercase"
fontWeight="lg"
>
Category {categoryIndex + 1}
</Typography>
</ListItem>
<List aria-labelledby={`sticky-list-demo-${categoryIndex}`}>
<ListSubheader sticky>Category {categoryIndex + 1}</ListSubheader>
<List>
{[...Array(10)].map((__, index) => (
<ListItem key={index}>
<ListItemButton>Subitem {index + 1}</ListItemButton>
Expand Down
3 changes: 2 additions & 1 deletion docs/data/joy/components/list/list.md
Expand Up @@ -18,6 +18,7 @@ Joy UI provides four list-related components:
- [`ListItemDecorator`](#decorator): A decorator of a list item, usually used to display an icon.
- [`ListItemContent`](#ellipsis-content): A container inside a list item, used to display text content.
- [`ListDivider`](#divider): A separator between list items.
- [`ListSubheader`](#nested-list): A label for a nested list.

{{"demo": "ListUsage.js", "hideToolbar": true}}

Expand All @@ -31,7 +32,7 @@ import ListItem from '@mui/joy/ListItem';

export default function MyApp() {
return (
<List aria-labelledby="basic-list-demo">
<List aria-label="basic-list">
<ListItem>Hello, world!</ListItem>
<ListItem>Bye bye, world!</ListItem>
</List>
Expand Down
1 change: 1 addition & 0 deletions packages/mui-joy/src/List/List.tsx
Expand Up @@ -183,6 +183,7 @@ const List = React.forwardRef(function List(inProps, ref) {
className={clsx(classes.root, className)}
ownerState={ownerState}
role={role}
aria-labelledby={typeof nesting === 'string' ? nesting : undefined}
{...other}
>
<ComponentListContext.Provider
Expand Down
2 changes: 1 addition & 1 deletion packages/mui-joy/src/List/ListProps.ts
Expand Up @@ -68,5 +68,5 @@ export interface ListOwnerState extends ListProps {
* @internal
* If `true`, the element is rendered in a nested list item.
*/
nesting: boolean;
nesting: boolean | string;
}
2 changes: 1 addition & 1 deletion packages/mui-joy/src/List/NestedListContext.ts
@@ -1,5 +1,5 @@
import * as React from 'react';

const NestedListContext = React.createContext(false);
const NestedListContext = React.createContext<boolean | string>(false);

export default NestedListContext;
37 changes: 37 additions & 0 deletions packages/mui-joy/src/ListItem/ListItem.test.js
Expand Up @@ -5,6 +5,7 @@ import { ThemeProvider } from '@mui/joy/styles';
import MenuList from '@mui/joy/MenuList';
import List from '@mui/joy/List';
import ListItem, { listItemClasses as classes } from '@mui/joy/ListItem';
import ListSubheader from '@mui/joy/ListSubheader';

describe('Joy <ListItem />', () => {
const { render } = createRenderer();
Expand Down Expand Up @@ -164,4 +165,40 @@ describe('Joy <ListItem />', () => {
expect(screen.getByText('Foo')).to.have.attribute('role', 'menuitem');
});
});

describe('NestedList', () => {
it('the nested list should be labelledby the subheader', () => {
const { getByRole, getByTestId } = render(
<ListItem nested>
<ListSubheader data-testid="subheader">Subheader</ListSubheader>
<List />
</ListItem>,
);

const subheader = getByTestId('subheader');

expect(getByRole('list')).to.have.attribute('aria-labelledby', subheader.id);
});

it('the aria-labelledby can be overridden', () => {
const { getByRole } = render(
<ListItem nested>
<ListSubheader data-testid="subheader">Subheader</ListSubheader>
<List aria-labelledby={undefined} />
</ListItem>,
);

expect(getByRole('list')).not.to.have.attribute('aria-labelledby');
});

it('the nested list should not be labelled without the subheader', () => {
const { getByRole } = render(
<ListItem nested>
<List />
</ListItem>,
);

expect(getByRole('list')).not.to.have.attribute('aria-labelledby');
});
});
});
72 changes: 39 additions & 33 deletions packages/mui-joy/src/ListItem/ListItem.tsx
Expand Up @@ -15,6 +15,7 @@ import NestedListContext from '../List/NestedListContext';
import RowListContext from '../List/RowListContext';
import WrapListContext from '../List/WrapListContext';
import ComponentListContext from '../List/ComponentListContext';
import ListSubheaderDispatch from '../ListSubheader/ListSubheaderContext';

const useUtilityClasses = (ownerState: ListItemOwnerState) => {
const { sticky, nested, nesting, variant, color } = ownerState;
Expand Down Expand Up @@ -96,6 +97,7 @@ const ListItemRoot = styled('li', {
fontSize: 'var(--List-item-fontSize)',
fontFamily: theme.vars.fontFamily.body,
...(ownerState.sticky && {
// sticky in list item can be found in grouped options
position: 'sticky',
top: 'var(--List-item-stickyTop, 0px)', // integration with Menu and Select.
zIndex: 1,
Expand Down Expand Up @@ -157,6 +159,8 @@ const ListItem = React.forwardRef(function ListItem(inProps, ref) {
...other
} = props;

const [subheaderId, setSubheaderId] = React.useState('');

const [listElement, listRole] = listComponent?.split(':') || ['', ''];
const component =
componentProp || (listElement && !listElement.match(/^(ul|ol|menu)$/) ? 'div' : undefined);
Expand Down Expand Up @@ -189,41 +193,43 @@ const ListItem = React.forwardRef(function ListItem(inProps, ref) {

const classes = useUtilityClasses(ownerState);
return (
<NestedListContext.Provider value={nested}>
<ListItemRoot
ref={ref}
as={component}
className={clsx(classes.root, className)}
ownerState={ownerState}
role={role}
{...other}
>
{startAction && (
<ListItemStartAction className={classes.startAction} ownerState={ownerState}>
{startAction}
</ListItemStartAction>
)}
<ListSubheaderDispatch.Provider value={setSubheaderId}>
<NestedListContext.Provider value={nested ? subheaderId || true : false}>
<ListItemRoot
ref={ref}
as={component}
className={clsx(classes.root, className)}
ownerState={ownerState}
role={role}
{...other}
>
{startAction && (
<ListItemStartAction className={classes.startAction} ownerState={ownerState}>
{startAction}
</ListItemStartAction>
)}

{React.Children.map(children, (child, index) =>
React.isValidElement(child)
? React.cloneElement(child, {
// to let ListItem knows when to apply margin(Inline|Block)Start
...(index === 0 && { 'data-first-child': '' }),
...(isMuiElement(child, ['ListItem']) && {
// The ListItem of ListItem should not be 'li'
component: child.props.component || 'div',
}),
})
: child,
)}
{React.Children.map(children, (child, index) =>
React.isValidElement(child)
? React.cloneElement(child, {
// to let ListItem knows when to apply margin(Inline|Block)Start
...(index === 0 && { 'data-first-child': '' }),
...(isMuiElement(child, ['ListItem']) && {
// The ListItem of ListItem should not be 'li'
component: child.props.component || 'div',
}),
})
: child,
)}

{endAction && (
<ListItemEndAction className={classes.endAction} ownerState={ownerState}>
{endAction}
</ListItemEndAction>
)}
</ListItemRoot>
</NestedListContext.Provider>
{endAction && (
<ListItemEndAction className={classes.endAction} ownerState={ownerState}>
{endAction}
</ListItemEndAction>
)}
</ListItemRoot>
</NestedListContext.Provider>
</ListSubheaderDispatch.Provider>
);
}) as OverridableComponent<ListItemTypeMap>;

Expand Down
2 changes: 1 addition & 1 deletion packages/mui-joy/src/ListItem/ListItemProps.ts
Expand Up @@ -71,7 +71,7 @@ export interface ListItemOwnerState extends ListItemProps {
/**
* If `true`, the element is rendered in a nested list item.
*/
nesting: boolean;
nesting: boolean | string;
/**
* @internal
* The internal prop for controlling CSS margin of the element.
Expand Down
4 changes: 4 additions & 0 deletions packages/mui-joy/src/ListItemButton/ListItemButton.tsx
Expand Up @@ -47,6 +47,10 @@ export const ListItemButtonRoot = styled('div', {
...(ownerState.selected && {
'--List-decorator-color': 'initial',
}),
...(ownerState.disabled && {
'--List-decorator-color':
theme.vars.palette[ownerState.color!]?.[`${ownerState.variant!}DisabledColor`],
}),
boxSizing: 'border-box',
position: 'relative',
display: 'flex',
Expand Down
55 changes: 55 additions & 0 deletions packages/mui-joy/src/ListSubheader/ListSubheader.test.tsx
@@ -0,0 +1,55 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { describeConformance, createRenderer } from 'test/utils';
import { ThemeProvider } from '@mui/joy/styles';
import ListSubheader, { listSubheaderClasses as classes } from '@mui/joy/ListSubheader';
import ListSubheaderDispatch from './ListSubheaderContext';

describe('Joy <ListSubheader />', () => {
const { render } = createRenderer();

describeConformance(<ListSubheader />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyListSubheader',
refInstanceof: window.HTMLDivElement,
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
skip: ['componentsProp', 'classesRoot'],
}));

it('should have root className', () => {
const { container } = render(<ListSubheader />);
expect(container.firstChild).to.have.class(classes.root);
});

it('should accept className prop', () => {
const { container } = render(<ListSubheader className="foo-bar" />);
expect(container.firstChild).to.have.class('foo-bar');
});

it('should have variant class', () => {
const { container } = render(<ListSubheader variant="soft" />);
expect(container.firstChild).to.have.class(classes.variantSoft);
});

it('should have color class', () => {
const { container } = render(<ListSubheader color="success" />);
expect(container.firstChild).to.have.class(classes.colorSuccess);
});

it('should call dispatch context with the generated id', () => {
const dispatch = spy();
const { container } = render(
<ListSubheaderDispatch.Provider value={dispatch}>
<ListSubheader />
</ListSubheaderDispatch.Provider>,
);

// @ts-ignore
expect(dispatch.firstCall.calledWith(container.firstChild?.id)).to.equal(true);
});
});

0 comments on commit 04027ec

Please sign in to comment.