mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	refactor: use buttons for sortable <th>s (#898)
* refactor: use buttons for sortable <th>s * refactor: announce sorting to screen readers * refactor: fix MenuItem padding override
This commit is contained in:
		
							parent
							
								
									629df7ee26
								
							
						
					
					
						commit
						5288438c9f
					
				| @ -0,0 +1,27 @@ | |||||||
|  | import { render } from 'utils/testRenderer'; | ||||||
|  | import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider'; | ||||||
|  | import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext'; | ||||||
|  | import { useContext, useEffect } from 'react'; | ||||||
|  | import { screen } from '@testing-library/react'; | ||||||
|  | 
 | ||||||
|  | test('AnnouncerContext', async () => { | ||||||
|  |     const TestComponent = () => { | ||||||
|  |         const { setAnnouncement } = useContext(AnnouncerContext); | ||||||
|  | 
 | ||||||
|  |         useEffect(() => { | ||||||
|  |             setAnnouncement('Foo'); | ||||||
|  |             setAnnouncement('Bar'); | ||||||
|  |         }, [setAnnouncement]); | ||||||
|  | 
 | ||||||
|  |         return null; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     render( | ||||||
|  |         <AnnouncerProvider> | ||||||
|  |             <TestComponent /> | ||||||
|  |         </AnnouncerProvider> | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     expect(screen.getByRole('status')).not.toHaveTextContent('Foo'); | ||||||
|  |     expect(screen.getByRole('status')).toHaveTextContent('Bar'); | ||||||
|  | }); | ||||||
| @ -0,0 +1,15 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | 
 | ||||||
|  | export interface IAnnouncerContext { | ||||||
|  |     setAnnouncement: React.Dispatch<React.SetStateAction<string | undefined>>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const setAnnouncementPlaceholder = () => { | ||||||
|  |     throw new Error('setAnnouncement called outside AnnouncerContext'); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // AnnouncerContext announces messages to screen readers through a live region.
 | ||||||
|  | // Call setAnnouncement to broadcast a new message to the screen reader.
 | ||||||
|  | export const AnnouncerContext = React.createContext<IAnnouncerContext>({ | ||||||
|  |     setAnnouncement: setAnnouncementPlaceholder, | ||||||
|  | }); | ||||||
| @ -0,0 +1,13 @@ | |||||||
|  | import { makeStyles } from '@material-ui/core/styles'; | ||||||
|  | 
 | ||||||
|  | export const useStyles = makeStyles({ | ||||||
|  |     container: { | ||||||
|  |         clip: 'rect(0 0 0 0)', | ||||||
|  |         clipPath: 'inset(50%)', | ||||||
|  |         zIndex: -1, | ||||||
|  |         width: 1, | ||||||
|  |         height: 1, | ||||||
|  |         margin: -1, | ||||||
|  |         padding: 0, | ||||||
|  |     }, | ||||||
|  | }); | ||||||
| @ -0,0 +1,23 @@ | |||||||
|  | import React, { ReactElement } from 'react'; | ||||||
|  | import { useStyles } from 'component/common/Announcer/AnnouncerElement/AnnouncerElement.styles'; | ||||||
|  | 
 | ||||||
|  | interface IAnnouncerElementProps { | ||||||
|  |     announcement?: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const AnnouncerElement = ({ | ||||||
|  |     announcement, | ||||||
|  | }: IAnnouncerElementProps): ReactElement => { | ||||||
|  |     const styles = useStyles(); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div | ||||||
|  |             role="status" | ||||||
|  |             aria-live="polite" | ||||||
|  |             aria-atomic | ||||||
|  |             className={styles.container} | ||||||
|  |         > | ||||||
|  |             {announcement} | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
| @ -0,0 +1,27 @@ | |||||||
|  | import React, { ReactElement, useMemo, useState, ReactNode } from 'react'; | ||||||
|  | import { AnnouncerContext } from '../AnnouncerContext/AnnouncerContext'; | ||||||
|  | import { AnnouncerElement } from 'component/common/Announcer/AnnouncerElement/AnnouncerElement'; | ||||||
|  | 
 | ||||||
|  | interface IAnnouncerProviderProps { | ||||||
|  |     children: ReactNode; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const AnnouncerProvider = ({ | ||||||
|  |     children, | ||||||
|  | }: IAnnouncerProviderProps): ReactElement => { | ||||||
|  |     const [announcement, setAnnouncement] = useState<string>(); | ||||||
|  | 
 | ||||||
|  |     const value = useMemo( | ||||||
|  |         () => ({ | ||||||
|  |             setAnnouncement, | ||||||
|  |         }), | ||||||
|  |         [setAnnouncement] | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <AnnouncerContext.Provider value={value}> | ||||||
|  |             {children} | ||||||
|  |             <AnnouncerElement announcement={announcement} /> | ||||||
|  |         </AnnouncerContext.Provider> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
| @ -22,4 +22,10 @@ export const useStyles = makeStyles(theme => ({ | |||||||
|             }, |             }, | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
|  |     sortButton: { | ||||||
|  |         all: 'unset', | ||||||
|  |         '&:focus-visible, &:active': { | ||||||
|  |             outline: 'revert', | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
| })); | })); | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import React, { ReactNode } from 'react'; | import React, { ReactNode, useContext } from 'react'; | ||||||
| import { TableCell } from '@material-ui/core'; | import { TableCell } from '@material-ui/core'; | ||||||
| import classnames from 'classnames'; | import classnames from 'classnames'; | ||||||
| import { | import { | ||||||
| @ -9,6 +9,7 @@ import { | |||||||
| import { IUsersSort, UsersSortType } from 'hooks/useUsersSort'; | import { IUsersSort, UsersSortType } from 'hooks/useUsersSort'; | ||||||
| import ConditionallyRender from 'component/common/ConditionallyRender'; | import ConditionallyRender from 'component/common/ConditionallyRender'; | ||||||
| import { useStyles } from 'component/common/Table/TableCellSortable/TableCellSortable.styles'; | import { useStyles } from 'component/common/Table/TableCellSortable/TableCellSortable.styles'; | ||||||
|  | import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext'; | ||||||
| 
 | 
 | ||||||
| // Add others as needed, e.g. UsersSortType | FeaturesSortType
 | // Add others as needed, e.g. UsersSortType | FeaturesSortType
 | ||||||
| type SortType = UsersSortType; | type SortType = UsersSortType; | ||||||
| @ -29,23 +30,37 @@ export const TableCellSortable = ({ | |||||||
|     setSort, |     setSort, | ||||||
|     children, |     children, | ||||||
| }: ITableCellSortableProps) => { | }: ITableCellSortableProps) => { | ||||||
|  |     const { setAnnouncement } = useContext(AnnouncerContext); | ||||||
|     const styles = useStyles(); |     const styles = useStyles(); | ||||||
| 
 | 
 | ||||||
|     return ( |     const ariaSort = | ||||||
|         <TableCell |         sort.type === name | ||||||
|             className={classnames( |             ? sort.desc | ||||||
|  |                 ? 'descending' | ||||||
|  |                 : 'ascending' | ||||||
|  |             : undefined; | ||||||
|  | 
 | ||||||
|  |     const cellClassName = classnames( | ||||||
|         className, |         className, | ||||||
|         styles.tableCellHeaderSortable, |         styles.tableCellHeaderSortable, | ||||||
|         sort.type === name && 'sorted' |         sort.type === name && 'sorted' | ||||||
|             )} |     ); | ||||||
|             onClick={() => | 
 | ||||||
|  |     const onSortClick = () => { | ||||||
|         setSort(prev => ({ |         setSort(prev => ({ | ||||||
|             desc: !Boolean(prev.desc), |             desc: !Boolean(prev.desc), | ||||||
|             type: name, |             type: name, | ||||||
|                 })) |         })); | ||||||
|             } |         setAnnouncement( | ||||||
|         > |             `Sorted table by ${name}, ${sort.desc ? 'ascending' : 'descending'}` | ||||||
|  |         ); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <TableCell aria-sort={ariaSort} className={cellClassName}> | ||||||
|  |             <button className={styles.sortButton} onClick={onSortClick}> | ||||||
|                 {children} |                 {children} | ||||||
|  |             </button> | ||||||
|             <ConditionallyRender |             <ConditionallyRender | ||||||
|                 condition={sort.type === name} |                 condition={sort.type === name} | ||||||
|                 show={ |                 show={ | ||||||
|  | |||||||
| @ -48,4 +48,10 @@ export const useStyles = makeStyles(theme => ({ | |||||||
|         color: theme.palette.primary.main, |         color: theme.palette.primary.main, | ||||||
|         fontWeight: theme.fontWeight.bold, |         fontWeight: theme.fontWeight.bold, | ||||||
|     }, |     }, | ||||||
|  |     sortButton: { | ||||||
|  |         all: 'unset', | ||||||
|  |         '&:focus-visible, &:active': { | ||||||
|  |             outline: 'revert', | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
| })); | })); | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { useState, useEffect } from 'react'; | import { useState, useEffect, useContext } from 'react'; | ||||||
| import { | import { | ||||||
|     Table, |     Table, | ||||||
|     TableBody, |     TableBody, | ||||||
| @ -18,6 +18,7 @@ import { | |||||||
| import PaginateUI from 'component/common/PaginateUI/PaginateUI'; | import PaginateUI from 'component/common/PaginateUI/PaginateUI'; | ||||||
| import StringTruncator from 'component/common/StringTruncator/StringTruncator'; | import StringTruncator from 'component/common/StringTruncator/StringTruncator'; | ||||||
| import { createGlobalStateHook } from 'hooks/useGlobalState'; | import { createGlobalStateHook } from 'hooks/useGlobalState'; | ||||||
|  | import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext'; | ||||||
| interface IFeatureToggleListNewProps { | interface IFeatureToggleListNewProps { | ||||||
|     features: IFeatureToggleListItem[]; |     features: IFeatureToggleListItem[]; | ||||||
|     loading: boolean; |     loading: boolean; | ||||||
| @ -83,6 +84,7 @@ const FeatureToggleListNew = ({ | |||||||
|     projectId, |     projectId, | ||||||
| }: IFeatureToggleListNewProps) => { | }: IFeatureToggleListNewProps) => { | ||||||
|     const styles = useStyles(); |     const styles = useStyles(); | ||||||
|  |     const { setAnnouncement } = useContext(AnnouncerContext); | ||||||
|     const [sortOpt, setSortOpt] = useFeatureToggLeProjectSort(); |     const [sortOpt, setSortOpt] = useFeatureToggLeProjectSort(); | ||||||
|     const [sortedFeatures, setSortedFeatures] = useState( |     const [sortedFeatures, setSortedFeatures] = useState( | ||||||
|         sortList([...features], sortOpt) |         sortList([...features], sortOpt) | ||||||
| @ -116,6 +118,12 @@ const FeatureToggleListNew = ({ | |||||||
|         setSortOpt(newSortOpt); |         setSortOpt(newSortOpt); | ||||||
|         setSortedFeatures(sortList([...features], newSortOpt)); |         setSortedFeatures(sortList([...features], newSortOpt)); | ||||||
|         setPageIndex(0); |         setPageIndex(0); | ||||||
|  | 
 | ||||||
|  |         setAnnouncement( | ||||||
|  |             `Sorted table by ${field}, ${ | ||||||
|  |                 sortOpt.direction ? 'ascending' : 'descending' | ||||||
|  |             }` | ||||||
|  |         ); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const getEnvironments = () => { |     const getEnvironments = () => { | ||||||
| @ -163,6 +171,14 @@ const FeatureToggleListNew = ({ | |||||||
|         }); |         }); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  |     const ariaSort = (field: string) => { | ||||||
|  |         return field === sortOpt.field | ||||||
|  |             ? sortOpt.direction | ||||||
|  |                 ? 'ascending' | ||||||
|  |                 : 'descending' | ||||||
|  |             : undefined; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             <Table> |             <Table> | ||||||
| @ -176,13 +192,15 @@ const FeatureToggleListNew = ({ | |||||||
|                                 styles.tableCellHeaderSortable |                                 styles.tableCellHeaderSortable | ||||||
|                             )} |                             )} | ||||||
|                             align="left" |                             align="left" | ||||||
|  |                             aria-sort={ariaSort('lastSeenAt')} | ||||||
|                         > |                         > | ||||||
|                             <span |                             <button | ||||||
|                                 data-loading |                                 data-loading | ||||||
|                                 onClick={() => updateSort('lastSeenAt')} |                                 onClick={() => updateSort('lastSeenAt')} | ||||||
|  |                                 className={styles.sortButton} | ||||||
|                             > |                             > | ||||||
|                                 Last use |                                 Last use | ||||||
|                             </span> |                             </button> | ||||||
|                         </TableCell> |                         </TableCell> | ||||||
|                         <TableCell |                         <TableCell | ||||||
|                             className={classnames( |                             className={classnames( | ||||||
| @ -192,13 +210,15 @@ const FeatureToggleListNew = ({ | |||||||
|                                 styles.tableCellHeaderSortable |                                 styles.tableCellHeaderSortable | ||||||
|                             )} |                             )} | ||||||
|                             align="center" |                             align="center" | ||||||
|  |                             aria-sort={ariaSort('type')} | ||||||
|                         > |                         > | ||||||
|                             <span |                             <button | ||||||
|                                 data-loading |                                 data-loading | ||||||
|                                 onClick={() => updateSort('type')} |                                 onClick={() => updateSort('type')} | ||||||
|  |                                 className={styles.sortButton} | ||||||
|                             > |                             > | ||||||
|                                 Type |                                 Type | ||||||
|                             </span> |                             </button> | ||||||
|                         </TableCell> |                         </TableCell> | ||||||
|                         <TableCell |                         <TableCell | ||||||
|                             className={classnames( |                             className={classnames( | ||||||
| @ -208,13 +228,15 @@ const FeatureToggleListNew = ({ | |||||||
|                                 styles.tableCellHeaderSortable |                                 styles.tableCellHeaderSortable | ||||||
|                             )} |                             )} | ||||||
|                             align="left" |                             align="left" | ||||||
|  |                             aria-sort={ariaSort('name')} | ||||||
|                         > |                         > | ||||||
|                             <span |                             <button | ||||||
|                                 data-loading |                                 data-loading | ||||||
|                                 onClick={() => updateSort('name')} |                                 onClick={() => updateSort('name')} | ||||||
|  |                                 className={styles.sortButton} | ||||||
|                             > |                             > | ||||||
|                                 Name |                                 Name | ||||||
|                             </span> |                             </button> | ||||||
|                         </TableCell> |                         </TableCell> | ||||||
|                         <TableCell |                         <TableCell | ||||||
|                             className={classnames( |                             className={classnames( | ||||||
| @ -224,13 +246,15 @@ const FeatureToggleListNew = ({ | |||||||
|                                 styles.tableCellHeaderSortable |                                 styles.tableCellHeaderSortable | ||||||
|                             )} |                             )} | ||||||
|                             align="left" |                             align="left" | ||||||
|  |                             aria-sort={ariaSort('createdAt')} | ||||||
|                         > |                         > | ||||||
|                             <span |                             <button | ||||||
|                                 data-loading |                                 data-loading | ||||||
|                                 onClick={() => updateSort('createdAt')} |                                 onClick={() => updateSort('createdAt')} | ||||||
|  |                                 className={styles.sortButton} | ||||||
|                             > |                             > | ||||||
|                                 Created |                                 Created | ||||||
|                             </span> |                             </button> | ||||||
|                         </TableCell> |                         </TableCell> | ||||||
|                         {getEnvironments().map((env: any) => { |                         {getEnvironments().map((env: any) => { | ||||||
|                             return ( |                             return ( | ||||||
|  | |||||||
| @ -23,6 +23,9 @@ export const useStyles = makeStyles(theme => ({ | |||||||
|         color: '#000', |         color: '#000', | ||||||
|         height: '100%', |         height: '100%', | ||||||
|         width: '100%', |         width: '100%', | ||||||
|  |         '&&': { | ||||||
|  |             // Override MenuItem's built-in padding.
 | ||||||
|             padding: '0.5rem 1rem', |             padding: '0.5rem 1rem', | ||||||
|         }, |         }, | ||||||
|  |     }, | ||||||
| })); | })); | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ import AccessProvider from 'component/providers/AccessProvider/AccessProvider'; | |||||||
| import { getBasePath } from 'utils/formatPath'; | import { getBasePath } from 'utils/formatPath'; | ||||||
| import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/FeedbackCESProvider'; | import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/FeedbackCESProvider'; | ||||||
| import UIProvider from 'component/providers/UIProvider/UIProvider'; | import UIProvider from 'component/providers/UIProvider/UIProvider'; | ||||||
|  | import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider'; | ||||||
| 
 | 
 | ||||||
| ReactDOM.render( | ReactDOM.render( | ||||||
|     <DndProvider backend={HTML5Backend}> |     <DndProvider backend={HTML5Backend}> | ||||||
| @ -19,10 +20,12 @@ ReactDOM.render( | |||||||
|             <AccessProvider> |             <AccessProvider> | ||||||
|                 <Router basename={`${getBasePath()}`}> |                 <Router basename={`${getBasePath()}`}> | ||||||
|                     <MainThemeProvider> |                     <MainThemeProvider> | ||||||
|  |                         <AnnouncerProvider> | ||||||
|                             <FeedbackCESProvider> |                             <FeedbackCESProvider> | ||||||
|                                 <ScrollTop /> |                                 <ScrollTop /> | ||||||
|                                 <Route path="/" component={App} /> |                                 <Route path="/" component={App} /> | ||||||
|                             </FeedbackCESProvider> |                             </FeedbackCESProvider> | ||||||
|  |                         </AnnouncerProvider> | ||||||
|                     </MainThemeProvider> |                     </MainThemeProvider> | ||||||
|                 </Router> |                 </Router> | ||||||
|             </AccessProvider> |             </AccessProvider> | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user