fix(design): improve gallery and pager accessibility (#7569)

This commit is contained in:
白熱
2026-08-21 16:03:11 +08:00
committed by GitHub
parent 0b907e26b3
commit c40c1165ee
23 changed files with 303 additions and 80 deletions
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import { OneToOneIcon, ZoomInIcon, ZoomOutIcon } from '@univerjs/icons';
import { CloseIcon, OneToOneIcon, ZoomInIcon, ZoomOutIcon } from '@univerjs/icons';
import { useContext, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from '../../helper/clsx';
import { Button } from '../button/Button';
import { ConfigContext } from '../config-provider/ConfigProvider';
import { Pager } from '../pager/Pager';
@@ -28,53 +29,107 @@ export interface IGalleryProps {
onOpenChange?: (open: boolean) => void;
}
const buttonClassName = `
univer-flex univer-cursor-pointer univer-items-center univer-justify-center univer-border-none
univer-bg-transparent univer-p-0 univer-text-current
hover:univer-text-gray-0
const toolbarButtonClassName = `
!univer-border-transparent !univer-bg-transparent !univer-text-gray-300
hover:!univer-bg-gray-600 hover:!univer-text-gray-0
focus-visible:!univer-outline-none focus-visible:!univer-ring-2 focus-visible:!univer-ring-gray-0
`;
const focusableElementSelector = `
button:not([disabled]), [href], input:not([disabled]), select:not([disabled]),
textarea:not([disabled]), [tabindex]:not([tabindex="-1"])
`;
export function Gallery(props: IGalleryProps) {
const { className, images, open, onOpenChange } = props;
const [isVisible, setIsVisible] = useState(false);
const [isVisible, setIsVisible] = useState(Boolean(open));
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const { direction, locale } = useContext(ConfigContext);
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
const activeImage = images[activeImageIndex];
const hasPagination = images.length > 1;
const imageLabel = locale?.Accessibility.image
?.replace('{0}', String(activeImageIndex + 1))
.replace('{1}', String(images.length)) ?? `Image ${activeImageIndex + 1} of ${images.length}`;
// Focus management
useEffect(() => {
if (open && dialogRef.current) {
dialogRef.current.focus();
}
const timer = setTimeout(() => {
setIsVisible(Boolean(open));
if (!open && previouslyFocusedElementRef.current?.isConnected) {
previouslyFocusedElementRef.current.focus();
}
if (!open) {
previouslyFocusedElementRef.current = null;
}
}, open ? 0 : 150);
return () => clearTimeout(timer);
}, [open]);
// ESC close support
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onOpenChange?.(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onOpenChange]);
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
closeButtonRef.current?.focus();
}, [open]);
useEffect(() => {
if (open) {
setIsVisible(true);
} else {
const timer = setTimeout(() => {
setIsVisible(false);
}, 150);
return () => clearTimeout(timer);
}
}, [open]);
if (!open && !isVisible) return;
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
return;
}
if (event.key !== 'Tab' || !dialogRef.current) {
return;
}
const focusableElements = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(focusableElementSelector)
);
if (focusableElements.length === 0) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey && (activeElement === firstElement || !dialogRef.current.contains(activeElement))) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && (activeElement === lastElement || !dialogRef.current.contains(activeElement))) {
event.preventDefault();
firstElement.focus();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isVisible, onOpenChange, open]);
useEffect(() => {
return () => {
if (previouslyFocusedElementRef.current?.isConnected) {
previouslyFocusedElementRef.current.focus();
}
};
}, []);
// wheel
useEffect(() => {
@@ -99,10 +154,12 @@ export function Gallery(props: IGalleryProps) {
setZoomLevel(1);
return;
}
const newZoomLevel = zoomLevel + ratio;
if (newZoomLevel < 0.5) return;
if (newZoomLevel > 2) return;
setZoomLevel(newZoomLevel);
setZoomLevel((previousZoomLevel) => Math.min(Math.max(0.5, previousZoomLevel + ratio), 2));
}
function handleClose() {
onOpenChange?.(false);
}
return createPortal(
@@ -111,7 +168,7 @@ export function Gallery(props: IGalleryProps) {
dir={direction}
role="dialog"
aria-modal="true"
aria-label={locale?.Accessibility.imageGallery}
aria-label={locale?.Accessibility.imageGallery ?? 'Image gallery'}
tabIndex={-1}
ref={dialogRef}
className={clsx(
@@ -129,9 +186,28 @@ export function Gallery(props: IGalleryProps) {
<div
className="univer-absolute univer-inset-0 univer-size-full univer-bg-gray-900 univer-opacity-80"
aria-hidden="true"
onClick={() => onOpenChange?.(false)}
onClick={handleClose}
/>
<Button
ref={closeButtonRef}
data-u-comp="gallery-close"
type="button"
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.close ?? 'Close'}
className={`
univer-absolute univer-right-4 univer-top-4 univer-z-10 univer-size-10 univer-rounded-full
!univer-border-gray-500 !univer-bg-gray-800 !univer-text-gray-0
hover:!univer-bg-gray-700
focus-visible:!univer-outline-none focus-visible:!univer-ring-2 focus-visible:!univer-ring-gray-0
rtl:univer-left-4 rtl:univer-right-auto
`}
onClick={handleClose}
>
<CloseIcon aria-hidden="true" />
</Button>
{/* Content */}
<div
className="univer-relative univer-flex univer-w-fit univer-items-center univer-justify-center"
@@ -146,12 +222,16 @@ export function Gallery(props: IGalleryProps) {
transform: `scale(${zoomLevel})`,
}}
src={activeImage}
alt={locale?.Accessibility.image.replace('{0}', String(activeImageIndex + 1)).replace('{1}', String(images.length))}
alt={imageLabel}
draggable={false}
/>
)}
</div>
<span className="univer-sr-only" aria-live="polite" aria-atomic="true">
{imageLabel}
</span>
{/* Toolbar */}
<footer
className={`
@@ -170,33 +250,44 @@ export function Gallery(props: IGalleryProps) {
`}
value={activeImageIndex + 1}
total={images.length}
previousButtonAriaLabel={locale?.Accessibility.previous ?? 'Previous'}
nextButtonAriaLabel={locale?.Accessibility.next ?? 'Next'}
onChange={(value) => setActiveImageIndex(value - 1)}
/>
)}
<button
<Button
type="button"
aria-label={locale?.Accessibility.zoomIn}
className={buttonClassName}
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.zoomIn ?? 'Zoom in'}
className={toolbarButtonClassName}
disabled={zoomLevel >= 2}
onClick={() => handleToggleZoom(0.25)}
>
<ZoomInIcon aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label={locale?.Accessibility.zoomOut}
className={buttonClassName}
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.zoomOut ?? 'Zoom out'}
className={toolbarButtonClassName}
disabled={zoomLevel <= 0.5}
onClick={() => handleToggleZoom(-0.25)}
>
<ZoomOutIcon aria-hidden="true" />
</button>
<button
</Button>
<Button
type="button"
aria-label={locale?.Accessibility.resetZoom}
className={buttonClassName}
variant="ghost"
size="icon"
aria-label={locale?.Accessibility.resetZoom ?? 'Reset zoom'}
className={toolbarButtonClassName}
disabled={zoomLevel === 1}
onClick={() => handleToggleZoom('reset')}
>
<OneToOneIcon aria-hidden="true" />
</button>
</Button>
</footer>
</div>,
document.body
@@ -15,8 +15,9 @@
*/
import type { ComponentProps, PropsWithChildren } from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Gallery } from '../Gallery';
@@ -36,8 +37,18 @@ function renderGallery(props: ComponentProps<typeof Gallery>) {
return render(<Gallery {...props} />, { wrapper: LocaleProvider });
}
function FocusHarness({ open }: { open: boolean }) {
return (
<>
<button type="button">Open gallery trigger</button>
<Gallery images={images} open={open} />
</>
);
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('Gallery', () => {
@@ -77,13 +88,48 @@ describe('Gallery', () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('toolbar buttons have correct aria-labels', () => {
it('provides named close, pagination, and zoom controls', () => {
renderGallery({ images, open: true });
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
expect(screen.getByRole('button', { name: /zoom in/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /zoom out/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reset zoom/i })).toBeInTheDocument();
});
it('calls onOpenChange(false) when clicking the close button', () => {
const onOpenChange = vi.fn();
renderGallery({ images, open: true, onOpenChange });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('contains focus while open and restores it after closing', () => {
vi.useFakeTimers();
const { rerender } = render(<FocusHarness open={false} />, { wrapper: LocaleProvider });
const trigger = screen.getByRole('button', { name: 'Open gallery trigger' });
trigger.focus();
rerender(<FocusHarness open />);
const closeButton = screen.getByRole('button', { name: 'Close' });
const zoomOutButton = screen.getByRole('button', { name: /zoom out/i });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(window, { key: 'Tab', shiftKey: true });
expect(zoomOutButton).toHaveFocus();
fireEvent.keyDown(window, { key: 'Tab' });
expect(closeButton).toHaveFocus();
rerender(<FocusHarness open={false} />);
act(() => vi.advanceTimersByTime(150));
expect(trigger).toHaveFocus();
});
it('keeps the toolbar controls in logical order in RTL', () => {
render(
<ConfigProvider locale={enUS.design} direction="rtl" mountContainer={document.body}>
+33 -8
View File
@@ -15,7 +15,10 @@
*/
import { MoreLeftIcon, MoreRightIcon } from '@univerjs/icons';
import { useContext } from 'react';
import { clsx } from '../../helper/clsx';
import { ConfigContext } from '../config-provider/ConfigProvider';
export interface IPagerProps {
className?: string;
@@ -23,14 +26,28 @@ export interface IPagerProps {
value: number;
total: number;
loop?: boolean;
previousButtonAriaLabel?: string;
nextButtonAriaLabel?: string;
onChange?(value: number): void;
}
export function Pager(props: IPagerProps) {
const { className, value: current = 0, total: count = 0, loop, text: propText, onChange } = props;
const {
className,
value: current = 0,
total: count = 0,
loop,
text: propText,
previousButtonAriaLabel,
nextButtonAriaLabel,
onChange,
} = props;
const { locale } = useContext(ConfigContext);
const text = propText ?? `${current}/${count}`;
const hasValue = count > 0;
const previousButtonDisabled = !loop && current <= 1;
const nextButtonDisabled = !loop && current >= count;
const onClickLeftArrow = () => {
if (current === 1) {
@@ -66,31 +83,39 @@ export function Pager(props: IPagerProps) {
<button
data-u-comp="pager-left-arrow"
className={`
univer-inline-flex univer-size-4 univer-cursor-pointer univer-items-center univer-rounded
univer-inline-flex univer-size-6 univer-cursor-pointer univer-items-center univer-rounded
univer-border-none univer-bg-transparent univer-p-0 univer-text-current
hover:univer-bg-gray-50
focus-visible:univer-outline-none focus-visible:univer-ring-2
focus-visible:univer-ring-primary-500
disabled:univer-cursor-default disabled:univer-opacity-40
dark:hover:!univer-bg-gray-600
`}
type="button"
role="button"
aria-label={previousButtonAriaLabel ?? locale?.Accessibility.previous ?? 'Previous'}
disabled={previousButtonDisabled}
onClick={onClickLeftArrow}
>
<MoreLeftIcon className="rtl:univer-rotate-180" />
<MoreLeftIcon className="rtl:univer-rotate-180" aria-hidden="true" />
</button>
<span className="univer-mx-1">{text}</span>
<span className="univer-mx-1" aria-live="polite" aria-atomic="true">{text}</span>
<button
data-u-comp="pager-right-arrow"
className={`
univer-inline-flex univer-size-4 univer-cursor-pointer univer-items-center univer-rounded
univer-inline-flex univer-size-6 univer-cursor-pointer univer-items-center univer-rounded
univer-border-none univer-bg-transparent univer-p-0 univer-text-current
hover:univer-bg-gray-50
focus-visible:univer-outline-none focus-visible:univer-ring-2
focus-visible:univer-ring-primary-500
disabled:univer-cursor-default disabled:univer-opacity-40
dark:hover:!univer-bg-gray-600
`}
type="button"
role="button"
aria-label={nextButtonAriaLabel ?? locale?.Accessibility.next ?? 'Next'}
disabled={nextButtonDisabled}
onClick={onClickRightArrow}
>
<MoreRightIcon className="rtl:univer-rotate-180" />
<MoreRightIcon className="rtl:univer-rotate-180" aria-hidden="true" />
</button>
</>
)
@@ -14,23 +14,25 @@
* limitations under the License.
*/
import type { IPagerProps } from '../Pager';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ComponentProps } from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Pager } from '../Pager';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('Pager', () => {
const defaultProps: IPagerProps = {
value: 1,
total: 5,
onChange: vi.fn(),
};
let defaultProps: ComponentProps<typeof Pager>;
// cleanup after each test
afterEach(cleanup);
beforeEach(() => {
defaultProps = {
value: 1,
total: 5,
onChange: vi.fn(),
};
});
it('renders correctly with default props', () => {
const { getByText } = render(<Pager {...defaultProps} />);
@@ -38,32 +40,33 @@ describe('Pager', () => {
});
it('renders correctly with custom text', () => {
const props: IPagerProps = { ...defaultProps, text: 'Custom Text' };
const props: ComponentProps<typeof Pager> = { ...defaultProps, text: 'Custom Text' };
const { getByText } = render(<Pager {...props} />);
expect(getByText('Custom Text')).toBeTruthy();
});
it('renders only text when total is 0', () => {
const props: IPagerProps = { ...defaultProps, total: 0 };
const props: ComponentProps<typeof Pager> = { ...defaultProps, total: 0 };
const { getByText, queryByRole } = render(<Pager {...props} />);
expect(getByText('1/0')).toBeTruthy();
expect(queryByRole('button')).not.toBeTruthy();
});
it('calls onChange with correct value when arrow is clicked', () => {
const { container } = render(<Pager {...defaultProps} />);
const rightArrow = container.querySelector('[data-u-comp="pager-right-arrow"]') as HTMLButtonElement;
const { rerender } = render(<Pager {...defaultProps} />);
const rightArrow = screen.getByRole('button', { name: 'Next' });
fireEvent.click(rightArrow);
expect(defaultProps.onChange).toBeCalled();
expect(defaultProps.onChange).toHaveBeenLastCalledWith(2);
const leftArrow = container.querySelector('[data-u-comp="pager-left-arrow"]') as HTMLButtonElement;
rerender(<Pager {...defaultProps} value={2} />);
const leftArrow = screen.getByRole('button', { name: 'Previous' });
fireEvent.click(leftArrow);
expect(defaultProps.onChange).toBeCalled();
expect(defaultProps.onChange).toHaveBeenLastCalledWith(1);
});
it('loops to last page when clicking left arrow on first page', () => {
const props: IPagerProps = { ...defaultProps, loop: true };
const props: ComponentProps<typeof Pager> = { ...defaultProps, loop: true };
const { container } = render(<Pager {...props} />);
const leftArrow = container.querySelector('[data-u-comp="pager-left-arrow"]') as HTMLButtonElement;
@@ -72,7 +75,7 @@ describe('Pager', () => {
});
it('loops to first page when clicking right arrow on last page', () => {
const props: IPagerProps = { ...defaultProps, value: 5, loop: true };
const props: ComponentProps<typeof Pager> = { ...defaultProps, value: 5, loop: true };
const { container } = render(<Pager {...props} />);
const rightArrow = container.querySelector('[data-u-comp="pager-right-arrow"]') as HTMLButtonElement;
@@ -81,11 +84,12 @@ describe('Pager', () => {
});
it('does not loop when loop prop is false', () => {
const props: IPagerProps = { ...defaultProps, value: 5, loop: false };
const { container } = render(<Pager {...props} />);
const rightArrow = container.querySelector('[data-u-comp="pager-right-arrow"]') as HTMLButtonElement;
const props: ComponentProps<typeof Pager> = { ...defaultProps, value: 5, loop: false };
render(<Pager {...props} />);
const rightArrow = screen.getByRole('button', { name: 'Next' });
expect(rightArrow).toBeDisabled();
fireEvent.click(rightArrow);
expect(props.onChange).toBeCalledWith(5);
expect(props.onChange).not.toHaveBeenCalled();
});
});
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'إغلاق الشارة',
close: 'إغلاق',
previous: 'السابق',
next: 'التالي',
imageGallery: 'معرض الصور',
image: 'الصورة {0} من {1}',
zoomIn: 'تكبير',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Tanca la insígnia',
close: 'Tanca',
previous: 'Anterior',
next: 'Següent',
imageGallery: 'Galeria dimatges',
image: 'Imatge {0} de {1}',
zoomIn: 'Apropa',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Badge schließen',
close: 'Schließen',
previous: 'Zurück',
next: 'Weiter',
imageGallery: 'Bildergalerie',
image: 'Bild {0} von {1}',
zoomIn: 'Vergrößern',
+3
View File
@@ -18,6 +18,9 @@ const locale = {
design: {
Accessibility: {
closeBadge: 'Close badge',
close: 'Close',
previous: 'Previous',
next: 'Next',
imageGallery: 'Image gallery',
image: 'Image {0} of {1}',
zoomIn: 'Zoom in',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Cerrar insignia',
close: 'Cerrar',
previous: 'Anterior',
next: 'Siguiente',
imageGallery: 'Galería de imágenes',
image: 'Imagen {0} de {1}',
zoomIn: 'Acercar',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'بستن نشان',
close: 'بستن',
previous: 'قبلی',
next: 'بعدی',
imageGallery: 'گالری تصاویر',
image: 'تصویر {0} از {1}',
zoomIn: 'بزرگ‌نمایی',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Fermer le badge',
close: 'Fermer',
previous: 'Précédent',
next: 'Suivant',
imageGallery: 'Galerie dimages',
image: 'Image {0} sur {1}',
zoomIn: 'Zoom avant',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Tutup lencana',
close: 'Tutup',
previous: 'Sebelumnya',
next: 'Berikutnya',
imageGallery: 'Galeri gambar',
image: 'Gambar {0} dari {1}',
zoomIn: 'Perbesar',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Chiudi badge',
close: 'Chiudi',
previous: 'Precedente',
next: 'Successivo',
imageGallery: 'Galleria immagini',
image: 'Immagine {0} di {1}',
zoomIn: 'Ingrandisci',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'バッジを閉じる',
close: '閉じる',
previous: '前へ',
next: '次へ',
imageGallery: '画像ギャラリー',
image: '画像 {0}/{1}',
zoomIn: '拡大',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: '배지 닫기',
close: '닫기',
previous: '이전',
next: '다음',
imageGallery: '이미지 갤러리',
image: '이미지 {0}/{1}',
zoomIn: '확대',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Zamknij plakietkę',
close: 'Zamknij',
previous: 'Poprzedni',
next: 'Następny',
imageGallery: 'Galeria obrazów',
image: 'Obraz {0} z {1}',
zoomIn: 'Powiększ',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Fechar selo',
close: 'Fechar',
previous: 'Anterior',
next: 'Próximo',
imageGallery: 'Galeria de imagens',
image: 'Imagem {0} de {1}',
zoomIn: 'Ampliar',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Закрыть метку',
close: 'Закрыть',
previous: 'Предыдущее',
next: 'Следующее',
imageGallery: 'Галерея изображений',
image: 'Изображение {0} из {1}',
zoomIn: 'Увеличить',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Zavrieť odznak',
close: 'Zavrieť',
previous: 'Predchádzajúci',
next: 'Nasledujúci',
imageGallery: 'Galéria obrázkov',
image: 'Obrázok {0} z {1}',
zoomIn: 'Priblížiť',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: 'Đóng huy hiệu',
close: 'Đóng',
previous: 'Trước',
next: 'Tiếp theo',
imageGallery: 'Thư viện ảnh',
image: 'Ảnh {0} trên {1}',
zoomIn: 'Phóng to',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: '关闭徽章',
close: '关闭',
previous: '上一个',
next: '下一个',
imageGallery: '图片库',
image: '第 {0} 张图片,共 {1} 张',
zoomIn: '放大',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: '關閉徽章',
close: '關閉',
previous: '上一個',
next: '下一個',
imageGallery: '圖片庫',
image: '第 {0} 張圖片,共 {1} 張',
zoomIn: '放大',
+3
View File
@@ -20,6 +20,9 @@ const locale: typeof enUS = {
design: {
Accessibility: {
closeBadge: '關閉徽章',
close: '關閉',
previous: '上一個',
next: '下一個',
imageGallery: '圖片庫',
image: '第 {0} 張圖片,共 {1} 張',
zoomIn: '放大',