Skip to content
Draft
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
19 changes: 19 additions & 0 deletions .changeset/nasty-planes-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@salt-ds/core": minor
---

- `AriaAnnouncer` improvements, previously `delay` was used to queue announcements, but it could create overlapping announcements. We have deprecated `delay`, although it is still supported. Instead, we have added a new options API.
- `duration`: provides a sequential delay between announcements, ensuring they do not overlap.
- `ariaLive` determines the importance and urgency of the announcement.

The default duration is 500 msecs, unless specified.

```diff
import { useAriaAnnouncer } from "./useAriaAnnouncer";

const { announce } = useuseAriaAnnouncer();

- const delayMsec = 1000;
- announce("my announcement", delayMsec);
+ announce("my announcement", { duration: 1000, ariaLive: "polite"});
```
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ARIA_ANNOUNCE_DELAY,
type AnnounceFnOptions,
AriaAnnouncerProvider,
DEFAULT_ANNOUNCEMENT_DURATION,
useAriaAnnouncer,
} from "@salt-ds/core";
import { mount } from "cypress/react18";
Expand All @@ -10,25 +11,28 @@ const BUTTON_TEXT_WAIT = "CLICK ME AND WAIT";

const TestComponent = ({
announcement,
ariaLive,
delay,
debounce,
duration,
getAnnouncement,
}: {
announcement?: string;
ariaLive?: AnnounceFnOptions["ariaLive"];
delay?: number;
duration?: AnnounceFnOptions["duration"];
debounce?: number;
} & (
| { announcement?: never; getAnnouncement: () => string }
| { announcement: string; getAnnouncement?: never }
)) => {
getAnnouncement?: () => string;
}) => {
const { announce } = useAriaAnnouncer({ debounce });
const getMessageToAnnounce = () =>
getAnnouncement ? getAnnouncement() : announcement;
const getMessageToAnnounce = (): string =>
getAnnouncement ? getAnnouncement() : (announcement ?? "");

return (
<>
<button
onClick={() => {
announce(getMessageToAnnounce());
announce(getMessageToAnnounce(), { duration, ariaLive });
}}
>
{BUTTON_TEXT}
Expand All @@ -44,108 +48,99 @@ const TestComponent = ({
);
};

describe("Given a AriaAnnouncerProvider", () => {
it("should not affect the document flow", () => {
mount(
<div style={{ height: "100%", width: "100%" }}>
<AriaAnnouncerProvider>
<div style={{ height: "100%", width: "100%" }} />
</AriaAnnouncerProvider>
</div>,
);

cy.document().then((doc) => {
const style = doc.createElement("style");
style.innerHTML = `
body, html {
height: 100%;
display: block;
min-height: auto;
}
[data-cy-root] {
height: 100%;
}
`;
doc.head.appendChild(style);
const documentHeight = doc.documentElement.getBoundingClientRect().height;
const documentScrollHeight = document.documentElement.scrollHeight;
expect(documentHeight).to.equal(documentScrollHeight);
doc.head.removeChild(style);
});
});
it.skip("should allow for style overrides on the [aria-live] element", () => {
mount(<AriaAnnouncerProvider style={{ borderWidth: 1 }} />);

// TODO: figure out why this doesn't work
cy.get("[aria-live]").should("have.css", "border-width", "1px");
});
});

describe("Given useAriaAnnouncer", () => {
it("should trigger an announcement", () => {
mount(
<AriaAnnouncerProvider>
<TestComponent announcement="test" />
</AriaAnnouncerProvider>,
);
cy.findByText(BUTTON_TEXT).click();

cy.findByText(BUTTON_TEXT).realClick();
cy.get("[aria-live]").should("have.attr", "aria-live", "assertive");
cy.get("[aria-live]").should("have.text", "test");
});

describe("given a delay", () => {
describe("given a legacy delay", () => {
it("should trigger an announcement after that delay", () => {
mount(
<AriaAnnouncerProvider>
<TestComponent announcement="test" delay={500} />
</AriaAnnouncerProvider>,
);
cy.findByText(BUTTON_TEXT_WAIT).click();

cy.findByText(BUTTON_TEXT_WAIT).realClick();
cy.get("[aria-live]").should("not.have.text", "test");

cy.wait(510);

cy.get("[aria-live]", { timeout: 0 }).should("have.text", "test");
});
});

describe("given a debounce", () => {
it("should create an announce method that triggers an announcement after that delay", () => {
describe("given a duration", () => {
it("should trigger an announcement which persists in the DOM for a duration", () => {
mount(
<AriaAnnouncerProvider>
<TestComponent announcement="test" debounce={500} />
<TestComponent announcement="test" duration={250} />
</AriaAnnouncerProvider>,
);
cy.findByText(BUTTON_TEXT).click();

cy.get("[aria-live]").should("not.have.text", "test");
cy.findByText(BUTTON_TEXT).realClick();
cy.get("[aria-live]").should("have.text", "test");
cy.wait(250);
cy.get("[aria-live]", { timeout: 0 }).should("have.text", "");
});
});

cy.wait(510);
describe("given an ariaLive", () => {
it("should trigger an announcement with the specified urgency", () => {
mount(
<AriaAnnouncerProvider>
<TestComponent announcement="test" ariaLive={"polite"} />
</AriaAnnouncerProvider>,
);
cy.findByText(BUTTON_TEXT).realClick();
cy.get("[aria-live]").should("have.attr", "aria-live", "polite");
cy.get("[aria-live]").should("have.text", "test");
});
});

cy.get("[aria-live]", { timeout: 0 }).should("have.text", "test");
describe("given a debounce", () => {
let increment = 0;
it("should create an announce method that triggers an announcement after that delay", () => {
mount(
<AriaAnnouncerProvider>
<TestComponent
debounce={500}
getAnnouncement={() => {
increment++;
return `test ${increment}`;
}}
/>
</AriaAnnouncerProvider>,
);
cy.findByText(BUTTON_TEXT).realClick().realClick().realClick();
cy.get("[aria-live]").should("have.text", "test 3");
cy.wait(DEFAULT_ANNOUNCEMENT_DURATION);
cy.get("[aria-live]", { timeout: 0 }).should("have.text", "");
});
});

describe("given two queued up announcements", () => {
it(`should render the queued announcements one after the other with a ${ARIA_ANNOUNCE_DELAY}ms delay`, () => {
it("should render the queued announcements one after the other with a delay", () => {
let increment = 0;
mount(
<AriaAnnouncerProvider>
<TestComponent
duration={250}
getAnnouncement={() => {
increment++;
return `test ${increment}`;
}}
/>
</AriaAnnouncerProvider>,
);
cy.findByText(BUTTON_TEXT).click().click();

cy.findByText(BUTTON_TEXT).realClick().realClick();
cy.get("[aria-live]").should("have.text", "test 1");

cy.wait(ARIA_ANNOUNCE_DELAY);

cy.wait(250);
cy.get("[aria-live]", { timeout: 0 }).should("have.text", "");
cy.wait(250);
cy.get("[aria-live]", { timeout: 0 }).should("have.text", "test 2");
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {
AriaAnnounce,
AriaAnnouncerProvider,
DEFAULT_ANNOUNCEMENT_DURATION,
useAriaAnnouncer,
} from "@salt-ds/core";
import { mount } from "cypress/react18";
import { type ReactNode, useState } from "react";

const BUTTON_LEGACY_TEXT = "LEGACY";
const BUTTON_TEXT = "CLICK ME";
const BUTTON_TEXT_WAIT = "CLICK ME AND WAIT";
const ANNOUNCEMENT = "ANNOUNCEMENT";

const TestWrapper = ({ children }: { children?: ReactNode }) => (
Expand All @@ -18,13 +19,15 @@ interface SimpleTestContentProps {
announcement?: string;
delay?: number;
debounce?: number;
duration?: number;
getAnnouncement?: () => string;
}

const SimpleTestContent = ({
announcement,
delay,
debounce,
duration,
getAnnouncement,
}: SimpleTestContentProps) => {
const { announce } = useAriaAnnouncer({ debounce });
Expand All @@ -36,7 +39,7 @@ const SimpleTestContent = ({
<button
onClick={() => {
const message = getMessageToAnnounce();
if (message != null) announce(message);
if (message != null) announce(message, { duration });
}}
>
{BUTTON_TEXT}
Expand All @@ -47,7 +50,7 @@ const SimpleTestContent = ({
if (message != null) announce(message, delay);
}}
>
{BUTTON_TEXT_WAIT}
{BUTTON_LEGACY_TEXT}
</button>
</>
);
Expand Down Expand Up @@ -91,11 +94,11 @@ describe("aria-announcer", () => {
</TestWrapper>,
);

cy.findByText(BUTTON_TEXT).realClick();
cy.findByText(BUTTON_TEXT).realClick();

cy.findByText(BUTTON_TEXT).realClick().realClick();
cy.findByText("Announcement 1").should("exist");
cy.wait(DEFAULT_ANNOUNCEMENT_DURATION);
cy.findByText("Announcement 1").should("not.exist");
cy.wait(DEFAULT_ANNOUNCEMENT_DURATION);
cy.findByText("Announcement 2").should("exist");
});
});
Expand Down Expand Up @@ -125,57 +128,25 @@ describe("aria-announcer", () => {
</TestWrapper>,
);

cy.findByText(BUTTON_TEXT).realClick(); // 'Announcement 1'
cy.findByText(BUTTON_TEXT).realClick(); // 'Announcement 2'
cy.findByText(BUTTON_TEXT).realClick(); // 'Announcement 3'

// We should see the last announcement only
cy.findByText("Announcement 3").should("exist");
cy.findByText(BUTTON_TEXT).realClick().realClick().realClick();
cy.get("[aria-live]").should("have.text", "Announcement 3");
cy.wait(DEFAULT_ANNOUNCEMENT_DURATION);
cy.get("[aria-live]", { timeout: 0 }).should("have.text", "");
});
});

describe("Delayed Announcements", () => {
it("delays individual announcements, when configured to do so", () => {
it("legacy API, delays individual announcements, when configured to do so", () => {
mount(
<TestWrapper>
<SimpleTestContent announcement={ANNOUNCEMENT} delay={500} />
<SimpleTestContent announcement={ANNOUNCEMENT} delay={250} />
</TestWrapper>,
);

cy.clock();

cy.findByText(BUTTON_TEXT_WAIT).realClick();

// This won't trigger anything as we're waiting on a 500ms delay
cy.tick(150);

cy.findByText(ANNOUNCEMENT).should("not.exist");

// This will fire the scheduled delay, rendering the announcement
cy.tick(400);

cy.findByText(BUTTON_LEGACY_TEXT).realClick();
cy.findByText(ANNOUNCEMENT).should("exist");
// This will trigger the auto-scheduled 'cleanup' to remove the announcement again
cy.tick(200);

cy.wait(250);
cy.findByText(ANNOUNCEMENT).should("not.exist");
});
it("announces regular messages before delayed messages, when configured to do so", () => {
let count = 1;
const getAnnouncement = () => `Announcement ${count++}`;
mount(
<TestWrapper>
<SimpleTestContent delay={500} getAnnouncement={getAnnouncement} />
</TestWrapper>,
);

// Because the first announcement is delayed, the second message will be announced first
cy.findByText(BUTTON_TEXT_WAIT).realClick();
cy.findByText(BUTTON_TEXT).realClick();

cy.findByText("Announcement 2").should("exist");

cy.findByText("Announcement 1").should("exist");
});
});
});
22 changes: 17 additions & 5 deletions packages/core/src/aria-announcer/AriaAnnounce.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
import { type ComponentType, useEffect } from "react";

import type { AnnounceFnOptions } from "./AriaAnnouncerContext";
import { useAriaAnnouncer } from "./useAriaAnnouncer";

export interface AriaAnnounceProps {
export interface AriaAnnounceProps extends AnnounceFnOptions {
/**
* String which will be announced by screen readers on change
* String to be announced by screenreader.
*/
announcement?: string;
/**
* Legacy option, precede the announcement with a delay.
* @deprecated
* useAriaAnnouncer `delay` arg is deprecated, use your own `setTimeout` or consider using `duration` through `AnnounceFnOptions` instead.
*/
delay?: number;
}

export const AriaAnnounce: ComponentType<AriaAnnounceProps> = ({
announcement,
delay,
...rest
}) => {
const { announce } = useAriaAnnouncer();

// biome-ignore lint/correctness/useExhaustiveDependencies: ignore rest
useEffect(() => {
if (announcement) {
announce(announcement);
if (delay !== undefined) {
announce(announcement, delay);
}
announce(announcement, rest);
}
}, [announce, announcement]);
}, [announce, announcement, delay]);

// biome-ignore lint/complexity/noUselessFragments: If we return null here, react-docgen wouldn't be able to locate the component.
return <></>;
Expand Down
Loading
Loading