Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/inbox-a11y-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chimely/react": patch
---

Inbox accessibility and robustness polish from the hostile-frontend review: move focus into the popover dialog on open (APG dialog pattern) so keyboard users reach a portaled popover directly, add arrow-key/Home/End navigation to the more-actions menu (honoring its `role=menu` semantics), cap the popover width at the viewport with `min(360px, calc(100vw - 16px))`, and re-probe the DOM in `ensureStyles` each call so head-replacing navigation (Turbo/PJAX) cannot suppress style re-injection.
12 changes: 12 additions & 0 deletions packages/react/src/Inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ function InboxView<TPayload>(props: InboxProps<TPayload>): ReactNode {
};
}, [isOpen]);

// APG dialog pattern: move focus into the dialog on open. A portaled popover
// renders under document.body, after the host page in Tab order, so keyboard
// users would otherwise tab through the whole page to reach it. Escape returns
// focus to the bell (the dismissal effect above).
useEffect(() => {
if (!isOpen) {
return;
}
popoverRef.current?.focus();
}, [isOpen]);

const popover = isOpen && (
<div
ref={popoverRef}
Expand All @@ -206,6 +217,7 @@ function InboxView<TPayload>(props: InboxProps<TPayload>): ReactNode {
style={slotStyle(props.appearance, 'popover', rootStyle)}
role="dialog"
aria-labelledby={titleId}
tabIndex={-1}
>
<InboxContent<TPayload>
tabs={props.tabs}
Expand Down
28 changes: 28 additions & 0 deletions packages/react/src/archive-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,34 @@ describe('more-actions menu', () => {
expect(screen.queryByRole('menu')).toBeNull();
});

test('arrow keys and Home/End roam the menu items, wrapping at the ends', async () => {
const stub = createStubServer();
stub.addNotification();
await renderInbox(stub);

fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
const items = screen.getAllByRole('menuitem');
// Focus lands on the first item when the menu opens.
expect(document.activeElement).toBe(items[0]);

fireEvent.keyDown(items[0] as HTMLElement, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[1]);

fireEvent.keyDown(items[1] as HTMLElement, { key: 'End' });
expect(document.activeElement).toBe(items[items.length - 1]);

// ArrowDown from the last item wraps to the first.
fireEvent.keyDown(items[items.length - 1] as HTMLElement, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[0]);

// ArrowUp from the first item wraps to the last.
fireEvent.keyDown(items[0] as HTMLElement, { key: 'ArrowUp' });
expect(document.activeElement).toBe(items[items.length - 1]);

fireEvent.keyDown(items[items.length - 1] as HTMLElement, { key: 'Home' });
expect(document.activeElement).toBe(items[0]);
});

test('escape closes only the menu and restores trigger focus', async () => {
const stub = createStubServer();
stub.addNotification();
Expand Down
43 changes: 42 additions & 1 deletion packages/react/src/components/InboxContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ export function InboxContent<TPayload = WellKnownPayload>(
};
}, [menuOpen]);

// role=menu owes keyboard semantics: focus the first item on open so the
// arrow keys have a starting point. Escape and outside pointerdown (above)
// close it, restoring focus to the trigger.
useEffect(() => {
if (!menuOpen) {
return;
}
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
}, [menuOpen]);

// Roving tabindex with automatic activation per the ARIA tabs pattern.
// Arrows wrap, Home and End jump, and the moved-to tab is selected.
const handleTabKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
Expand Down Expand Up @@ -178,6 +188,37 @@ export function InboxContent<TPayload = WellKnownPayload>(
document.getElementById(tabId(next))?.focus();
};

// Roving focus across the more-actions menu items. Down and Up wrap, Home
// and End jump. Escape and Tab are left to their default handlers.
const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const menuItems = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
);
if (menuItems.length === 0) {
return;
}
const current = menuItems.indexOf(document.activeElement as HTMLButtonElement);
let next: number;
switch (event.key) {
case 'ArrowDown':
next = current < 0 ? 0 : (current + 1) % menuItems.length;
break;
case 'ArrowUp':
next = current <= 0 ? menuItems.length - 1 : current - 1;
break;
case 'Home':
next = 0;
break;
case 'End':
next = menuItems.length - 1;
break;
default:
return;
}
event.preventDefault();
menuItems[next]?.focus();
};

const handleItemClick = (item: InboxItem<TPayload>) => {
if (props.onItemClick?.(item) === false) {
return;
Expand Down Expand Up @@ -249,7 +290,7 @@ export function InboxContent<TPayload = WellKnownPayload>(
{'\u22ef'}
</button>
{menuOpen && (
<div className="chimely-menu" role="menu">
<div className="chimely-menu" role="menu" onKeyDown={handleMenuKeyDown}>
<button
type="button"
role="menuitem"
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/inbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ describe('bell and badge', () => {
expect(document.querySelectorAll('style[data-chimely]')).toHaveLength(1);
});

test('re-injects the stylesheet after head-replacing navigation drops it', async () => {
const stub = createStubServer();
const { unmount } = await renderInbox(stub);
expect(document.querySelectorAll('style[data-chimely]')).toHaveLength(1);
unmount();
// Turbo/PJAX swaps <head>, removing the tag while the module stays loaded.
document.querySelector('style[data-chimely]')?.remove();
await renderInbox(stub);
expect(document.querySelectorAll('style[data-chimely]')).toHaveLength(1);
});

test('no badge when nothing is unseen', async () => {
const stub = createStubServer();
stub.addNotification({ seen: true });
Expand Down Expand Up @@ -310,6 +321,13 @@ describe('portal', () => {
expect(screen.queryByRole('dialog')).toBeNull();
expect(document.activeElement).toBe(bell());
});

test('opening moves focus into the dialog', async () => {
const stub = createStubServer();
await renderInbox(stub, { portal: true });
fireEvent.click(bell());
expect(document.activeElement).toBe(screen.getByRole('dialog'));
});
});

describe('controlled open', () => {
Expand Down
12 changes: 6 additions & 6 deletions packages/react/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const INBOX_CSS = `
left: 0;
display: flex;
flex-direction: column;
width: 360px;
width: min(360px, calc(100vw - 16px));
max-height: 480px;
overflow: hidden;
background: var(--chimely-colorBackground, #ffffff);
Expand Down Expand Up @@ -341,20 +341,20 @@ export const INBOX_CSS = `
}
`;

let injected = false;

/** Idempotent, SSR-safe. Called on <Inbox /> mount, never at import time. */
export function ensureStyles(): void {
if (injected || typeof document === 'undefined') {
if (typeof document === 'undefined') {
return;
}
// Probe the DOM each call rather than trusting a module-level flag.
// Head-replacing navigation (Turbo/PJAX) can drop the injected tag while
// the module stays loaded, so a stale flag would suppress re-injection
// until a full reload. The query also keeps injection idempotent.
if (document.querySelector('style[data-chimely]')) {
injected = true;
return;
}
const element = document.createElement('style');
element.setAttribute('data-chimely', '');
element.textContent = INBOX_CSS;
document.head.appendChild(element);
injected = true;
}