Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions .changeset/lightbox-announces-inside-viewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] `Lightbox`: a screen reader now hears the viewer's own messages while it is open. Moving to another item ("Mountain lake, 2 of 4") and zooming in or out are announced inside the open viewer; before, they went to the page behind the modal viewer, where a screen reader does not read them. The `0` key now returns a zoomed photo to its fitted size. The docs list the `0` key, and no longer say that the lightbox is only for images.

@cixzhang
4 changes: 4 additions & 0 deletions .github/a11y-audit-axe.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,9 @@
"core-layout--dual-panels",
"core-layout--content-width-with-start-panel"
]
},
"Lightbox": {
"stories": ["core-lightbox--open-viewer"],
"readySelector": "dialog[open]"
}
}
27 changes: 27 additions & 0 deletions apps/storybook/stories/Lightbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,30 @@ export const WithHook: Story = {
);
},
};

/**
* The gallery viewer, open when the page loads, so a page-level
* accessibility check reaches the open viewer.
*/
export const OpenViewer: Story = {
name: 'Open Viewer',
// Kept off the docs page because the viewer opens on load.
tags: ['!autodocs'],
render: () => {
const [isOpen, setIsOpen] = useState(true);
const [index, setIndex] = useState(0);
return (
<>
<button onClick={() => setIsOpen(true)}>Open viewer</button>
<Lightbox
isOpen={isOpen}
onOpenChange={setIsOpen}
media={GALLERY_MEDIA}
index={index}
onIndexChange={setIndex}
hasZoom
/>
</>
);
},
};
13 changes: 7 additions & 6 deletions packages/core/src/Lightbox/Lightbox.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const docs = {
name: 'hasZoom',
type: 'boolean',
description:
'Enable zoom (images only): pinch (or ctrl-wheel), or double-tap or double-click to zoom in at that point and back; Enter/Space/+/- zoom 2× about the centre and back. When zoomed, drag or scroll a trackpad to pan, or use arrow keys; on a phone or a tablet, the pan has momentum.',
'Enable zoom (images only): pinch (or ctrl-wheel), or double-tap or double-click to zoom in at that point and back; Enter/Space/+/- zoom 2× about the centre and back, and 0 returns to the fitted size. When zoomed, drag or scroll a trackpad to pan, or use arrow keys; on a phone or a tablet, the pan has momentum.',
default: 'false',
},
{
Expand Down Expand Up @@ -149,7 +149,7 @@ export const docs = {
{
guidance: false,
description:
'Use the lightbox for non-image content; it is specialized for images.',
'Use the lightbox for content other than images and videos; it shows one media item at a time.',
},
{
guidance: false,
Expand Down Expand Up @@ -214,7 +214,7 @@ export const docsZh = {
name: 'hasZoom',
type: 'boolean',
description:
'启用缩放(仅图片):双指捏合(或按住 Ctrl 滚动滚轮),或双点按、双击在该处放大并还原;Enter/Space/+/- 键以中心放大 2 倍并还原。缩放后可拖动或在触控板上滚动平移,或用方向键平移;在手机或平板上,平移带惯性。',
'启用缩放(仅图片):双指捏合(或按住 Ctrl 滚动滚轮),或双点按、双击在该处放大并还原;Enter/Space/+/- 键以中心放大 2 倍并还原,0 键恢复到适合大小。缩放后可拖动或在触控板上滚动平移,或用方向键平移;在手机或平板上,平移带惯性。',
default: 'false',
},
{
Expand Down Expand Up @@ -250,7 +250,7 @@ export const docsZh = {
{
guidance: false,
description:
'Use the lightbox for non-image content; it is specialized for images.',
'Use the lightbox for content other than images and videos; it shows one media item at a time.',
},
{
guidance: false,
Expand Down Expand Up @@ -283,7 +283,8 @@ export const docsDense = {
},
{
guidance: false,
description: 'Use for non-image content; specialized for images.',
description:
'Use for content other than images and videos; one media item at a time.',
},
{
guidance: false,
Expand All @@ -301,7 +302,7 @@ export const docsDense = {
onIndexChange:
'Callback when gallery index changes (buttons, keys or a swipe).',
hasZoom:
'Enable zoom (pinch or ctrl-wheel, double-tap or double-click, Enter/Space, or +/-) and pan (drag, trackpad scroll, or arrow keys) for images.',
'Enable zoom (pinch or ctrl-wheel, double-tap or double-click, Enter/Space, or +/-; 0 resets) and pan (drag, trackpad scroll, or arrow keys) for images.',
xstyle: 'StyleX styles for layout customization.',
},
};
70 changes: 70 additions & 0 deletions packages/core/src/Lightbox/Lightbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,76 @@ describe('Lightbox', () => {
expect(zoomTarget()).toHaveAttribute('aria-pressed', 'true');
});

it('returns a zoomed photo to its fitted size with 0', () => {
render(
<Lightbox
isOpen={true}
onOpenChange={() => {}}
media={{src: '/photo.jpg', alt: 'Photo'}}
hasZoom
/>,
);
const dialog = document.querySelector('dialog')!;
fireEvent.keyDown(dialog, {key: '+'});
expect(zoomTarget()).toHaveAttribute('aria-pressed', 'true');
fireEvent.keyDown(dialog, {key: '0'});
expect(zoomTarget()).toHaveAttribute('aria-pressed', 'false');
// At the fitted size, 0 leaves the photo there.
fireEvent.keyDown(dialog, {key: '0'});
expect(zoomTarget()).toHaveAttribute('aria-pressed', 'false');
});

it('leaves Ctrl+0 and Cmd+0 to the browser', () => {
render(
<Lightbox
isOpen={true}
onOpenChange={() => {}}
media={{src: '/photo.jpg', alt: 'Photo'}}
hasZoom
/>,
);
const dialog = document.querySelector('dialog')!;
fireEvent.keyDown(dialog, {key: '+'});
expect(fireEvent.keyDown(dialog, {key: '0', ctrlKey: true})).toBe(true);
expect(fireEvent.keyDown(dialog, {key: '0', metaKey: true})).toBe(true);
expect(zoomTarget()).toHaveAttribute('aria-pressed', 'true');
});

it('announces a zoom inside the open modal viewer', async () => {
// jsdom has no :modal; treat an open dialog as modal, as showModal()
// makes it in a browser.
const matches = Element.prototype.matches;
const spy = vi
.spyOn(Element.prototype, 'matches')
.mockImplementation(function (this: Element, selector: string) {
return selector === ':modal'
? this instanceof HTMLDialogElement && this.open
: matches.call(this, selector);
});
try {
render(
<Lightbox
isOpen={true}
onOpenChange={() => {}}
media={{src: '/photo.jpg', alt: 'Photo'}}
hasZoom
/>,
);
const dialog = document.querySelector('dialog')!;
fireEvent.keyDown(dialog, {key: '+'});
await waitFor(() => {
expect(
dialog.querySelector(
':scope > [data-astryx-modal-live-region="polite"]',
),
).toHaveTextContent('Zoomed in');
});
expect(politeRegion()?.textContent ?? '').toBe('');
} finally {
spy.mockRestore();
}
});

it('pans with arrow keys while zoomed instead of navigating the gallery', () => {
const onIndexChange = vi.fn();
render(
Expand Down
44 changes: 34 additions & 10 deletions packages/core/src/Lightbox/Lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type Ref,
type PointerEvent as ReactPointerEvent,
type ComponentProps,
type ReactNode,
Expand All @@ -47,7 +49,7 @@ import {
} from '../theme/tokens.stylex';
import {Icon} from '../Icon';
import {IconButton} from '../IconButton';
import {useAnnounce} from '../hooks/useAnnounce';
import {useAnnounce, type AnnounceFn} from '../hooks/useAnnounce';
import {useScrollLock} from '../hooks/useScrollLock';
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
import {useAttachment} from '../hooks/useAttachment';
Expand Down Expand Up @@ -147,9 +149,10 @@ export interface LightboxProps extends BaseProps<HTMLDialogElement> {
* Enable zoom (images only). A pinch or a ctrl-wheel zooms about the
* fingers or the pointer. A double tap fills the viewer and a double-click
* zooms 2×, about that point, and the next goes back. Enter, Space, `+` and
* `-` zoom 2× about the centre and back. When zoomed, drag or scroll a
* trackpad to pan, or use the arrow keys. On a phone or a tablet, the pan
* has momentum. The gestures work for every pointer.
* `-` zoom 2× about the centre and back, and `0` returns to the fitted
* size. When zoomed, drag or scroll a trackpad to pan, or use the arrow
* keys. On a phone or a tablet, the pan has momentum. The gestures work
* for every pointer.
* @default false
*/
hasZoom?: boolean;
Expand Down Expand Up @@ -470,7 +473,17 @@ const KEYBOARD_PAN_OFFSETS: Record<string, [number, number]> = {
const VIDEO_CONTROLS_BAND = 48;

/**
* A fullscreen overlay for viewing images at full resolution.
* Gives Lightbox an announce function inside its dialog. While the modal
* viewer is open, a screen reader reads only messages inside it.
*/
function ViewerAnnouncer({ref}: {ref: Ref<AnnounceFn>}) {
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
const announce = useAnnounce();
useImperativeHandle(ref, () => announce, [announce]);
return null;
}

/**
* A fullscreen overlay for viewing images and videos at full resolution.
*
* Supports single image and gallery modes. In gallery mode, it provides
* prev/next navigation via buttons and arrow keys. The arrow keys follow the
Expand All @@ -480,10 +493,11 @@ const VIDEO_CONTROLS_BAND = 48;
* Optionally supports zoom like a photo viewer. A pinch or a ctrl-wheel zooms
* about the fingers or the pointer, gives past the limits and springs back. A
* double tap or a double-click zooms in at that point and back. Enter or
* Space on the image, and `+` or `-` from the keyboard, zoom too. When zoomed,
* a drag or a trackpad's scroll pans. On a phone or a tablet, the pan has
* momentum and a rubber band at the edges. The arrow keys pan a zoomed photo
* and navigate the gallery otherwise.
* Space on the image, and `+` or `-` from the keyboard, zoom too, and `0`
* returns to the fitted size. When zoomed, a drag or a trackpad's scroll
* pans. On a phone or a tablet, the pan has momentum and a rubber band at the
* edges. The arrow keys pan a zoomed photo and navigate the gallery
* otherwise.
*
* Uses the native `<dialog>` element with `showModal()` for focus
* trapping and top-layer placement. Dismiss via Escape, close button,
Expand Down Expand Up @@ -600,7 +614,10 @@ export function Lightbox({
// already-open session — not on mount, not when opening (even at a new
// index, since the dialog's aria-label already names the current image), and
// not on close.
const announce = useAnnounce();
const viewerAnnounceRef = useRef<AnnounceFn | null>(null);
const announce = useCallback((message: string) => {
viewerAnnounceRef.current?.(message);
}, []);
const prevIndexRef = useRef(index);
const wasOpenRef = useRef(isOpen);
useEffect(() => {
Expand Down Expand Up @@ -1232,6 +1249,12 @@ export function Lightbox({
stage.zoomOut();
return;
}
// Ctrl or Cmd with 0 resets the browser's page zoom; leave it.
if (e.key === '0' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
stage.zoomOut();
return;
}
if (stage.isZoomed() && KEYBOARD_PAN_OFFSETS[e.key] !== undefined) {
e.preventDefault();
const [dx, dy] = KEYBOARD_PAN_OFFSETS[e.key];
Expand Down Expand Up @@ -1376,6 +1399,7 @@ export function Lightbox({
})}>
<LayerDepthProvider>
<ModalAnnounceProvider dialogRef={dialogRef} isOpen={isOpen}>
<ViewerAnnouncer ref={viewerAnnounceRef} />
<LayerContentBoundary>
<div
ref={containerRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* @input Built Lightbox stories (LTR and the RTL story global) and real
* Chromium/WebKit keyboard input
* @output Browser evidence that the gallery's Left/Right arrow keys move in
* the direction of the matching on-screen button, in both directions, and
* that Tab and Shift+Tab wrap inside the open viewer at both ends
* the direction of the matching on-screen button, in both directions, that
* Tab and Shift+Tab wrap inside the open viewer at both ends, and that the
* zoom keys work and are announced inside the open viewer
* @position Cross-browser proof for Lightbox's direction-aware gallery keys
* (design:bidirectionality DR6, DR11)
*/
Expand Down Expand Up @@ -137,3 +138,32 @@ test('Tab and Shift+Tab wrap inside an open single image, zoomed or not', async
await page.keyboard.press('+');
await expectTabWrapsInside(page);
});

test('+ zooms and is announced inside the viewer, and 0 returns to the fitted size', async ({
page,
}) => {
// A local picture stands in for the remote photo, so the zoom has a size.
await page.route(/picsum\.photos/, async route =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800"><rect width="1200" height="800" fill="#5b7f6a"/></svg>',
}),
);
await page.goto(
`${storybook.origin}/iframe.html?id=core-lightbox--with-zoom&viewMode=story`,
);
await page.getByRole('button', {name: 'Open with zoom'}).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const zoom = dialog.getByRole('button', {name: 'Zoom'});
await expect(zoom).toHaveAttribute('aria-pressed', 'false');

await page.keyboard.press('+');
await expect(zoom).toHaveAttribute('aria-pressed', 'true');
await expect(
page.locator('dialog[open] > [data-astryx-modal-live-region="polite"]'),
).toContainText('Zoomed in');

await page.keyboard.press('0');
await expect(zoom).toHaveAttribute('aria-pressed', 'false');
});
Loading
Loading