mirror of
https://github.com/apache/superset.git
synced 2026-05-29 11:45:16 +00:00
chore(Accessibility): Improve keyboard navigation and screen access (#33396)
This commit is contained in:
@@ -189,8 +189,10 @@ export function interceptFilterState() {
|
||||
export function setFilter(filter: string, option: string) {
|
||||
interceptFiltering();
|
||||
|
||||
cy.get(`[aria-label="${filter}"]`).first().click();
|
||||
cy.get(`[aria-label="${filter}"] [title="${option}"]`).click();
|
||||
cy.get(`[aria-label^="${filter}"]`).first().click();
|
||||
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.wait('@filtering');
|
||||
}
|
||||
@@ -346,8 +348,10 @@ export function addParentFilterWithValue(index: number, value: string) {
|
||||
return cy
|
||||
.get(nativeFilters.filterConfigurationSections.displayedSection)
|
||||
.within(() => {
|
||||
cy.get('input[aria-label="Limit type"]').eq(index).click({ force: true });
|
||||
cy.get('input[aria-label="Limit type"]')
|
||||
cy.get('input[aria-label^="Limit type"]')
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
cy.get('input[aria-label^="Limit type"]')
|
||||
.eq(index)
|
||||
.type(`${value}{enter}`, { delay: 30, force: true });
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('Test explore links', () => {
|
||||
// This time around, typing the same dashboard name
|
||||
// will select the existing one
|
||||
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
|
||||
.find('input[aria-label="Select a dashboard"]')
|
||||
.find('input[aria-label^="Select a dashboard"]')
|
||||
.type(`${dashboardTitle}{enter}`, { force: true });
|
||||
|
||||
cy.get(`.ant-select-item[label="${dashboardTitle}"]`).click({
|
||||
|
||||
@@ -61,8 +61,10 @@ export function interceptExploreGet() {
|
||||
export function setFilter(filter: string, option: string) {
|
||||
interceptFiltering();
|
||||
|
||||
cy.get(`[aria-label="${filter}"]`).first().click();
|
||||
cy.get(`[aria-label="${filter}"] [title="${option}"]`).click();
|
||||
cy.get(`[aria-label^="${filter}"]`).first().click();
|
||||
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.wait('@filtering');
|
||||
}
|
||||
@@ -76,17 +78,18 @@ export function saveChartToDashboard(dashboardName: string) {
|
||||
.should('be.enabled')
|
||||
.should('not.be.disabled')
|
||||
.click();
|
||||
cy.getBySelLike('chart-modal').should('be.visible');
|
||||
cy.get(
|
||||
'[data-test="save-chart-modal-select-dashboard-form"] [aria-label="Select a dashboard"]',
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
cy.get(
|
||||
'.ant-select-selection-search-input[aria-label="Select a dashboard"]',
|
||||
).type(dashboardName, { force: true });
|
||||
cy.get(`.ant-select-item-option[title="${dashboardName}"]`).click();
|
||||
cy.getBySel('btn-modal-save').click();
|
||||
cy.getBySelLike('chart-modal')
|
||||
.should('be.visible')
|
||||
.within(() => {
|
||||
cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
|
||||
.first()
|
||||
.click();
|
||||
cy.get('.ant-select-selection-search-input').type(dashboardName, {
|
||||
force: true,
|
||||
});
|
||||
cy.get(`.ant-select-item-option[title="${dashboardName}"]`).click();
|
||||
cy.getBySel('btn-modal-save').click();
|
||||
});
|
||||
|
||||
cy.wait('@update');
|
||||
cy.wait('@get');
|
||||
|
||||
@@ -94,9 +94,8 @@ 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, tabIndex: 0 } : {})}
|
||||
className={labelClass}
|
||||
style={{ cursor: onClick ? 'pointer' : '' }}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { JsonValue, useTheme } from '@superset-ui/core';
|
||||
import { JsonValue, t, useTheme } from '@superset-ui/core';
|
||||
import { ControlHeader } from '../../components/ControlHeader';
|
||||
|
||||
// [value, label]
|
||||
@@ -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,23 @@ export default function RadioButtonControl({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* accessibility begin */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
height: '1px',
|
||||
width: '1px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'%s tab selected',
|
||||
options.find(([val]) => val === currentValue)?.[1],
|
||||
)}
|
||||
</div>
|
||||
{/* accessibility end */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ test('table should be visible when expanded is true', async () => {
|
||||
name: 'Select database or type to search databases',
|
||||
});
|
||||
const schemaSelect = getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema or type to search schemas: main',
|
||||
});
|
||||
const tableSelect = getAllByLabelText(
|
||||
/Select table or type to search tables/i,
|
||||
|
||||
@@ -211,7 +211,7 @@ test('Refresh should work', async () => {
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(0);
|
||||
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
});
|
||||
|
||||
userEvent.click(select);
|
||||
@@ -324,7 +324,7 @@ test('Should schema select display options', async () => {
|
||||
const props = createProps();
|
||||
render(<DatabaseSelector {...props} />, { useRedux: true, store });
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
userEvent.click(select);
|
||||
@@ -370,7 +370,7 @@ test('Sends the correct schema when changing the schema', async () => {
|
||||
rerender(<DatabaseSelector {...props} />);
|
||||
expect(props.onSchemaChange).toHaveBeenCalledTimes(0);
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema or type to search schemas: public',
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
userEvent.click(select);
|
||||
|
||||
@@ -429,6 +429,10 @@ 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}
|
||||
@@ -479,6 +483,10 @@ 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}
|
||||
|
||||
@@ -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,
|
||||
...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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -99,7 +99,8 @@ const getElementByClassName = (className: string) =>
|
||||
const getElementsByClassName = (className: string) =>
|
||||
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
|
||||
|
||||
const getSelect = () => screen.getByRole('combobox', { name: ARIA_LABEL });
|
||||
const getSelect = () =>
|
||||
screen.getByRole('combobox', { name: new RegExp(ARIA_LABEL, 'i') });
|
||||
|
||||
const getAllSelectOptions = () =>
|
||||
getElementsByClassName('.ant-select-item-option-content');
|
||||
|
||||
@@ -600,7 +600,14 @@ const AsyncSelect = forwardRef(
|
||||
)}
|
||||
<StyledSelect
|
||||
allowClear={!isLoading && allowClear}
|
||||
aria-label={ariaLabel || name}
|
||||
aria-label={
|
||||
isSingleMode &&
|
||||
isLabeledValue(selectValue) &&
|
||||
typeof selectValue.label === 'string'
|
||||
? `${ariaLabel || name}: ${selectValue.label}`
|
||||
: ariaLabel || name
|
||||
}
|
||||
data-test={ariaLabel || name}
|
||||
autoClearSearchValue={autoClearSearchValue}
|
||||
dropdownRender={dropdownRender}
|
||||
filterOption={handleFilterOption}
|
||||
|
||||
@@ -82,7 +82,8 @@ const getElementByClassName = (className: string) =>
|
||||
const getElementsByClassName = (className: string) =>
|
||||
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
|
||||
|
||||
const getSelect = () => screen.getByRole('combobox', { name: ARIA_LABEL });
|
||||
const getSelect = () =>
|
||||
screen.getByRole('combobox', { name: new RegExp(ARIA_LABEL, 'i') });
|
||||
|
||||
const selectAllButtonText = (length: number) => `Select all (${length})`;
|
||||
const deselectAllButtonText = (length: number) => `Deselect all (${length})`;
|
||||
|
||||
@@ -675,7 +675,14 @@ const Select = forwardRef(
|
||||
<StyledSelect
|
||||
id={name}
|
||||
allowClear={!isLoading && allowClear}
|
||||
aria-label={ariaLabel}
|
||||
aria-label={
|
||||
isSingleMode &&
|
||||
isLabeledValue(selectValue) &&
|
||||
typeof selectValue.label === 'string'
|
||||
? `${ariaLabel || name}: ${selectValue.label}`
|
||||
: ariaLabel || name
|
||||
}
|
||||
data-test={ariaLabel || name}
|
||||
autoClearSearchValue={autoClearSearchValue}
|
||||
dropdownRender={dropdownRender}
|
||||
filterOption={handleFilterOption}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ test('renders with default props', async () => {
|
||||
name: 'Select database or type to search databases',
|
||||
});
|
||||
const schemaSelect = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type to search schemas',
|
||||
name: 'Select schema or type to search schemas: test_schema',
|
||||
});
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: 'Select table or type to search tables',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,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>
|
||||
);
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('VizTypeControl', () => {
|
||||
|
||||
const visualizations = screen.getByTestId(getTestId('viz-row'));
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'ballot All charts' }));
|
||||
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
|
||||
|
||||
expect(
|
||||
await within(visualizations).findByText('Line Chart'),
|
||||
@@ -247,7 +247,7 @@ describe('VizTypeControl', () => {
|
||||
|
||||
it('Submit on viz type double-click', async () => {
|
||||
await waitForRenderWrapper();
|
||||
userEvent.click(screen.getByRole('button', { name: 'ballot All charts' }));
|
||||
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
|
||||
const visualizations = screen.getByTestId(getTestId('viz-row'));
|
||||
userEvent.click(within(visualizations).getByText('Bar Chart'));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
32
superset-frontend/src/hooks/useOpenerRef.ts
Normal file
32
superset-frontend/src/hooks/useOpenerRef.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -152,8 +152,8 @@ test('renders an enabled button if datasource and viz type are selected', async
|
||||
userEvent.click(await screen.findByText(/test_db/i));
|
||||
|
||||
userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /ballot all charts/i,
|
||||
screen.getByRole('tab', {
|
||||
name: /All charts/i,
|
||||
}),
|
||||
);
|
||||
userEvent.click(await screen.findByText('Table'));
|
||||
@@ -167,8 +167,8 @@ test('double-click viz type does nothing if no datasource is selected', async ()
|
||||
await renderComponent();
|
||||
|
||||
userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /ballot all charts/i,
|
||||
screen.getByRole('tab', {
|
||||
name: /All charts/i,
|
||||
}),
|
||||
);
|
||||
userEvent.dblClick(await screen.findByText('Table'));
|
||||
@@ -187,8 +187,8 @@ test('double-click viz type submits with formatted URL if datasource is selected
|
||||
userEvent.click(await screen.findByText(/test_db/i));
|
||||
|
||||
userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: /ballot all charts/i,
|
||||
screen.getByRole('tab', {
|
||||
name: /All charts/i,
|
||||
}),
|
||||
);
|
||||
userEvent.dblClick(await screen.findByText('Table'));
|
||||
|
||||
Reference in New Issue
Block a user