Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/eui/changelogs/upcoming/8839.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Accessibility**

- Improved the experience of `EuiProgress` by ensuring that determinate updates are read out immediately to screen readers

Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,25 @@ exports[`EuiProgress has labelProps 1`] = `
class="euiProgress__data emotion-euiProgress__data"
>
<span
aria-hidden="true"
class="euiProgress__valueText emotion-euiProgress__valueText-success"
title="150"
>
150
</span>
</div>
<div
aria-atomic="true"
aria-live="polite"
class="emotion-euiScreenReaderOnly"
>
<span>
150
</span>
</div>
<progress
aria-hidden="false"
aria-label="aria-label"
aria-valuetext="150"
class="euiProgress testClass1 testClass2 emotion-euiProgress-native-m-static-success-euiTestCss"
data-test-subj="test subject string"
max="100"
Expand All @@ -133,13 +143,13 @@ exports[`EuiProgress has labelProps 1`] = `
`;

exports[`EuiProgress has max 1`] = `
<progress
aria-hidden="false"
aria-label="aria-label"
class="euiProgress testClass1 testClass2 emotion-euiProgress-native-m-static-success-euiTestCss"
data-test-subj="test subject string"
max="100"
/>
<div
aria-atomic="true"
aria-live="polite"
class="emotion-euiScreenReaderOnly"
>
<span />
</div>
`;

exports[`EuiProgress has value 1`] = `
Expand All @@ -156,21 +166,33 @@ exports[`EuiProgress has valueText and label 1`] = `
class="euiProgress__data emotion-euiProgress__data"
>
<span
aria-hidden="true"
class="euiProgress__label emotion-euiProgress__label"
title="Label"
>
Label
</span>
<span
aria-hidden="true"
class="euiProgress__valueText emotion-euiProgress__valueText-success"
title="150"
>
150
</span>
</div>
<div
aria-atomic="true"
aria-live="polite"
class="emotion-euiScreenReaderOnly"
>
<span>
Label
150
</span>
</div>
<progress
aria-hidden="true"
aria-label="aria-label"
aria-valuetext="150"
class="euiProgress testClass1 testClass2 emotion-euiProgress-native-m-static-success-euiTestCss"
data-test-subj="test subject string"
max="100"
Expand All @@ -180,14 +202,15 @@ exports[`EuiProgress has valueText and label 1`] = `
`;

exports[`EuiProgress is determinate 1`] = `
<progress
aria-hidden="false"
aria-label="aria-label"
class="euiProgress testClass1 testClass2 emotion-euiProgress-native-m-static-success-euiTestCss"
data-test-subj="test subject string"
max="100"
value="50"
/>
<div
aria-atomic="true"
aria-live="polite"
class="emotion-euiScreenReaderOnly"
>
<span>
50
</span>
</div>
`;

exports[`EuiProgress is indeterminate 1`] = `
Expand Down Expand Up @@ -236,15 +259,25 @@ exports[`EuiProgress valueText is true 1`] = `
class="euiProgress__data emotion-euiProgress__data"
>
<span
aria-hidden="true"
class="euiProgress__valueText emotion-euiProgress__valueText-success"
title="50%"
>
50%
</span>
</div>
<div
aria-atomic="true"
aria-live="polite"
class="emotion-euiScreenReaderOnly"
>
<span>
50%
</span>
</div>
<progress
aria-hidden="false"
aria-label="aria-label"
aria-valuetext="50%"
class="euiProgress testClass1 testClass2 emotion-euiProgress-native-m-static-success-euiTestCss"
data-test-subj="test subject string"
max="100"
Expand Down
109 changes: 108 additions & 1 deletion packages/eui/src/components/progress/progress.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
* Side Public License, v 1.
*/

import React, { useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { EuiProgress, COLORS } from './progress';
import { EuiButton } from '../button';
import { EuiFlexGroup, EuiFlexItem } from '../flex';

const meta: Meta<typeof EuiProgress> = {
title: 'Display/EuiProgress',
Expand All @@ -18,7 +21,15 @@ const meta: Meta<typeof EuiProgress> = {
// for quicker/easier QA
label: { control: 'text' },
value: { control: 'number' },
valueText: { control: 'boolean' },
valueText: {
control: 'radio',
options: ['custom', 'true', 'false'],
mapping: {
custom: 'steps',
true: true,
false: false,
},
},
},
args: {
color: 'success',
Expand Down Expand Up @@ -53,4 +64,100 @@ export const HighContrast: Story = {
size: 'xs',
color: 'primary',
},
render: (args) => <EuiProgress {...args} />,
};

export const DeterminateLoading: Story = {
parameters: {
controls: {
include: ['label', 'value', 'valueText', 'max'],
},
codeSnippet: {
resolveStoryElementOnly: true,
},
loki: {
skip: true,
},
},
args: {
label: 'Loading',
value: 70,
max: 100,
},
render: function Render(args) {
const { value, valueText, max } = args;
const maxValue = max ?? 100;
const hasCustomValueText = valueText === 'steps';

const [loading, setLoading] = useState<number>(
typeof value === 'number'
? value
: typeof value === 'string'
? parseInt(value)
: 0
);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | undefined>(
undefined
);

const cleanInterval = (id: NodeJS.Timeout | undefined) => {
if (id !== undefined) {
clearInterval(id);
setIntervalId(undefined);
}
};

useEffect(() => {
if (loading >= maxValue) {
cleanInterval(intervalId);
}
}, [intervalId, loading, maxValue]);

useEffect(() => {
return () => {
cleanInterval(intervalId);
};
}, []);

const increment = () => {
setLoading((prev: number) => {
if (prev >= maxValue) return 0;

return prev + 10;
});
};

const startLoading = () => {
if (loading === 0 && intervalId === undefined) {
const _intervalId = setInterval(() => increment(), 1000);

setIntervalId(_intervalId);
} else {
setLoading(0);
clearInterval(intervalId);
setIntervalId(undefined);
}
};

Comment thread
weronikaolejniczak marked this conversation as resolved.
return (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton onClick={startLoading}>
{loading === 0 ? 'Start' : 'Reset'}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
{/* casting due to ExclusiveUnion complexity */}
<EuiProgress
{...(args as typeof EuiProgress)}
max={maxValue}
value={loading}
valueText={
hasCustomValueText ? `${loading} ${valueText}` : valueText
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
},
};
48 changes: 43 additions & 5 deletions packages/eui/src/components/progress/progress.tsx
Comment thread
mgadewoll marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import React, {
ProgressHTMLAttributes,
ReactNode,
CSSProperties,
useState,
useRef,
useEffect,
MutableRefObject,
} from 'react';
import classNames from 'classnames';
import { EuiI18n } from '../i18n';
Expand All @@ -20,6 +24,7 @@ import { CommonProps, ExclusiveUnion } from '../common';
import { isNil } from '../../services/predicate';

import { useEuiTheme, makeHighContrastColor } from '../../services';
import { EuiScreenReaderOnly } from '../accessibility';
import {
euiProgressStyles,
euiProgressDataStyles,
Expand Down Expand Up @@ -67,9 +72,15 @@ type Indeterminate = EuiProgressProps & HTMLAttributes<HTMLDivElement>;

type Determinate = EuiProgressProps &
Omit<ProgressHTMLAttributes<HTMLProgressElement>, 'max'> & {
/**
* When set, creates determinate progress with a value/max ratio
*/
max?: number;
/*
* If true, will render the percentage, otherwise pass a custom node
/**
* Displays custom text or percentage
* Pass `true` to display the percentage value
* Pass a ReactNode for custom text
* @default false
*/
valueText?: boolean | ReactNode;
label?: ReactNode;
Expand All @@ -93,6 +104,11 @@ export const EuiProgress: FunctionComponent<
labelProps,
...rest
}) => {
const valueTextRef: MutableRefObject<HTMLSpanElement | null> = useRef(null);
const labelRef: MutableRefObject<HTMLSpanElement | null> = useRef(null);
const [innerValueText, setInnerValueText] = useState<string | undefined>();
const [labelText, setLabelText] = useState<string | undefined>();

const determinate = !isNil(max);
const isNamedColor = COLORS.includes(color as EuiProgressColor);

Expand Down Expand Up @@ -149,6 +165,11 @@ export const EuiProgress: FunctionComponent<
valueRender = valueText;
}

useEffect(() => {
setInnerValueText(valueTextRef.current?.textContent ?? '');
setLabelText(labelRef.current?.textContent ?? '');
}, [label, valueRender, value]);

// Because of a Firefox animation issue, indeterminate progress needs to not use <progress />.
// See https://css-tricks.com/html5-progress-element/

Expand All @@ -162,10 +183,14 @@ export const EuiProgress: FunctionComponent<
{(ref, innerText) => (
<span
title={innerText}
ref={ref}
ref={(node) => {
labelRef.current = node;
ref?.(node);
}}
{...labelProps}
className={labelClasses}
css={labelCssStyles}
aria-hidden="true"
>
{label}
</span>
Expand All @@ -177,10 +202,14 @@ export const EuiProgress: FunctionComponent<
{(ref, innerText) => (
<span
title={innerText}
ref={ref}
ref={(node) => {
valueTextRef.current = node;
ref?.(node);
}}
style={customTextColorStyles}
css={valueTextCssStyles}
className="euiProgress__valueText"
aria-hidden="true"
>
{valueRender}
</span>
Expand All @@ -189,13 +218,22 @@ export const EuiProgress: FunctionComponent<
)}
</div>
) : undefined}
<EuiScreenReaderOnly>
<div aria-live="polite" aria-atomic="true">
<span>
{label && `${label} `}
{valueRender || value}
</span>
</div>
</EuiScreenReaderOnly>
Comment thread
mgadewoll marked this conversation as resolved.
<progress
css={cssStyles}
className={classes}
style={customColorStyles}
max={max}
value={value}
aria-hidden={label && valueText ? true : false}
Copy link
Copy Markdown
Contributor Author

@mgadewoll mgadewoll Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Removing aria-hidden is intentional but I'm open to opinions here.

The previous idea here was that the progress bar is not needed as it's a graphical representation of the valueText.
Why I chose to always have the <progress> element on the accessibility tree has a couple reasons:

  • a) the element is perceivable in the DOM on user navigation and provides semantic information on demand next to the automatic updates (a user navigating to this element will hear it's semantic role and it's aria-valuetext e.g. "progressbar 10%`)
  • b) the available <progress> element can trigger update sounds (not reading it's value but providing a sound-icon indication of progress) when available
  • c) providing the label and progress aligns the semantic screen reader experience closer with the visual output

aria-valuetext={innerValueText || undefined}
aria-label={labelText || undefined}
{...(rest as ProgressHTMLAttributes<HTMLProgressElement>)}
/>
</>
Expand Down