feat(a11y): add labels and roles [EE-6717] (#11181)

pull/11226/head
Chaim Lev-Ari 2024-02-19 16:37:26 +02:00 committed by GitHub
parent 6c89d3c0c9
commit ce3a1b8ba5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 72 additions and 30 deletions

View File

@ -209,7 +209,7 @@
</uib-tab> </uib-tab>
<uib-tab index="1" disable="!buildLogs"> <uib-tab index="1" disable="!buildLogs">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'file-text'" class="leading-none"></pr-icon> Output </uib-tab-heading> <uib-tab-heading class="vertical-center"> <pr-icon icon="'file-text'" class="leading-none"></pr-icon> Output </uib-tab-heading>
<pre class="log_viewer"> <pre class="log_viewer" data-cy="logViewer">
<div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div> <div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div>
<div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div> <div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div>
</pre> </pre>

View File

@ -1,5 +1,5 @@
<!-- helm chart --> <!-- helm chart -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)"> <div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)" role="listitem">
<div class="blocklist-item-box"> <div class="blocklist-item-box">
<!-- helmchart-image --> <!-- helmchart-image -->
<span class="shrink-0"> <span class="shrink-0">

View File

@ -30,7 +30,7 @@
></beta-alert> ></beta-alert>
</div> </div>
<div class="blocklist !px-0"> <div class="blocklist !px-0" role="list">
<helm-templates-list-item <helm-templates-list-item
ng-repeat="chart in allCharts = ($ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory)" ng-repeat="chart in allCharts = ($ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory)"
model="chart" model="chart"

View File

@ -184,6 +184,7 @@ export const ngModule = angular
'components', 'components',
'isLoading', 'isLoading',
'noOptionsMessage', 'noOptionsMessage',
'aria-label',
]) ])
) )
.component( .component(

View File

@ -69,10 +69,13 @@ export function Badge({
children, children,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
const baseClasses = const baseClasses =
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5'; 'inline-flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
return ( return (
<span className={clsx(baseClasses, typeClasses[type], className)}> <span
className={clsx(baseClasses, typeClasses[type], className)}
role="status"
>
{children} {children}
</span> </span>
); );

View File

@ -28,6 +28,7 @@ export function BlocklistItem<T extends ElementType>({
'blocklist-item--selected': isSelected, 'blocklist-item--selected': isSelected,
} }
)} )}
role="listitem"
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
> >

View File

@ -188,6 +188,7 @@ export function Datatable<D extends DefaultType>({
isLoading={isLoading} isLoading={isLoading}
onSortChange={handleSortChange} onSortChange={handleSortChange}
data-cy={dataCy} data-cy={dataCy}
aria-label={`${title} table`}
/> />
<DatatableFooter <DatatableFooter

View File

@ -11,6 +11,7 @@ interface Props<D extends DefaultType> extends AutomationTestingProps {
onSortChange?(colId: string, desc: boolean): void; onSortChange?(colId: string, desc: boolean): void;
isLoading?: boolean; isLoading?: boolean;
emptyContentLabel?: string; emptyContentLabel?: string;
'aria-label'?: string;
} }
export function DatatableContent<D extends DefaultType>({ export function DatatableContent<D extends DefaultType>({
@ -20,12 +21,13 @@ export function DatatableContent<D extends DefaultType>({
isLoading, isLoading,
emptyContentLabel, emptyContentLabel,
'data-cy': dataCy, 'data-cy': dataCy,
'aria-label': ariaLabel,
}: Props<D>) { }: Props<D>) {
const headerGroups = tableInstance.getHeaderGroups(); const headerGroups = tableInstance.getHeaderGroups();
const pageRowModel = tableInstance.getPaginationRowModel(); const pageRowModel = tableInstance.getPaginationRowModel();
return ( return (
<Table data-cy={dataCy} className="nowrap-cells"> <Table data-cy={dataCy} className="nowrap-cells" aria-label={ariaLabel}>
<thead> <thead>
{headerGroups.map((headerGroup) => ( {headerGroups.map((headerGroup) => (
<Table.HeaderRow<D> <Table.HeaderRow<D>

View File

@ -28,6 +28,8 @@ interface Props<D extends DefaultType> {
* keyword to filter by * keyword to filter by
*/ */
search?: string; search?: string;
'aria-label'?: string;
} }
export function NestedDatatable<D extends DefaultType>({ export function NestedDatatable<D extends DefaultType>({
@ -39,6 +41,7 @@ export function NestedDatatable<D extends DefaultType>({
isLoading, isLoading,
initialSortBy, initialSortBy,
search, search,
'aria-label': ariaLabel,
}: Props<D>) { }: Props<D>) {
const tableInstance = useReactTable<D>({ const tableInstance = useReactTable<D>({
columns, columns,
@ -70,6 +73,7 @@ export function NestedDatatable<D extends DefaultType>({
isLoading={isLoading} isLoading={isLoading}
emptyContentLabel={emptyContentLabel} emptyContentLabel={emptyContentLabel}
renderRow={(row) => <Table.Row<D> cells={row.getVisibleCells()} />} renderRow={(row) => <Table.Row<D> cells={row.getVisibleCells()} />}
aria-label={ariaLabel}
/> />
</Table.Container> </Table.Container>
</NestedTable> </NestedTable>

View File

@ -42,6 +42,7 @@ export function SearchBar({
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
data-cy={dataCy} data-cy={dataCy}
aria-label="Search input"
/> />
{children} {children}
<Button onClick={onClear} icon={X} color="none" disabled={!searchValue} /> <Button onClick={onClear} icon={X} color="none" disabled={!searchValue} />

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { PropsWithChildren } from 'react'; import { AriaAttributes, PropsWithChildren } from 'react';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
@ -14,23 +14,21 @@ import { TableHeaderCell } from './TableHeaderCell';
import { TableHeaderRow } from './TableHeaderRow'; import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow'; import { TableRow } from './TableRow';
interface Props extends AutomationTestingProps { interface Props extends AutomationTestingProps, AriaAttributes {
className?: string; className?: string;
} }
function MainComponent({ function MainComponent({
children, children,
className, className,
'data-cy': dataCy, ...props
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<div className="table-responsive"> <div className="table-responsive">
<table <table
data-cy={dataCy} // eslint-disable-next-line react/jsx-props-no-spreading
className={clsx( {...props}
'table-hover table-filters nowrap-cells table', className={clsx('table-hover table-filters table', className)}
className
)}
> >
{children} {children}
</table> </table>

View File

@ -23,7 +23,7 @@ export function TableTitle({
<> <>
<div className={clsx('toolBar flex-col', className)} id={id}> <div className={clsx('toolBar flex-col', className)} id={id}>
<div className="flex w-full items-center gap-1 p-0"> <div className="flex w-full items-center gap-1 p-0">
<div className="toolBarTitle"> <h2 className="toolBarTitle m-0 text-2xl">
{icon && ( {icon && (
<div className="widget-icon"> <div className="widget-icon">
<Icon icon={icon} className="space-right" /> <Icon icon={icon} className="space-right" />
@ -31,7 +31,7 @@ export function TableTitle({
)} )}
{label} {label}
</div> </h2>
{children} {children}
</div> </div>
</div> </div>

View File

@ -17,6 +17,7 @@ export function buildExpandColumn<T extends DefaultType>(): ColumnDef<T> {
onClick={table.getToggleAllRowsExpandedHandler()} onClick={table.getToggleAllRowsExpandedHandler()}
color="none" color="none"
icon={table.getIsAllRowsExpanded() ? ChevronDown : ChevronUp} icon={table.getIsAllRowsExpanded() ? ChevronDown : ChevronUp}
title="Expand all"
/> />
) )
); );
@ -32,6 +33,7 @@ export function buildExpandColumn<T extends DefaultType>(): ColumnDef<T> {
}} }}
color="none" color="none"
icon={row.getIsExpanded() ? ChevronDown : ChevronUp} icon={row.getIsExpanded() ? ChevronDown : ChevronUp}
title={row.getIsExpanded() ? 'Collapse' : 'Expand'}
/> />
), ),
enableColumnFilter: false, enableColumnFilter: false,

View File

@ -4,6 +4,7 @@ import {
SelectComponentsConfig, SelectComponentsConfig,
} from 'react-select'; } from 'react-select';
import _ from 'lodash'; import _ from 'lodash';
import { AriaAttributes } from 'react';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
@ -20,7 +21,9 @@ type Options<TValue> = OptionsOrGroups<
GroupBase<Option<TValue>> GroupBase<Option<TValue>>
>; >;
interface SharedProps extends AutomationTestingProps { interface SharedProps
extends AutomationTestingProps,
Pick<AriaAttributes, 'aria-label'> {
name?: string; name?: string;
inputId?: string; inputId?: string;
placeholder?: string; placeholder?: string;
@ -87,6 +90,8 @@ export function SingleSelect<TValue = string>({
components, components,
isLoading, isLoading,
noOptionsMessage, noOptionsMessage,
isMulti,
...aria
}: SingleProps<TValue>) { }: SingleProps<TValue>) {
const selectedValue = const selectedValue =
value || (typeof value === 'number' && value === 0) value || (typeof value === 'number' && value === 0)
@ -111,6 +116,8 @@ export function SingleSelect<TValue = string>({
components={components} components={components}
isLoading={isLoading} isLoading={isLoading}
noOptionsMessage={noOptionsMessage} noOptionsMessage={noOptionsMessage}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
/> />
); );
} }
@ -152,6 +159,7 @@ export function MultiSelect<TValue = string>({
components, components,
isLoading, isLoading,
noOptionsMessage, noOptionsMessage,
...aria
}: Omit<MultiProps<TValue>, 'isMulti'>) { }: Omit<MultiProps<TValue>, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value); const selectedOptions = findSelectedOptions(options, value);
return ( return (
@ -174,6 +182,8 @@ export function MultiSelect<TValue = string>({
components={components} components={components}
isLoading={isLoading} isLoading={isLoading}
noOptionsMessage={noOptionsMessage} noOptionsMessage={noOptionsMessage}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
/> />
); );
} }

View File

@ -16,5 +16,11 @@ export function NestedNetworksDatatable({
const isSwarm = useIsSwarm(environmentId); const isSwarm = useIsSwarm(environmentId);
const columns = useColumns(isSwarm); const columns = useColumns(isSwarm);
return <NestedDatatable columns={columns} dataset={dataset} />; return (
<NestedDatatable
columns={columns}
dataset={dataset}
aria-label="Networks table"
/>
);
} }

View File

@ -1,6 +1,7 @@
import { truncate } from '@/portainer/filters/filters'; import { truncate } from '@/portainer/filters/filters';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { Badge } from '@@/Badge';
import { columnHelper } from './helper'; import { columnHelper } from './helper';
@ -18,12 +19,9 @@ export const name = columnHelper.accessor('Name', {
{truncate(item.Name, 40)} {truncate(item.Name, 40)}
</Link> </Link>
{item.ResourceControl?.System && ( {item.ResourceControl?.System && (
<span <Badge type="info" className="ml-2">
style={{ marginLeft: '10px' }}
className="label label-info image-tag space-left"
>
System System
</span> </Badge>
)} )}
</> </>
); );

View File

@ -24,6 +24,7 @@ export function TasksDatatable({
dataset={dataset} dataset={dataset}
search={search} search={search}
emptyContentLabel="No task matching filter." emptyContentLabel="No task matching filter."
aria-label="Tasks table"
/> />
); );
} }

View File

@ -108,7 +108,9 @@ function Cell({
</Authorized> </Authorized>
)} )}
{item.dangling && ( {item.dangling && (
<span className="label label-warning image-tag ml-2">Unused</span> <span className="label label-warning image-tag ml-2" role="status">
Unused
</span>
)} )}
</> </>
); );

View File

@ -212,6 +212,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
<div <div
className="blocklist mt-5 !space-y-2 !p-0" className="blocklist mt-5 !space-y-2 !p-0"
data-cy="home-endpointList" data-cy="home-endpointList"
role="list"
> >
{renderItems( {renderItems(
isLoading, isLoading,

View File

@ -31,9 +31,10 @@ export function RefField({
stackId, stackId,
}: Props) { }: Props) {
const [inputValue, updateInputValue] = useStateWrapper(value, onChange); const [inputValue, updateInputValue] = useStateWrapper(value, onChange);
const inputId = 'repository-reference-field';
return isBE ? ( return isBE ? (
<Wrapper <Wrapper
inputId={inputId}
errors={error} errors={error}
tip={ tip={
<> <>
@ -44,6 +45,7 @@ export function RefField({
} }
> >
<RefSelector <RefSelector
inputId={inputId}
value={value} value={value}
onChange={onChange} onChange={onChange}
model={model} model={model}
@ -53,6 +55,7 @@ export function RefField({
</Wrapper> </Wrapper>
) : ( ) : (
<Wrapper <Wrapper
inputId={inputId}
errors={error} errors={error}
tip={ tip={
<> <>
@ -65,6 +68,7 @@ export function RefField({
} }
> >
<Input <Input
id={inputId}
value={inputValue} value={inputValue}
onChange={(e) => updateInputValue(e.target.value)} onChange={(e) => updateInputValue(e.target.value)}
placeholder="refs/heads/main" placeholder="refs/heads/main"
@ -77,7 +81,8 @@ function Wrapper({
tip, tip,
children, children,
errors, errors,
}: PropsWithChildren<{ tip: ReactNode; errors?: string }>) { inputId,
}: PropsWithChildren<{ tip: ReactNode; errors?: string; inputId: string }>) {
return ( return (
<div className="form-group"> <div className="form-group">
<span className="col-sm-12 mb-2"> <span className="col-sm-12 mb-2">
@ -86,7 +91,7 @@ function Wrapper({
<div className="col-sm-12"> <div className="col-sm-12">
<FormControl <FormControl
label="Repository reference" label="Repository reference"
inputId="stack_repository_reference_name" inputId={inputId}
required required
errors={errors} errors={errors}
> >

View File

@ -13,12 +13,14 @@ export function RefSelector({
onChange, onChange,
isUrlValid, isUrlValid,
stackId, stackId,
inputId,
}: { }: {
model: RefFieldModel; model: RefFieldModel;
value: string; value: string;
stackId?: StackId; stackId?: StackId;
onChange: (value: string) => void; onChange: (value: string) => void;
isUrlValid?: boolean; isUrlValid?: boolean;
inputId: string;
}) { }) {
const creds = getAuthentication(model); const creds = getAuthentication(model);
const payload = { const payload = {
@ -64,6 +66,7 @@ export function RefSelector({
return ( return (
<PortainerSelect <PortainerSelect
inputId={inputId}
value={value} value={value}
options={refs || [{ value: 'refs/heads/main', label: 'refs/heads/main' }]} options={refs || [{ value: 'refs/heads/main', label: 'refs/heads/main' }]}
onChange={(e) => e && onChange(e)} onChange={(e) => e && onChange(e)}

View File

@ -86,7 +86,7 @@ export function AppTemplatesList({
} }
/> />
<div className="blocklist gap-y-2 !px-[20px] !pb-[20px]"> <div className="blocklist gap-y-2 !px-[20px] !pb-[20px]" role="list">
{pagedTemplates.map((template) => ( {pagedTemplates.map((template) => (
<AppTemplatesListItem <AppTemplatesListItem
key={template.Id} key={template.Id}

View File

@ -55,6 +55,7 @@ export function Filters({
value={listState.category} value={listState.category}
bindToBody bindToBody
isClearable isClearable
aria-label="Category filter"
/> />
</div> </div>
)} )}
@ -71,6 +72,7 @@ export function Filters({
value={listState.types} value={listState.types}
bindToBody bindToBody
isClearable isClearable
aria-label="Type filter"
/> />
</div> </div>
)} )}
@ -83,6 +85,7 @@ export function Filters({
options={orderByFields} options={orderByFields}
placeholder="Sort By" placeholder="Sort By"
value={listState.sortBy} value={listState.sortBy}
aria-label="Sort"
/> />
</div> </div>
</div> </div>

View File

@ -63,7 +63,7 @@ export function CustomTemplatesList({
)} )}
/> />
<div className="blocklist gap-y-2 !px-[20px] !pb-[20px]"> <div className="blocklist gap-y-2 !px-[20px] !pb-[20px]" role="list">
{pagedTemplates.map((template) => ( {pagedTemplates.map((template) => (
<CustomTemplatesListItem <CustomTemplatesListItem
key={template.Id} key={template.Id}

View File

@ -182,7 +182,7 @@ export function DockerSidebar({ environmentId, environment }: Props) {
icon={setupSubMenuProps.icon} icon={setupSubMenuProps.icon}
to={setupSubMenuProps.to} to={setupSubMenuProps.to}
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
data-cy="portainerSidebar-host" data-cy="portainerSidebar-host-area"
> >
<SidebarItem <SidebarItem
label="Details" label="Details"