Skip to content
Closed
58 changes: 57 additions & 1 deletion packages/react/src/Inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ChimelyClientConfig, WellKnownPayload } from '@chimely/client';
import { ChimelyClient } from '@chimely/client';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import type { ReactNode } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
import { useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { InboxSlot } from './appearance';
Expand All @@ -15,6 +15,21 @@ import { ensureStyles } from './styles';

export type { InboxAppearance, InboxSlot } from './appearance';

// Sequential tab stops only. The branches are alternatives, so each one
// carries the tabindex="-1" exclusion. A native control opted out of the
// tab order that way (the tab strip's roving tabindex) would otherwise
// still match and become a false trap edge.
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]',
]
.map((branch) => `${branch}:not([tabindex="-1"])`)
.join(', ');

/**
* Drop-in bell + badge + popover inbox.
*
Expand Down Expand Up @@ -143,6 +158,44 @@ function InboxView<TPayload>(props: InboxProps<TPayload>): ReactNode {
wasOpen.current = isOpen;
}, [isOpen, client]);

// Focus moves into the dialog on open per the APG dialog pattern. A
// portaled popover sits under document.body, so without this Tab from
// the bell walks the host page before reaching the dialog.
useEffect(() => {
if (isOpen) {
popoverRef.current?.focus();
}
}, [isOpen]);

// The dialog is modal, so Tab and Shift+Tab wrap at its edges. A
// portaled popover is the last child of document.body, so without the
// wrap Tab past the last focusable lands back in the host page with
// the dialog still open.
const trapFocus = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'Tab') {
return;
}
const popover = popoverRef.current;
if (!popover) {
return;
}
const focusables = popover.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!first || !last) {
event.preventDefault();
return;
}
const active = document.activeElement;
if (event.shiftKey && (active === first || active === popover)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};

useEffect(() => {
if (!isOpen) {
return undefined;
Expand Down Expand Up @@ -205,7 +258,10 @@ function InboxView<TPayload>(props: InboxProps<TPayload>): ReactNode {
className={portal ? `${cls('popover')} chimely-popover-portal` : cls('popover')}
style={slotStyle(props.appearance, 'popover', rootStyle)}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={trapFocus}
>
<InboxContent<TPayload>
tabs={props.tabs}
Expand Down
46 changes: 46 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,52 @@ describe('more-actions menu', () => {
expect(screen.queryByRole('menu')).toBeNull();
});

test('opens with the first item focused, arrows wrap, Home/End jump', async () => {
const stub = createStubServer();
stub.addNotification();
await renderInbox(stub);

fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
const items = screen.getAllByRole('menuitem');
expect(items).toHaveLength(3);
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: 'ArrowDown' });
expect(document.activeElement).toBe(items[2]);
fireEvent.keyDown(items[2] as HTMLElement, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[0]);

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

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

test('tab closes the menu and lets focus move on naturally', async () => {
const stub = createStubServer();
stub.addNotification();
await renderInbox(stub);

const trigger = screen.getByRole('button', { name: 'More actions' });
fireEvent.click(trigger);
const items = screen.getAllByRole('menuitem');
expect(document.activeElement).toBe(items[0]);

// fireEvent returns false when preventDefault was called. Tab must
// stay uncancelled so the browser's own focus move still runs, and
// it starts from the trigger, the menu's place in the tab sequence.
const uncancelled = fireEvent.keyDown(items[0] as HTMLElement, { key: 'Tab' });
expect(screen.queryByRole('menu')).toBeNull();
expect(uncancelled).toBe(true);
expect(document.activeElement).toBe(trigger);
expect(screen.getByRole('dialog')).toBeDefined();
});

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

// role=menu promises the APG menu keyboard contract: focus enters the
// first item on open, arrows move it with wrap, Home and End jump,
// Tab closes the menu and moves on.
useEffect(() => {
if (menuOpen) {
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
}
}, [menuOpen]);

const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (items.length === 0) {
return;
}
let next: number;
switch (event.key) {
case 'ArrowDown':
next = (index + 1) % items.length;
break;
case 'ArrowUp':
next = (index - 1 + items.length) % items.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = items.length - 1;
break;
// APG menus close on Tab. The default action stays uncancelled and
// runs from the trigger, the menu's place in the tab sequence, so
// focus moves on instead of dropping to body with the items gone.
case 'Tab':
setMenuOpen(false);
menuTriggerRef.current?.focus();
return;
default:
return;
}
event.preventDefault();
items[next]?.focus();
};

// 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 @@ -249,7 +294,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
5 changes: 5 additions & 0 deletions packages/react/src/components/NotificationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ export function NotificationList<TPayload>(props: NotificationListProps<TPayload

return (
<div className="chimely-list-container">
{/* Rendered unconditionally so the live region exists in the
accessibility tree before its text changes. */}
<div className="chimely-sr-only" role="status" aria-live="polite">
{pendingCount > 0 ? strings.newNotifications(pendingCount) : null}
</div>
{pendingCount > 0 && (
<button
type="button"
Expand Down
68 changes: 68 additions & 0 deletions packages/react/src/inbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,74 @@ describe('portal', () => {
expect(screen.queryByRole('dialog')).toBeNull();
expect(document.activeElement).toBe(bell());
});

test('opening moves focus into the dialog, escape returns it', async () => {
const stub = createStubServer();
stub.addNotification();
await renderInbox(stub, { portal: true });

bell().focus();
fireEvent.click(bell());
const dialog = screen.getByRole('dialog');
expect(dialog.contains(document.activeElement)).toBe(true);

fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
expect(document.activeElement).toBe(bell());
});

test('tab wraps at the dialog edges instead of escaping to the page', async () => {
const stub = createStubServer();
stub.addNotification();
await renderInbox(stub, { portal: true });
fireEvent.click(bell());

const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-modal')).toBe('true');

// First and last tabbables in DOM order: the view filter and the
// item's archive action.
const first = screen.getByRole('combobox', { name: 'View' });
const last = screen.getByRole('button', { name: 'Archive' });

last.focus();
fireEvent.keyDown(last, { key: 'Tab' });
expect(document.activeElement).toBe(first);

fireEvent.keyDown(first, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(last);

// Shift+Tab with the dialog container itself focused (the open state)
// must not walk backward into the host page.
dialog.focus();
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(last);
});

test('tab wraps from the active tab, not an inactive roving tab', async () => {
const stub = createStubServer();
await renderInbox(stub, {
portal: true,
tabs: [{ label: 'All' }, { label: 'Billing', filter: () => false }],
});
fireEvent.click(bell());

// Empty tabbed inbox: the active tab is the last sequential tab stop,
// and the inactive tab after it carries tabindex="-1". The trap must
// wrap from the active tab, not treat the inactive one as its edge.
const first = screen.getByRole('combobox', { name: 'View' });
const [active, inactive] = screen.getAllByRole('tab');
expect(inactive?.tabIndex).toBe(-1);

active?.focus();
// fireEvent returns false when a handler cancelled the event, so a
// true here means forward Tab escaped to the host page.
expect(fireEvent.keyDown(active as HTMLElement, { key: 'Tab' })).toBe(false);
expect(document.activeElement).toBe(first);

fireEvent.keyDown(first, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(active);
});
});

describe('controlled open', () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/react/src/styles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, test } from 'vitest';
import { ensureStyles } from './styles';

afterEach(() => {
document.querySelector('style[data-chimely]')?.remove();
});

describe('ensureStyles', () => {
test('the popover width is clamped to the viewport', () => {
ensureStyles();
const style = document.querySelector('style[data-chimely]');
expect(style?.textContent).toContain('width: min(360px, calc(100vw - 16px));');
});

test('re-injects after head-replacing navigation removes the tag', () => {
ensureStyles();
expect(document.querySelector('style[data-chimely]')).not.toBeNull();

// Turbo and PJAX swap <head> wholesale while the module instance and
// its state survive.
document.querySelector('style[data-chimely]')?.remove();
ensureStyles();
expect(document.querySelector('style[data-chimely]')).not.toBeNull();
});

test('never injects a second tag', () => {
ensureStyles();
ensureStyles();
expect(document.querySelectorAll('style[data-chimely]')).toHaveLength(1);
});
});
27 changes: 20 additions & 7 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 @@ -339,22 +339,35 @@ export const INBOX_CSS = `
.chimely-preference input {
accent-color: var(--chimely-colorPrimary, #1264FF);
}
.chimely-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
`;

let injected = false;

/** Idempotent, SSR-safe. Called on <Inbox /> mount, never at import time. */
/**
* Idempotent, SSR-safe. Called on <Inbox /> mount, never at import time.
* The DOM is re-checked on every call rather than cached in module state.
* Head-replacing navigation (Turbo, PJAX) removes the injected tag while
* the module instance survives, so a cached flag would block re-injection
* until a full reload.
*/
export function ensureStyles(): void {
if (injected || typeof document === 'undefined') {
if (typeof document === 'undefined') {
return;
}
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;
}
Loading