feat: support screen reader in gridless sheet (#1756)

* feat: support screen reader on sheet

* feat: support screen reader

* fix: add hidden screen reader

* fix: failed test case

* fix: change to currentId

* fix: remove unrelated change

* fix: change to cell id

* fix: comments

* fix: add no title

* fix: failed test cases

* refactor: comments

* fix: remove ?
This commit is contained in:
Linh Nihlgård
2025-06-03 12:17:32 +02:00
committed by GitHub
parent 990d707b95
commit ffdbab0a28
10 changed files with 238 additions and 16 deletions

View File

@@ -234,5 +234,145 @@
"Visualization.Invalid.Measure": {
"value": "Invalid measure",
"comment": "Status text shown when a measure is not valid"
},
"Object.AutoChart": {
"value": "Autochart",
"comment": "Visualization in the library"
},
"Object.BarChart": {
"value": "Bar chart",
"comment": "Visualization in the library"
},
"Object.ComboChart": {
"value": "Combo chart",
"comment": "Visualization in the library"
},
"Object.Container": {
"value": "Container",
"comment": "Visualization in the library"
},
"Object.DistributionPlot": {
"value": "Distribution plot",
"comment": "Visualization in the library"
},
"Object.BoxPlot": {
"value": "Box plot",
"comment": "Visualization in the library"
},
"Object.FilterPane": {
"value": "Filter pane",
"comment": "Visualization in the library"
},
"Object.Gauge": {
"value": "Gauge",
"comment": "Visualization in the library"
},
"Object.Histogram": {
"value": "Histogram",
"comment": "Visualization in the library"
},
"Object.Kpi": {
"value": "KPI",
"comment": "Do not use more than a total of 19 characters when you translate this string."
},
"Object.LineChart": {
"value": "Line chart",
"comment": "Visualization in the library"
},
"Object.Listbox": {
"value": "List box",
"comment": "Visualization in the library"
},
"Object.PieChart": {
"value": "Pie chart",
"comment": "Visualization in the library"
},
"Object.PivotTable": {
"value": "Pivot table",
"comment": "Visualization in the library"
},
"Object.Map": {
"value": "Map",
"comment": "Visualization for geographical data"
},
"Object.ScatterPlot": {
"value": "Scatter plot",
"comment": "Visualization in the library"
},
"Object.StraightTable": {
"value": "Straight table",
"comment": "Visualization in the library."
},
"Object.TextImage": {
"value": "Text & image",
"comment": "Visualization in the library. Renamed from Utility object to Text & image"
},
"Object.Treemap": {
"value": "Treemap",
"comment": ""
},
"Object.WaterfallChart":{
"value": "Waterfall chart",
"comment": "Visualization in the library"
},
"Object.MekkoChart": {
"value": "Mekko chart",
"comment": "Visualization for?"
},
"Object.ActionButton": {
"value": "Button",
"comment": "Button for selections and navigation"
},
"Object.NavMenu": {
"value": "Navigation menu",
"comment": "A navigation menu for navigating sheets."
},
"Object.BulletChart": {
"value": "Bullet chart",
"comment": "Visualization for values using bars and ranges"
},
"Object.NlgChart": {
"value": "NL insights",
"comment": "Visualization in the library."
},
"Object.TabContainer": {
"value": "Tab container",
"comment": "Visualization in the library."
},
"Object.Table": {
"value": "Table",
"comment": "Visualization in the library"
},
"Object.Table.Deprecated": {
"value": "Table ",
"comment": "Visualization in the library that will be deprecated."
},
"Object.Text": {
"value": "Text",
"comment": "A chart used to display text, measures, add tables with rows and columns, etc."
},
"Object.LayoutContainer": {
"value": "Layout container",
"comment": "Visualization in the library."
},
"Object.GridChart": {
"value": "Grid chart",
"comment": "A chart used to present data in a matrix."
},
"Object.FunnelChart": {
"value": "Funnel chart",
"comment": "A chart often used to represent stages of a process."
},
"Object.SankeyChart": {
"value": "Sankey chart",
"comment": "A chart that shows the flow from a set of values to another."
},
"Object.RadarChart": {
"value": "Radar chart",
"comment": "A chart that displays multivariate data stacked at an axis with the same central point."
},
"Accessibility.Object.NoTitle": {
"value": "No title",
"comment": "Screen reader text for objects without a title"
}
}

View File

@@ -20,6 +20,8 @@ import eventmixin from '../selections/event-mixin';
import useStyling from '../hooks/useStyling';
import RenderError from '../utils/render-error';
import getPadding from '../utils/cell-padding';
import translationKeys from '../utils/extension-translation-keys';
import hiddenScreenReaderText from '../utils/style/screen-reader';
/**
* @interface
@@ -583,6 +585,7 @@ const Cell = forwardRef(
snPlugins={snPlugins}
layout={layout}
appLayout={appLayout}
cellId={currentId}
/>
);
}
@@ -607,7 +610,8 @@ const Cell = forwardRef(
translator,
});
}
const translationKey = translationKeys.get(layout?.visualization);
const translation = translator.get(translationKey);
return (
<Paper
style={{
@@ -644,7 +648,15 @@ const Cell = forwardRef(
...(useOldCellPadding ? { padding: theme.spacing(1) } : {}),
...(state.longRunningQuery ? { opacity: '0.3' } : {}),
}}
aria-labelledby={`${currentId}_title ${currentId}_type ${currentId}_content`}
>
{layout && (
<div
id={`${currentId}_type`}
style={hiddenScreenReaderText}
aria-label={translation ?? layout.visualization}
/>
)}
{cellNode && layout && state.sn && (
<Header
layout={layout}
@@ -654,6 +666,8 @@ const Cell = forwardRef(
focusHandler={focusHandler.current}
titleStyles={titleStyles}
isRtl={isRtl}
id={currentId}
translator={translator}
>
&nbsp;
</Header>

View File

@@ -4,6 +4,7 @@ import { styled } from '@mui/material/styles';
import { Grid, Tooltip, Typography } from '@mui/material';
import ActionsToolbar from './ActionsToolbar';
import hiddenScreenReaderText from '../utils/style/screen-reader';
const PREFIX = 'Header';
@@ -42,7 +43,7 @@ const CellSubTitle = {
className: 'njs-cell-sub-title',
};
function Header({ layout, sn, anchorEl, hovering, focusHandler, titleStyles = {}, isRtl }) {
function Header({ id, layout, sn, anchorEl, hovering, focusHandler, titleStyles = {}, isRtl, translator }) {
const showTitle = layout.showTitles && !!layout.title;
const showSubtitle = layout.showTitles && !!layout.subtitle;
const showInSelectionActions = layout.qSelectionInfo && layout.qSelectionInfo.qInSelections;
@@ -76,17 +77,28 @@ function Header({ layout, sn, anchorEl, hovering, focusHandler, titleStyles = {}
isRtl={isRtl}
/>
);
return (
<StyledGrid item container wrap="nowrap" className={cls.join(' ')}>
<Grid item zeroMinWidth xs dir={isRtl ? 'rtl' : 'ltr'}>
<Grid container wrap="nowrap" direction="column">
{showTitle && (
{showTitle ? (
<Tooltip title={layout.title}>
<Typography variant="h6" noWrap className={CellTitle.className} style={titleStyles.main}>
<Typography
id={`${id}_title`}
variant="h6"
noWrap
className={CellTitle.className}
style={titleStyles.main}
>
{layout.title}
</Typography>
</Tooltip>
) : (
<div
id={`${id}_title`}
style={hiddenScreenReaderText}
aria-label={translator.get('Accessibility.Object.NoTitle')}
/>
)}
{showSubtitle && (
<Tooltip title={layout.subtitle}>

View File

@@ -19,7 +19,7 @@ const VizElement = {
className: 'njs-viz',
};
function Supernova({ sn, snOptions: options, snPlugins: plugins, layout, appLayout, halo }) {
function Supernova({ sn, snOptions: options, snPlugins: plugins, layout, appLayout, halo, cellId }) {
const { component } = sn;
const { theme: themeName, language, constraints, interactions, keyboardNavigation } = useContext(InstanceContext);
@@ -135,7 +135,7 @@ function Supernova({ sn, snOptions: options, snPlugins: plugins, layout, appLayo
}}
className={VizElement.className}
>
<div ref={snRef} style={{ position: 'absolute', width: '100%', height: '100%' }} />
<div ref={snRef} id={`${cellId}_content`} style={{ position: 'absolute', width: '100%', height: '100%' }} />
</div>
);
}

View File

@@ -53,7 +53,7 @@ describe('<Cell />', () => {
Header = jest.fn().mockImplementation(() => 'Header');
InstanceContext = React.createContext();
appLayout = { foo: 'app-layout' };
layout = { qSelectionInfo: {}, visualization: '' };
layout = { qSelectionInfo: {}, visualization: '', qInfo: { qId: 'id' } };
layoutState = { validating: true, canCancel: false, canRetry: false };
longrunning = { cancel: jest.fn(), retry: jest.fn() };
useLayout = jest.fn().mockReturnValue([layout, layoutState, longrunning]);

View File

@@ -23,9 +23,14 @@ describe('<Header />', () => {
jest.spyOn(useRectModule, 'default').mockImplementation(() => [() => {}, rect]);
ActionsToolbarModule.default = ActionsToolbar;
render = async (layout = {}, sn = { component: {}, selectionToolbar: {} }, focusHandler = {}) => {
render = async (
layout = {},
sn = { component: {}, selectionToolbar: {} },
focusHandler = {},
translator = { get: (s) => s }
) => {
await act(async () => {
renderer = create(<Header layout={layout} sn={sn} focusHandler={focusHandler} />);
renderer = create(<Header layout={layout} sn={sn} focusHandler={focusHandler} translator={translator} />);
});
};
});
@@ -40,7 +45,7 @@ describe('<Header />', () => {
expect(types).toHaveLength(0);
});
test('should render title', async () => {
await render({ showTitles: true, title: 'foo' });
await render({ showTitles: true, title: 'foo', qInfo: { qId: 'id' } });
const types = renderer.root.findAllByType(Typography);
expect(types).toHaveLength(1);
expect(types[0].props.children).toBe('foo');

View File

@@ -16,7 +16,7 @@ describe('<Supernova />', () => {
sn = { component: {} },
snOptions = {},
snPlugins = [],
layout = {},
layout = { qInfo: { qId: 'id' } },
appLayout = {},
halo = {},
rendererOptions,
@@ -30,6 +30,7 @@ describe('<Supernova />', () => {
layout={layout}
appLayout={appLayout}
halo={halo}
cellId="id"
/>,
rendererOptions || null
);
@@ -47,9 +48,10 @@ describe('<Supernova />', () => {
},
snOptions: {},
snPlugins: [],
layout: {},
layout: { qInfo: { qId: 'id' } },
appLayout: {},
halo: {},
cellId: 'id',
});
});
test('should mount', async () => {
@@ -65,6 +67,7 @@ describe('<Supernova />', () => {
logicalSize,
component,
},
layout: { qInfo: { qId: 'id' } },
rendererOptions: {
createNodeMock: () => ({
style: {},
@@ -100,7 +103,7 @@ describe('<Supernova />', () => {
},
snOptions,
snPlugins: [],
layout: 'layout',
layout: { qInfo: { qId: 'id' } },
appLayout: { qLocaleInfo: 'loc' },
halo: { public: { theme: 'theme', nebbie: 'embedAPI' }, app: { session: {} } },
rendererOptions: {
@@ -115,7 +118,7 @@ describe('<Supernova />', () => {
expect(await initialRender).toBe(true);
expect(component.render).toHaveBeenCalledTimes(1);
expect(component.render.mock.calls[0][0]).toEqual({
layout: 'layout',
layout: { qInfo: { qId: 'id' } },
options: snOptions,
plugins: [],
embed: 'embedAPI',

View File

@@ -39,7 +39,10 @@ const model = {
function getDefaultProps() {
const containerRef = React.createRef();
const defaultProps = {
layout: { title: 'The title', qListObject: { qDimensionInfo: { qLocked: false, qGrouping: 'N' } } },
layout: {
title: 'The title',
qListObject: { qDimensionInfo: { qLocked: false, qGrouping: 'N' } },
},
translator,
styles,
isRtl: false,

View File

@@ -0,0 +1,40 @@
const translationKeys = new Map();
const extensions = [
['auto-chart', 'Object.AutoChart'],
['dummy-chart', 'Dummy'],
['barchart', 'Object.BarChart'],
['combochart', 'Object.ComboChart'],
['container', 'Object.Container'],
['distributionplot', 'Object.DistributionPlot'],
['boxplot', 'Object.BoxPlot'],
['filterpane', 'Object.FilterPane'],
['gauge', 'Object.Gauge'],
['histogram', 'Object.Histogram'],
['kpi', 'Object.Kpi'],
['linechart', 'Object.LineChart'],
['listbox', 'Object.Listbox'],
['piechart', 'Object.PieChart'],
['pivot-table', 'Object.PivotTable'],
['map', 'Object.Map'],
['scatterplot', 'Object.ScatterPlot'],
['sn-table', 'Object.StraightTable'],
['text-image', 'Object.TextImage'],
['treemap', 'Object.Treemap'],
['waterfallchart', 'Object.WaterfallChart'],
['mekkochart', 'Object.MekkoChart'],
['action-button', 'Object.ActionButton'],
['sn-nav-menu', 'Object.NavMenu'],
['bulletchart', 'Object.BulletChart'],
['sn-nlg-chart', 'Object.NlgChart'],
['sn-analysis-autochart', 'Common.AnalysisTypes'],
['sn-tabbed-container', 'Object.TabContainer'],
['qlik-sankey-chart-ext', 'Object.SankeyChart'],
['qlik-radar-chart', 'Object.RadarChart'],
['qlik-funnel-chart-ext', 'Object.FunnelChart'],
['sn-grid-chart', 'Object.GridChart'],
['sn-layout-container', 'Object.LayoutContainer'],
];
extensions.forEach(([key, value]) => {
translationKeys.set(key, value);
});
export default translationKeys;

View File

@@ -0,0 +1,5 @@
const hiddenScreenReaderText = {
width: 0,
height: 0,
};
export default hiddenScreenReaderText;