diff --git a/public/locales/en/edit-favorites-modal.json b/public/locales/en/edit-favorites-modal.json
new file mode 100644
index 0000000..c50d77c
--- /dev/null
+++ b/public/locales/en/edit-favorites-modal.json
@@ -0,0 +1,18 @@
+{
+ "title": "Edit favorite symbols",
+ "listLabel": "Favorite symbols",
+ "moveUp": "Move {{name}} up",
+ "moveDown": "Move {{name}} down",
+ "remove": "Remove {{name}}",
+ "movedUp": "Moved {{name}} up, now {{position}} of {{total}}",
+ "movedDown": "Moved {{name}} down, now {{position}} of {{total}}",
+ "removed": "Removed {{name}}",
+ "noFavoritesLeft": "All favorite symbols removed. Select Done to save.",
+ "cancel": "Cancel",
+ "confirm": "Done",
+ "saved": "Favorite symbols updated",
+ "saveFailed": "Couldn't save favorites. Please try again.",
+ "emptyTitle": "No favorite symbols yet",
+ "emptyMessage": "Right-click a symbol (or press the context menu key or Shift+F10) to add it to favorites.",
+ "emptyConfirm": "OK"
+}
diff --git a/public/locales/en/home.json b/public/locales/en/home.json
index 6d6b902..f5ba479 100644
--- a/public/locales/en/home.json
+++ b/public/locales/en/home.json
@@ -18,6 +18,7 @@
"setting": "Setting",
"bracket2dollar": "{} to $",
"dollar2bracket": "$ to {}",
+ "editFavorites": "Edit favorites",
"pleaseInputTitle": "Please input file title",
"latexDelimiter": {
"name": "LaTeX delimiter",
diff --git a/public/locales/en/tabs.json b/public/locales/en/tabs.json
index d758166..e8ede0b 100644
--- a/public/locales/en/tabs.json
+++ b/public/locales/en/tabs.json
@@ -22,7 +22,19 @@
"quote": "quote",
"table": "table"
},
+ "favorite": {
+ "empty": "No favorite symbols yet. Right-click a symbol (or press the context menu key or Shift+F10) to add it to favorites.",
+ "add": "Add to favorites",
+ "added": "Added {{name}} to favorites",
+ "limitReachedTitle": "Favorites limit reached",
+ "limitReachedMessage": "You can have up to {{limit}} favorite symbols.\nRemove one before adding a new symbol.",
+ "duplicateTitle": "Already in favorites",
+ "duplicateMessage": "This symbol is already in your favorites.",
+ "confirm": "OK",
+ "saveFailed": "Couldn't save favorites. Please try again."
+ },
"categorys": {
+ "favorite": "Favorites",
"common": "Common",
"operator": "Operator",
"relation": "Relation",
diff --git a/public/locales/zh-TW/edit-favorites-modal.json b/public/locales/zh-TW/edit-favorites-modal.json
new file mode 100644
index 0000000..09f704a
--- /dev/null
+++ b/public/locales/zh-TW/edit-favorites-modal.json
@@ -0,0 +1,18 @@
+{
+ "title": "編輯常用符號",
+ "listLabel": "常用符號",
+ "moveUp": "上移 {{name}}",
+ "moveDown": "下移 {{name}}",
+ "remove": "刪除 {{name}}",
+ "movedUp": "{{name}} 已上移,目前第 {{position}} 個,共 {{total}} 個",
+ "movedDown": "{{name}} 已下移,目前第 {{position}} 個,共 {{total}} 個",
+ "removed": "已刪除 {{name}}",
+ "noFavoritesLeft": "已刪除所有常用符號,按「完成」儲存。",
+ "cancel": "取消",
+ "confirm": "完成",
+ "saved": "您已成功編輯常用符號",
+ "saveFailed": "無法儲存常用符號,請稍後再試",
+ "emptyTitle": "尚未建立任何常用符號",
+ "emptyMessage": "請先在欲建立的符號上按右鍵(或快顯鍵、Shift+F10)來加入常用符號",
+ "emptyConfirm": "確認"
+}
diff --git a/public/locales/zh-TW/home.json b/public/locales/zh-TW/home.json
index f61c39f..cb18c09 100644
--- a/public/locales/zh-TW/home.json
+++ b/public/locales/zh-TW/home.json
@@ -11,6 +11,7 @@
"exportWebsite": "匯出網站",
"bracket2dollar": "括號轉錢號",
"dollar2bracket": "錢號轉括號",
+ "editFavorites": "編輯常用",
"descript": "說明",
"instructions": "教學",
"preview": "轉換結果",
diff --git a/public/locales/zh-TW/tabs.json b/public/locales/zh-TW/tabs.json
index d88f9b7..b6f063c 100644
--- a/public/locales/zh-TW/tabs.json
+++ b/public/locales/zh-TW/tabs.json
@@ -21,7 +21,19 @@
"quote": "引用",
"table": "表格"
},
+ "favorite": {
+ "empty": "尚未建立常用符號。在符號上按右鍵(或快顯鍵、Shift+F10)即可加入常用。",
+ "add": "加入常用",
+ "added": "您已成功將 {{name}} 加入至常用符號",
+ "limitReachedTitle": "常用符號已超過上限",
+ "limitReachedMessage": "最多只能有 {{limit}} 個常用符號\n請刪除其他符號以加入新的符號",
+ "duplicateTitle": "常用符號已重複",
+ "duplicateMessage": "請再檢查一次常用符號列",
+ "confirm": "確認",
+ "saveFailed": "無法儲存常用符號,請稍後再試"
+ },
"categorys": {
+ "favorite": "常用符號",
"common": "常見",
"operator": "運算子",
"relation": "關係",
diff --git a/src/components/core/context-menu.jsx b/src/components/core/context-menu.jsx
new file mode 100644
index 0000000..2404c8c
--- /dev/null
+++ b/src/components/core/context-menu.jsx
@@ -0,0 +1,203 @@
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import PropTypes from 'prop-types';
+
+const VIEWPORT_MARGIN = 8;
+const TRIGGER_GAP = 4;
+
+/**
+ * Pointer events open the menu at the pointer. Keyboard-triggered ones open it just below
+ * `event.currentTarget`.
+ */
+export const getContextMenuPosition = (event) => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const { clientX, clientY } = event;
+ // Keyboard-triggered `contextmenu` events report the trigger's centre or 0,0 as their
+ // position. Browsers that dispatch them as PointerEvents give them an empty pointerType.
+ const isKeyboard = (event.nativeEvent ?? event).pointerType === '';
+ const isPointerOnTrigger =
+ !isKeyboard &&
+ typeof clientX === 'number' &&
+ (clientX !== 0 || clientY !== 0) &&
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom;
+
+ return isPointerOnTrigger
+ ? { x: clientX, y: clientY }
+ : { x: rect.left, y: rect.bottom + TRIGGER_GAP };
+};
+
+/**
+ * A context menu that follows the WAI-ARIA menu pattern. Mounting it opens it, and it calls
+ * `onClose` when the parent should unmount it.
+ */
+const ContextMenu = ({ position, label, items, returnFocusTo, onClose }) => {
+ const menuRef = useRef(null);
+ const itemRefs = useRef([]);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [placement, setPlacement] = useState(position);
+
+ // Without preventScroll the template panel could scroll under the pointer and the next click
+ // would miss.
+ const returnFocus = useCallback(() => {
+ returnFocusTo?.focus({ preventScroll: true });
+ }, [returnFocusTo]);
+
+ const close = useCallback(() => {
+ // Never leave focus on an element that is about to be removed.
+ if (menuRef.current?.contains(document.activeElement)) {
+ returnFocus();
+ }
+ onClose();
+ }, [onClose, returnFocus]);
+
+ const select = (index) => {
+ returnFocus();
+ onClose();
+ items[index].onSelect();
+ };
+
+ useLayoutEffect(() => {
+ const { width, height } = menuRef.current.getBoundingClientRect();
+ setPlacement({
+ x: Math.max(
+ VIEWPORT_MARGIN,
+ Math.min(position.x, window.innerWidth - width - VIEWPORT_MARGIN)
+ ),
+ y: Math.max(
+ VIEWPORT_MARGIN,
+ Math.min(position.y, window.innerHeight - height - VIEWPORT_MARGIN)
+ ),
+ });
+ }, [position]);
+
+ useEffect(() => {
+ itemRefs.current[activeIndex]?.focus();
+ }, [activeIndex]);
+
+ useEffect(() => {
+ const handlePointerDown = (event) => {
+ if (!menuRef.current?.contains(event.target)) {
+ close();
+ }
+ };
+ document.addEventListener('mousedown', handlePointerDown, true);
+ window.addEventListener('resize', close);
+ window.addEventListener('scroll', close, true);
+ return () => {
+ document.removeEventListener('mousedown', handlePointerDown, true);
+ window.removeEventListener('resize', close);
+ window.removeEventListener('scroll', close, true);
+ };
+ }, [close]);
+
+ const handleKeyDown = (event) => {
+ const lastIndex = items.length - 1;
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault();
+ setActiveIndex((index) => (index === lastIndex ? 0 : index + 1));
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ setActiveIndex((index) => (index === 0 ? lastIndex : index - 1));
+ break;
+ case 'Home':
+ event.preventDefault();
+ setActiveIndex(0);
+ break;
+ case 'End':
+ event.preventDefault();
+ setActiveIndex(lastIndex);
+ break;
+ case 'Enter':
+ event.preventDefault();
+ select(activeIndex);
+ break;
+ case ' ':
+ // Runs on keyup, like a native button, so the keyup cannot reach the trigger.
+ event.preventDefault();
+ break;
+ case 'Escape':
+ event.preventDefault();
+ close();
+ break;
+ case 'Tab':
+ // Focus returns to the trigger first, so Tab then moves on from there.
+ close();
+ break;
+ default:
+ }
+ };
+
+ const handleKeyUp = (event) => {
+ if (event.key === ' ') {
+ event.preventDefault();
+ select(activeIndex);
+ }
+ };
+
+ // Closes the menu when focus leaves without a press on the page, for example into an iframe
+ // or another app.
+ const handleBlur = (event) => {
+ if (!menuRef.current?.contains(event.relatedTarget)) {
+ onClose();
+ }
+ };
+
+ return createPortal(
+
event.preventDefault()}
+ onContextMenu={(event) => event.preventDefault()}
+ >
+ {items.map((item, index) => (
+
+ ))}
+
,
+ document.body
+ );
+};
+
+ContextMenu.propTypes = {
+ position: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ }).isRequired,
+ label: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ onSelect: PropTypes.func.isRequired,
+ })
+ ).isRequired,
+ returnFocusTo: PropTypes.instanceOf(Element),
+ onClose: PropTypes.func.isRequired,
+};
+
+export default ContextMenu;
diff --git a/src/components/core/context-menu.test.jsx b/src/components/core/context-menu.test.jsx
new file mode 100644
index 0000000..c834b2c
--- /dev/null
+++ b/src/components/core/context-menu.test.jsx
@@ -0,0 +1,228 @@
+import { vi } from 'vitest';
+import React, { useState } from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import ContextMenu, { getContextMenuPosition } from './context-menu';
+
+const TRIGGER_RECT = { left: 100, top: 50, right: 150, bottom: 100, width: 50, height: 50 };
+
+const Harness = ({ items }) => {
+ const [menu, setMenu] = useState(null);
+ return (
+ <>
+
+
+ {menu && (
+ setMenu(null)}
+ />
+ )}
+ >
+ );
+};
+
+const renderOpenMenu = () => {
+ const items = [
+ { id: 'first', label: 'First', onSelect: vi.fn() },
+ { id: 'second', label: 'Second', onSelect: vi.fn() },
+ { id: 'third', label: 'Third', onSelect: vi.fn() },
+ ];
+ render();
+ const trigger = screen.getByRole('button', { name: 'trigger' });
+ trigger.focus();
+ fireEvent.contextMenu(trigger);
+ return { items, trigger, menu: screen.getByRole('menu', { name: 'menu label' }) };
+};
+
+describe('', () => {
+ it('focuses the first item when it opens', () => {
+ renderOpenMenu();
+ expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([
+ 'First',
+ 'Second',
+ 'Third',
+ ]);
+ expect(screen.getByRole('menuitem', { name: 'First' })).toHaveFocus();
+ });
+
+ it('moves between items with the arrow, Home and End keys, wrapping around', () => {
+ const { menu } = renderOpenMenu();
+ const item = (name) => screen.getByRole('menuitem', { name });
+
+ fireEvent.keyDown(menu, { key: 'ArrowUp' });
+ expect(item('Third')).toHaveFocus();
+ fireEvent.keyDown(menu, { key: 'ArrowDown' });
+ expect(item('First')).toHaveFocus();
+ fireEvent.keyDown(menu, { key: 'ArrowDown' });
+ expect(item('Second')).toHaveFocus();
+ fireEvent.keyDown(menu, { key: 'End' });
+ expect(item('Third')).toHaveFocus();
+ fireEvent.keyDown(menu, { key: 'Home' });
+ expect(item('First')).toHaveFocus();
+ });
+
+ it('runs the focused item with Enter, closes and returns focus to the trigger', () => {
+ const { items, trigger, menu } = renderOpenMenu();
+ fireEvent.keyDown(menu, { key: 'ArrowDown' });
+ fireEvent.keyDown(menu, { key: 'Enter' });
+
+ expect(items[1].onSelect).toHaveBeenCalledTimes(1);
+ expect(items[0].onSelect).not.toHaveBeenCalled();
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('runs the focused item with Space on keyup, so the keyup never reaches the trigger', () => {
+ const { items, trigger, menu } = renderOpenMenu();
+ fireEvent.keyDown(menu, { key: 'ArrowDown' });
+ fireEvent.keyDown(menu, { key: ' ' });
+
+ expect(items[1].onSelect).not.toHaveBeenCalled();
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+
+ fireEvent.keyUp(menu, { key: ' ' });
+ expect(items[1].onSelect).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('runs an item on click', () => {
+ const { items, trigger } = renderOpenMenu();
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Third' }));
+
+ expect(items[2].onSelect).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('closes on Escape without running anything and returns focus to the trigger', () => {
+ const { items, trigger, menu } = renderOpenMenu();
+ fireEvent.keyDown(menu, { key: 'Escape' });
+
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ items.forEach((item) => expect(item.onSelect).not.toHaveBeenCalled());
+ expect(trigger).toHaveFocus();
+ });
+
+ it('closes on Tab and returns focus to the trigger', () => {
+ const { trigger, menu } = renderOpenMenu();
+ fireEvent.keyDown(menu, { key: 'Tab' });
+
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+
+ it('closes when pressing outside, without running anything, and returns focus to the trigger', () => {
+ const { items, trigger } = renderOpenMenu();
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'outside' }));
+
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ items.forEach((item) => expect(item.onSelect).not.toHaveBeenCalled());
+ expect(trigger).toHaveFocus();
+ });
+
+ it('returns focus without scrolling the trigger into view', () => {
+ const { trigger, menu } = renderOpenMenu();
+ const focus = vi.spyOn(trigger, 'focus');
+ fireEvent.keyDown(menu, { key: 'Escape' });
+
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ });
+
+ it('stays open, keeping focus, when pressing inside', () => {
+ renderOpenMenu();
+ const pressed = fireEvent.mouseDown(screen.getByRole('menuitem', { name: 'Second' }));
+
+ expect(pressed).toBe(false);
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ });
+
+ it.each([
+ ['the page scrolls', () => fireEvent.scroll(window)],
+ ['the window resizes', () => fireEvent(window, new Event('resize'))],
+ ])('closes when %s', (_, act) => {
+ renderOpenMenu();
+ act();
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ });
+
+ it('closes, without taking focus back, when focus leaves the page', () => {
+ const { trigger } = renderOpenMenu();
+ fireEvent.blur(screen.getByRole('menuitem', { name: 'First' }), { relatedTarget: null });
+
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(trigger).not.toHaveFocus();
+ });
+
+ it('stays open when focus moves between its items', () => {
+ renderOpenMenu();
+ fireEvent.blur(screen.getByRole('menuitem', { name: 'First' }), {
+ relatedTarget: screen.getByRole('menuitem', { name: 'Second' }),
+ });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ });
+
+ it('suppresses the browser context menu on itself', () => {
+ const { menu } = renderOpenMenu();
+ expect(fireEvent.contextMenu(menu)).toBe(false);
+ });
+
+ it('keeps itself inside the viewport', () => {
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ width: 100,
+ height: 40,
+ });
+ render(
+
+ );
+
+ expect(screen.getByRole('menu')).toHaveStyle({
+ left: `${window.innerWidth - 100 - 8}px`,
+ top: `${window.innerHeight - 40 - 8}px`,
+ });
+ vi.restoreAllMocks();
+ });
+});
+
+describe('getContextMenuPosition', () => {
+ const eventOn = (init) => {
+ const currentTarget = { getBoundingClientRect: () => TRIGGER_RECT };
+ return { currentTarget, ...init };
+ };
+
+ it('opens at the pointer for a right click on the trigger', () => {
+ expect(getContextMenuPosition(eventOn({ clientX: 120, clientY: 70 }))).toEqual({
+ x: 120,
+ y: 70,
+ });
+ });
+
+ it.each([
+ ['a keyboard event', {}],
+ ['a contextmenu event without coordinates', { clientX: 0, clientY: 0 }],
+ ['coordinates outside the trigger', { clientX: 5, clientY: 5 }],
+ [
+ 'a keyboard-triggered contextmenu at the trigger centre',
+ { clientX: 125, clientY: 75, nativeEvent: { pointerType: '' } },
+ ],
+ ])('opens below the trigger for %s', (_, init) => {
+ expect(getContextMenuPosition(eventOn(init))).toEqual({ x: 100, y: 104 });
+ });
+});
diff --git a/src/components/core/modal/alert-modal.jsx b/src/components/core/modal/alert-modal.jsx
new file mode 100644
index 0000000..fa465f8
--- /dev/null
+++ b/src/components/core/modal/alert-modal.jsx
@@ -0,0 +1,63 @@
+import React, { Fragment, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { Dialog, Transition } from '@headlessui/react';
+import { IconAlertTriangle } from '@tabler/icons-react';
+
+import Button from '@/components/core/button';
+
+/**
+ * A blocking message with one confirm button, following the alertdialog pattern.
+ * `message` may contain line breaks.
+ */
+const AlertModal = ({ isOpen, title, message, confirmLabel, onClose }) => {
+ const confirmButtonRef = useRef(null);
+
+ return (
+
+
+
+ );
+};
+
+AlertModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ title: PropTypes.string,
+ message: PropTypes.string,
+ confirmLabel: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+};
+
+export default AlertModal;
diff --git a/src/components/core/modal/alert-modal.test.jsx b/src/components/core/modal/alert-modal.test.jsx
new file mode 100644
index 0000000..a8fd0d0
--- /dev/null
+++ b/src/components/core/modal/alert-modal.test.jsx
@@ -0,0 +1,52 @@
+import { vi } from 'vitest';
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+import AlertModal from './alert-modal';
+
+const renderAlertModal = (props) => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ return { onClose };
+};
+
+describe('', () => {
+ // Headless UI wires up the description and initial focus after mounting.
+ it('exposes its title and message to assistive technology', async () => {
+ renderAlertModal();
+ const dialog = screen.getByRole('alertdialog', { name: 'Limit reached' });
+
+ await waitFor(() => expect(dialog).toHaveAccessibleDescription(/First line\s+Second line/));
+ });
+
+ it('focuses the confirm button when it opens', async () => {
+ renderAlertModal();
+ await waitFor(() => expect(screen.getByRole('button', { name: 'OK' })).toHaveFocus());
+ });
+
+ it.each([
+ ['the confirm button', () => fireEvent.click(screen.getByRole('button', { name: 'OK' }))],
+ [
+ 'Escape',
+ () => fireEvent.keyDown(screen.getByRole('button', { name: 'OK' }), { key: 'Escape' }),
+ ],
+ ])('closes with %s', (_, act) => {
+ const { onClose } = renderAlertModal();
+ act();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('renders nothing while closed', () => {
+ renderAlertModal({ isOpen: false });
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/core/modal/basic-modal.jsx b/src/components/core/modal/basic-modal.jsx
index 34e9341..fef0f77 100644
--- a/src/components/core/modal/basic-modal.jsx
+++ b/src/components/core/modal/basic-modal.jsx
@@ -17,6 +17,8 @@ const BasicModal = ({
confirmLabel = 'Confirm',
size = 'l',
asForm = false,
+ initialFocus,
+ afterLeave,
children,
}) => {
// When asForm, the body and footer live inside a