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
22 changes: 21 additions & 1 deletion src/components/Banner/Banner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,42 @@ const BannerContainer = styled.div`
width: 42rem;
`;

const Caption = styled.p`
margin: 0 0 1.2rem;
`;

const presentAtLoad = <>
Present when the page loads, so it is not announced. If it appears because of something the user or
the app did, use <code>BannerRegion</code>.
</>;

export const Error = () => (
<BannerContainer>
<Caption>
This shows how an error looks. An error like this usually comes from a request that fails
after the page loads, and then it belongs in a <code>BannerRegion</code> so it is announced.
</Caption>
<Banner messages={['This is an error message']} severity='error' />
</BannerContainer>
);

export const Warning = () => (
<BannerContainer>
<Caption>{presentAtLoad}</Caption>
<Banner messages={['This is a warning message']} severity='warning' />
</BannerContainer>
);

export const Note = () => (
<BannerContainer>
<Caption>{presentAtLoad}</Caption>
<Banner messages={['This is a note message']} severity='note' />
</BannerContainer>
);

export const MultipleMessages = () => (
<BannerContainer>
<Caption>{presentAtLoad}</Caption>
<Banner messages={['First message', 'Second message', 'Third message']} severity='warning' />
</BannerContainer>
);
Expand All @@ -37,11 +53,15 @@ export const Dismissible = () => {
const [visible, setVisible] = React.useState(true);
return visible ? (
<BannerContainer>
<Caption>
Present when the page loads, and removed by the user, so nothing needs announcing. A banner
that appears later belongs in a <code>BannerRegion</code>.
</Caption>
<Banner
messages={['This is a dismissible warning message']}
severity='warning'
onDismiss={() => setVisible(false)}
/>
</BannerContainer>
) : null;
};
};
4 changes: 4 additions & 0 deletions src/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export const CloseButton = styled(Button)<{severity: BannerSeverity}>`
}
`;

/**
* A message box that does not announce itself. For a banner that appears or changes because of
* something the user or the app did, such as a failed request, use `BannerRegion`.
*/
export const Banner = (props: {messages: string[]; severity: BannerSeverity; onDismiss?: () => void}) => {
const numWarnings = props.messages.length;

Expand Down
5 changes: 5 additions & 0 deletions src/components/Banner/BannerRegion.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* Empty, the region leaves the layout, so a flex or grid parent does not count it as an item
with its own gap or cell. It stays in the accessibility tree, which is what lets it announce. */
.banner-region:empty {
position: absolute;
}
57 changes: 57 additions & 0 deletions src/components/Banner/BannerRegion.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import "@testing-library/jest-dom";
import { render, fireEvent } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { BannerRegion } from "./BannerRegion";

describe('BannerRegion', () => {
it('renders the region before there is anything to announce', () => {
const { getByRole } = render(<BannerRegion messages={[]} severity='warning' />);
expect(getByRole('status')).toBeEmptyDOMElement();
});

it('is :empty until there is something to announce, which its layout rule depends on', () => {
const { getByRole, rerender } = render(<BannerRegion messages={[]} severity='warning' />);
expect(getByRole('status').matches(':empty')).toBe(true);

rerender(<BannerRegion messages={['Heads up']} severity='warning' />);
expect(getByRole('status').matches(':empty')).toBe(false);
});

it('renders messages from the first render as page content, not after an effect', () => {
// effects do not run in a server render, so this only passes if the banner is in the
// first render's output
const html = renderToStaticMarkup(<BannerRegion messages={['Heads up']} severity='warning' />);

expect(html).toContain('role="status"');
expect(html).toContain('Heads up');
});

it('keeps its own class alongside a caller class', () => {
const { getByRole } = render(<BannerRegion messages={[]} severity='warning' className='caller' />);

expect(getByRole('status')).toHaveClass('banner-region', 'caller');
});

it('announces into the region that was already there', () => {
const { getByRole, rerender } = render(<BannerRegion messages={[]} severity='warning' />);
const region = getByRole('status');

rerender(<BannerRegion messages={['Heads up']} severity='warning' />);

// same element, new content — a region that arrives together with its
// content is the case AT misses, so this is the property worth asserting
expect(getByRole('status')).toBe(region);
expect(region).toHaveTextContent('Heads up');
});

it('passes props through to the banner', () => {
const onDismiss = jest.fn();
const { getByLabelText } = render(
<BannerRegion messages={['Heads up']} severity='error' onDismiss={onDismiss} />
);

fireEvent.click(getByLabelText('dismiss'));

expect(onDismiss).toHaveBeenCalled();
});
});
66 changes: 66 additions & 0 deletions src/components/Banner/BannerRegion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { Banner } from './Banner';
import { BannerRegion } from './BannerRegion';
import { Button } from '../Button';

const message = 'Your assignment is past due and cannot be edited';

export const Announced = () => {
const [messages, setMessages] = React.useState<string[]>([]);

return <div style={{width: '42rem'}}>
<p>
With a screen reader on, press the button. The warning is announced: the region was already
on the page, empty, when its content arrived.
</p>
<BannerRegion messages={messages} severity='warning' onDismiss={() => setMessages([])} />
<Button onClick={() => setMessages([message])}>
Show a warning
</Button>
</div>;
};

export const ComparedWithBanner = () => {
const [plain, setPlain] = React.useState<string[]>([]);
const [region, setRegion] = React.useState<string[]>([]);

return <div style={{display: 'flex', gap: '3rem', alignItems: 'flex-start'}}>
<section style={{width: '30rem'}}>
<h3>Banner</h3>
<p>
No live region. Some screen readers announce it when it appears and others, such as
VoiceOver in Safari, do not. Right for a banner that is already there when the page loads.
</p>
{plain.length
? <Banner messages={plain} severity='warning' onDismiss={() => setPlain([])} />
: null}
<Button onClick={() => setPlain([message])}>Show in a Banner</Button>
</section>
<section style={{width: '30rem'}}>
<h3>BannerRegion</h3>
<p>
The live region is already on the page, so the warning is announced. Right for a banner
that appears because of something the user or the app did, such as a failed request.
</p>
<BannerRegion messages={region} severity='warning' onDismiss={() => setRegion([])} />
<Button onClick={() => setRegion([message])}>Show in a BannerRegion</Button>
</section>
</div>;
};

export const InAFlexContainer = () => {
const [messages, setMessages] = React.useState<string[]>([]);
const item = {background: 'rgb(205, 221, 238)', padding: '0.4rem 1.2rem'};

return <div style={{width: '42rem'}}>
<p>
The region sits between the two items in a row with a 2rem gap. Empty, it adds no extra gap.
</p>
<div style={{display: 'flex', gap: '2rem', alignItems: 'center'}}>
<span style={item}>First</span>
<BannerRegion messages={messages} severity='warning' onDismiss={() => setMessages([])} />
<span style={item}>Second</span>
</div>
<Button onClick={() => setMessages([message])}>Show a warning</Button>
</div>;
};
41 changes: 41 additions & 0 deletions src/components/Banner/BannerRegion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import classNames from "classnames";
import { Banner, BannerSeverity } from "./Banner";
import "./BannerRegion.css";

/**
* A Banner that announces itself when it appears.
*
* The wrapper is always rendered and the Banner is conditional inside it,
* which is the part that is easy to get wrong: a live region has to already
* be in the accessibility tree before its content changes. Putting the role
* on the Banner itself would insert the region and its content in the same
* tick, and AT reads that as initial state rather than a change — Chrome +
* NVDA usually announces it anyway, Safari + VoiceOver reliably does not.
*
* Use it when something the user or the app did causes the banner to
* appear or change, such as a failed request. Use plain `Banner` for one
* that is already on the page when it loads: steady-state information
* inside a live region announces itself again on every remount, for a
* change the user never made.
*
* Messages present on the first render are page content, read in order, and are not
* announced. Only messages that arrive after it has mounted are, so keep it mounted rather
* than rendering it only when there is something to show.
*
* While it is empty the wrapper is taken out of flow, so in a flex or grid parent it does
* not add a gap or take a grid cell. It stays in the accessibility tree, and with a banner
* in it, it is an ordinary block; `className` styles it then.
*
* `role="status"` is polite for every severity, deliberately. Mapping
* `error` to `role="alert"` would make a banner rendered on page load
* interrupt whatever the user was doing; severity describes how loud the
* banner looks, not how urgently it needs to reach someone.
*/
export const BannerRegion = ({className, ...props}: {
messages: string[];
severity: BannerSeverity;
onDismiss?: () => void;
className?: string;
}) => <div role='status' className={classNames('banner-region', className)}>
{props.messages.length ? <Banner {...props} /> : null}
</div>;
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './components/Error';
export * from './components/Html';
export * from './components/MessageBox/MessageBox';
export * from './components/Banner/Banner';
export * from './components/Banner/BannerRegion';
export * from './components/ErrorBoundary';
export * from './components/ErrorMessage';
export * from './components/ErrorModal';
Expand Down
Loading