Compare commits

...

2 Commits

Author SHA1 Message Date
Diego Pucci
b0bfc27e11 fix(Native Filters): Apply existing value if it exists 2025-05-16 11:09:39 +02:00
Diego Pucci
deeacb0dfb chore(Accessibility): Improve keyboard navigation and screen access 2025-05-16 11:07:01 +02:00
18 changed files with 183 additions and 36 deletions

View File

@@ -94,9 +94,9 @@ export function ControlHeader({
<label className="control-label" htmlFor={name}>
{leftNode && <span>{leftNode}</span>}
<span
role="button"
tabIndex={0}
onClick={onClick}
role={onClick ? 'button' : undefined}
{...(onClick ? { onClick } : {})}
{...(onClick ? { tabIndex: 0 } : {})}
className={labelClass}
style={{ cursor: onClick ? 'pointer' : '' }}
>

View File

@@ -69,17 +69,24 @@ export default function RadioButtonControl({
boxShadow: 'none',
},
}}
role="tablist"
aria-label={typeof props.label === 'string' ? props.label : undefined}
>
<ControlHeader {...props} />
<div className="btn-group btn-group-sm">
{options.map(([val, label]) => (
<button
aria-label={typeof label === 'string' ? label : undefined}
id={`tab-${val}`}
key={JSON.stringify(val)}
type="button"
aria-selected={val === currentValue}
role="tab"
className={`btn btn-default ${
val === currentValue ? 'active' : ''
}`}
onClick={() => {
onClick={e => {
e.currentTarget?.focus();
onChange(val);
}}
>
@@ -87,6 +94,20 @@ export default function RadioButtonControl({
</button>
))}
</div>
{/* accessibility begin */}
<div
aria-live="polite"
style={{
position: 'absolute',
left: '-9999px',
height: '1px',
width: '1px',
overflow: 'hidden',
}}
>
{options.find(([val]) => val === currentValue)?.[1]} tab selected
</div>
{/* accessibility end */}
</div>
);
}

View File

@@ -432,6 +432,7 @@ function ColumnCollectionTable({
).is_dttm;
return (
<Radio
aria-label={t('Set %s as default datetime column', record.column_name)}
data-test={`radio-default-dttm-${record.column_name}`}
checked={checked}
disabled={disabled}
@@ -482,6 +483,7 @@ function ColumnCollectionTable({
).is_dttm;
return (
<Radio
aria-label={t('Set %s as default datetime column', record.column_name)}
data-test={`radio-default-dttm-${record.column_name}`}
checked={checked}
disabled={disabled}

View File

@@ -71,6 +71,7 @@ export interface ModalProps {
maskClosable?: boolean;
zIndex?: number;
bodyStyle?: CSSProperties;
openerRef?: React.RefObject<HTMLElement>;
}
interface StyledModalProps {
@@ -276,22 +277,34 @@ const CustomModal = ({
resizableConfig = defaultResizableConfig(hideFooter),
draggableConfig,
destroyOnClose,
openerRef = null,
...rest
}: ModalProps) => {
const draggableRef = useRef<HTMLDivElement>(null);
const [bounds, setBounds] = useState<DraggableBounds>();
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
const handleOnHide = () => {
openerRef.current?.focus();
onHide();
};
let FooterComponent;
if (isValidElement(footer)) {
// If a footer component is provided inject a closeModal function
// so the footer can provide a "close" button if desired
FooterComponent = cloneElement(footer, {
closeModal: onHide,
closeModal: handleOnHide,
} as Partial<unknown>);
}
const modalFooter = isNil(FooterComponent)
? [
<Button key="back" onClick={onHide} cta data-test="modal-cancel-button">
<Button
key="back"
onClick={handleOnHide}
cta
data-test="modal-cancel-button"
>
{t('Cancel')}
</Button>,
<Button
@@ -350,7 +363,7 @@ const CustomModal = ({
<StyledModal
centered={!!centered}
onOk={onHandledPrimaryAction}
onCancel={onHide}
onCancel={handleOnHide}
width={modalWidth}
maxWidth={maxWidth}
responsive={responsive}

View File

@@ -31,7 +31,8 @@ export function Item({ active, children, onClick }: PaginationItemButton) {
<li className={classNames({ active })}>
<span
role="button"
tabIndex={active ? -1 : 0}
tabIndex={0}
aria-current={active ? 'page' : undefined}
onClick={e => {
e.preventDefault();
if (!active) onClick(e);

View File

@@ -600,7 +600,11 @@ const AsyncSelect = forwardRef(
)}
<StyledSelect
allowClear={!isLoading && allowClear}
aria-label={ariaLabel || name}
aria-label={
isSingleMode && isLabeledValue(selectValue)
? `${ariaLabel || name}: ${selectValue.label}`
: ariaLabel || name
}
autoClearSearchValue={autoClearSearchValue}
dropdownRender={dropdownRender}
filterOption={handleFilterOption}

View File

@@ -673,7 +673,11 @@ const Select = forwardRef(
<StyledSelect
id={name}
allowClear={!isLoading && allowClear}
aria-label={ariaLabel}
aria-label={
isSingleMode && isLabeledValue(selectValue)
? `${ariaLabel || name}: ${selectValue.label}`
: ariaLabel || name
}
autoClearSearchValue={autoClearSearchValue}
dropdownRender={dropdownRender}
filterOption={handleFilterOption}

View File

@@ -46,6 +46,10 @@ export const StyledSelect = styled(AntdSelect, {
shouldForwardProp: prop => prop !== 'headerPosition' && prop !== 'oneLine',
})<{ headerPosition?: string; oneLine?: boolean }>`
${({ theme, headerPosition, oneLine }) => `
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
outline: 2px solid ${theme.colors.primary.base};
outline-offset: -2px;
}
flex: ${headerPosition === 'left' ? 1 : 0};
&& .ant-select-selector {
border-radius: ${theme.gridUnit}px;

View File

@@ -163,12 +163,31 @@ export const dropDownRenderHelper = (
if (errorComponent) {
return errorComponent;
}
// remap for accessibility for proper item count
const accessibilityNode = {
...originNode,
props: {
...originNode.props,
flattenOptions: ensureIsArray(originNode.props.flattenOptions).map(
(opt: Record<string, any>, idx: number) => ({
...opt,
data: {
...opt.data,
'aria-setsize': originNode.props.flattenOptions?.length || 0,
'aria-posinset': idx + 1,
},
}),
),
},
};
return (
<>
{helperText && (
<StyledHelperText role="note">{helperText}</StyledHelperText>
)}
{originNode}
{accessibilityNode}
{bulkSelectComponents}
</>
);

View File

@@ -55,6 +55,7 @@ export default function TimezoneSelector({
onTimezoneChange,
timezone,
minWidth = MIN_SELECT_WIDTH, // smallest size for current values
...rest
}: TimezoneSelectorProps) {
const { TIMEZONE_OPTIONS, TIMEZONE_OPTIONS_SORT_COMPARATOR, validTimezone } =
useMemo(() => {
@@ -156,6 +157,7 @@ export default function TimezoneSelector({
value={validTimezone}
options={TIMEZONE_OPTIONS}
sortComparator={TIMEZONE_OPTIONS_SORT_COMPARATOR}
{...rest}
/>
);
}

View File

@@ -131,6 +131,7 @@ class TextAreaControl extends Component {
defaultValue={this.props.initialValue}
disabled={this.props.readOnly}
style={{ height: this.props.height }}
aria-required={this.props["aria-required"]}
/>
</div>
);

View File

@@ -418,11 +418,15 @@ const Selector: FC<{
return (
<SelectorLabel
aria-label={selector}
aria-selected={isSelected}
ref={btnRef}
key={selector}
name={selector}
className={cx(className, isSelected && 'selected')}
onClick={() => onClick(selector, sectionId)}
tabIndex={0}
role="tab"
>
{icon}
{selector}
@@ -656,7 +660,7 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
className={className}
isSelectedVizMetadata={Boolean(selectedVizMetadata)}
>
<LeftPane>
<LeftPane aria-label={t('Choose chart type')} role="tablist">
<Selector
css={({ gridUnit }) =>
// adjust style for not being inside a collapse

View File

@@ -72,6 +72,7 @@ import {
import { useSelector } from 'react-redux';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { Icons } from 'src/components/Icons';
import { useOpenerRef } from 'src/hooks/useOpenerRef';
import NumberInput from './components/NumberInput';
import { AlertReportCronScheduler } from './components/AlertReportCronScheduler';
import { NotificationMethod } from './components/NotificationMethod';
@@ -431,6 +432,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
isReport = false,
addSuccessToast,
}) => {
const openerRef = useOpenerRef(show);
const currentUser = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -1465,6 +1467,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
width="500px"
centered
title={<h4 data-test="alert-report-modal-title">{getTitleText()}</h4>}
openerRef={openerRef}
>
<Collapse
expandIconPosition="right"
@@ -1504,6 +1507,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
isReport ? t('Enter report name') : t('Enter alert name')
}
onChange={onInputChange}
aria-required="true"
/>
</div>
</StyledInputContainer>
@@ -1515,6 +1519,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<div data-test="owners-select" className="input-container">
<AsyncSelect
ariaLabel={t('Owners')}
aria-required="true"
allowClear
name="owners"
mode="multiple"
@@ -1581,6 +1586,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<div className="input-container">
<AsyncSelect
ariaLabel={t('Database')}
aria-required="true"
name="source"
placeholder={t('Select database')}
value={
@@ -1617,6 +1623,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
readOnly={false}
initialValue={resource?.sql}
key={currentAlert?.id}
aria-required="true"
/>
</StyledInputContainer>
<div className="inline-container wrap">
@@ -1628,6 +1635,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<div className="input-container">
<Select
ariaLabel={t('Condition')}
aria-required="true"
onChange={onConditionChange}
placeholder={t('Condition')}
value={currentAlert?.validator_config_json?.op || undefined}
@@ -1654,6 +1662,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
}
placeholder={t('Value')}
onChange={onThresholdChange}
aria-required="true"
/>
</div>
</StyledInputContainer>
@@ -1688,6 +1697,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
value={contentType}
options={CONTENT_TYPE_OPTIONS}
placeholder={t('Select content type')}
aria-required="true"
/>
</StyledInputContainer>
<StyledInputContainer>
@@ -1699,6 +1709,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</div>
<AsyncSelect
ariaLabel={t('Chart')}
aria-required="true"
name="chart"
value={
currentAlert?.chart?.label && currentAlert?.chart?.value
@@ -1721,6 +1732,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</div>
<AsyncSelect
ariaLabel={t('Dashboard')}
aria-required="true"
name="dashboard"
value={
currentAlert?.dashboard?.label &&
@@ -1751,6 +1763,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</div>
<Select
ariaLabel={t('Select format')}
aria-required="true"
onChange={onFormatChange}
value={reportFormat}
options={
@@ -1845,6 +1858,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
onTimezoneChange={onTimezoneChange}
timezone={currentAlert?.timezone}
minWidth="100%"
aria-required="true"
/>
</StyledInputContainer>
<StyledInputContainer>
@@ -1855,6 +1869,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<div className="input-container">
<Select
ariaLabel={t('Log retention')}
aria-required="true"
placeholder={t('Log retention')}
onChange={onLogRetentionChange}
value={currentAlert?.log_retention}
@@ -1878,6 +1893,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
aria-required="true"
/>
</div>
</>

View File

@@ -34,6 +34,7 @@ export default function NumberInput({
value,
placeholder,
onChange,
...rest
}: NumberInputProps) {
const [isFocused, setIsFocused] = useState<boolean>(false);
@@ -47,6 +48,7 @@ export default function NumberInput({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={onChange}
{...rest}
/>
);
}

View File

@@ -120,6 +120,8 @@ type MenuChild = {
usesRouter?: boolean;
onClick?: () => void;
'data-test'?: string;
id?: string;
'aria-controls'?: string;
};
export interface ButtonProps {
@@ -196,20 +198,22 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
<StyledHeader>
<Row className="menu" role="navigation">
{props.name && <div className="header">{props.name}</div>}
<Menu mode={showMenu} disabledOverflow>
<Menu mode={showMenu} disabledOverflow role="tablist">
{props.tabs?.map(tab => {
if ((props.usesRouter || hasHistory) && !!tab.usesRouter) {
return (
<Menu.Item key={tab.label}>
<div
<Link
to={tab.url || ''}
role="tab"
id={tab.id || tab.name}
data-test={tab['data-test']}
aria-selected={tab.name === props.activeChild}
aria-controls={tab['aria-controls'] || ''}
className={tab.name === props.activeChild ? 'active' : ''}
>
<div>
<Link to={tab.url || ''}>{tab.label}</Link>
</div>
</div>
{tab.label}
</Link>
</Menu.Item>
);
}
@@ -221,6 +225,7 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
active: tab.name === props.activeChild,
})}
role="tab"
aria-selected={tab.name === props.activeChild}
>
<a href={tab.url} onClick={tab.onClick}>
{tab.label}

View File

@@ -335,6 +335,12 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
return;
}
if (filterState.value !== undefined) {
// Set the filter state value if it is defined
updateDataMask(filterState.value);
return;
}
// Case 2: Handle the default to first Value case
if (defaultToFirstItem) {
// Set to first item if defaultToFirstItem is true
@@ -353,6 +359,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
enableEmptyFilter,
defaultToFirstItem,
formData?.defaultValue,
filterState.value,
data,
groupby,
col,

View File

@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef } from 'react';
export function useOpenerRef(active: boolean) {
const openerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (active) {
openerRef.current = document.activeElement as HTMLElement;
}
}, [active]);
return openerRef;
}

View File

@@ -559,6 +559,8 @@ function AlertList({
url: '/alert/list/',
usesRouter: true,
'data-test': 'alert-list',
id: 'alert-tab',
'aria-controls': 'alert-list',
},
{
name: 'Reports',
@@ -566,6 +568,8 @@ function AlertList({
url: '/report/list/',
usesRouter: true,
'data-test': 'report-list',
id: 'report-tab',
'aria-controls': 'report-list',
},
]}
buttons={subMenuButtons}
@@ -623,24 +627,30 @@ function AlertList({
]
: [];
return (
<ListView<AlertObject>
className="alerts-list-view"
columns={columns}
count={alertsCount}
data={alerts}
emptyState={emptyState}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
refreshData={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
pageSize={PAGE_SIZE}
/>
<div
id={isReportEnabled ? 'report-list' : 'alert-list'}
role="tabpanel"
aria-labelledby={isReportEnabled ? 'report-tab' : 'alert-tab'}
>
<ListView<AlertObject>
className="alerts-list-view"
columns={columns}
count={alertsCount}
data={alerts}
emptyState={emptyState}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
refreshData={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
pageSize={PAGE_SIZE}
/>
</div>
);
}}
</ConfirmStatusChange>