diff --git a/client/src/components/Studio/StudioExport.tsx b/client/src/components/Studio/StudioExport.tsx index c8d90798d..ba1021273 100644 --- a/client/src/components/Studio/StudioExport.tsx +++ b/client/src/components/Studio/StudioExport.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState } from 'react' -import { BookOpen, FileText, Printer, Scissors, X } from 'lucide-react' +import { useEffect, useId, useRef, useState } from 'react' +import { BookOpen, FileText, Printer, Scissors } from 'lucide-react' +import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import type { BookDocument } from '@trek/shared' import { BookSheetsView } from './BookSheetsView' import { sheetBox, sheetsFor, type SheetMode } from './bookSheets' @@ -38,6 +39,7 @@ export function StudioExport({ /** Set once the user has asked for it — this is what triggers the render. */ const [building, setBuilding] = useState(false) const stage = useRef(null) + const labelId = useId() const sheets = sheetsFor(doc, mode) @@ -76,78 +78,71 @@ export function StudioExport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [building]) + // Studio closes itself on Escape. While this dialog is open the key is its own, + // so it is taken in the capture phase before the editor's handler sees it. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + e.stopPropagation() + onClose() + } + document.addEventListener('keydown', onKey, true) + return () => document.removeEventListener('keydown', onKey, true) + }, [onClose]) + return ( -
-
-
- {t('journey.studio.export')} - -
- -
- - label={t('journey.studio.exportLayout')} - options={[ - { - id: 'pages', - icon: FileText, - name: t('journey.studio.exportPages'), - hint: t('journey.studio.exportPagesHint'), - }, - { - id: 'spreads', - icon: BookOpen, - name: t('journey.studio.exportSpreads'), - hint: t('journey.studio.exportSpreadsHint'), - }, - ]} - value={mode} - onPick={setMode} + <> + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={onClose} + title={t('journey.studio.export')} /> - -
- {t('journey.studio.exportFinishing')} - + )} + footer={( + + + {t('common.cancel')} + setBuilding(true)} disabled={building} icon={}> + {t('journey.studio.exportOpen')} + + + )} + > + +
+
+
-

- {t('journey.studio.exportNote', { - sheets: sheets.length, - width: round1(box.width), - height: round1(box.height), - })} -

-
+ + -
- - -
-
+

+ {t('journey.studio.exportNote', { + sheets: sheets.length, + width: round1(box.width), + height: round1(box.height), + })} +

+ {/* The sheets, rendered where nobody can see them. Off screen rather than `display: none`: a hidden subtree lays nothing out, and these are measured in millimetres by the same CSS that will - print them — a book built from an unlaid-out tree is a book of empty - boxes. `aria-hidden` keeps the whole thing out of the reading order. + print them, a book built from an unlaid-out tree is a book of empty + boxes. They stay inside Studio's tree, where that CSS applies, while the + dialog itself is portalled over it. `aria-hidden` keeps them out of the + reading order. */} {building && (
)} -
+ ) } const round1 = (n: number) => Math.round(n * 10) / 10 -function Choice({ - label, options, value, onPick, -}: { - label: string - options: { id: T; icon: typeof FileText; name: string; hint: string }[] - value: T - onPick: (id: T) => void +/** One choice of the dialog: an icon, its name and what it means, pressed while it holds. */ +function Option({ icon: Icon, name, hint, on, onClick }: { + icon: typeof FileText + name: string + hint: string + on: boolean + onClick: () => void }) { return ( -
- {label} - {options.map(opt => ( - - ))} -
+ ) } diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index f9f2af0ad..3d2676532 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -11,6 +11,7 @@ import { Tooltip } from '../components/shared/Tooltip' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell' import { EditorField, GRID_2 } from '../components/shared/dialogParts' import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel' +import CountryFlag from '../components/shared/CountryFlag' import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel' import { continentForCountry } from '@trek/shared' import { useAtlas } from './atlas/useAtlas' @@ -787,7 +788,10 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail <>
- {countryCodeToFlag(selectedCountry)} + {/* Twemoji where the platform has no flag glyphs: Windows draws the emoji as two letters. */} + {/^[A-Za-z]{2}$/.test(selectedCountry) + ? + : {countryCodeToFlag(selectedCountry)}}

{resolveName(selectedCountry)} diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css index 32a7f5d7b..45ca9344b 100644 --- a/client/src/styles/studio.css +++ b/client/src/styles/studio.css @@ -1662,118 +1662,6 @@ @keyframes st-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .trek-dash .st-spin { animation: none; } } -/* ── Export dialog (#1973) ──────────────────────────────────────────────── - Its own prefix rather than `.st-field` and friends: those already mean a - labelled row in the toolbar, and one class doing two jobs is how a toolbar - tweak silently reflows a dialog. */ -.trek-dash .st-ex { - position: absolute; - inset: 0; - z-index: 40; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - background: color-mix(in oklch, var(--ink) 42%, transparent); - backdrop-filter: blur(2px); -} -.trek-dash .st-ex-card { - width: 100%; - max-width: 380px; - /* Opaque for the same reason as .st-menu — see there. */ - background: var(--surface); - border: 1px solid var(--line-2); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - overflow: hidden; -} -.trek-dash .st-ex-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - border-bottom: 1px solid var(--line); - font-size: 12px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--ink-3); -} -.trek-dash .st-ex-x { - display: grid; - place-items: center; - width: 24px; - height: 24px; - border-radius: 7px; - color: var(--ink-3); -} -.trek-dash .st-ex-x:hover { background: color-mix(in oklch, var(--ink) 8%, transparent); color: var(--ink); } -.trek-dash .st-ex-body { padding: 14px; display: flex; flex-direction: column; gap: 14px; } -.trek-dash .st-ex-field { display: flex; flex-direction: column; gap: 6px; } -.trek-dash .st-ex-label { - font-size: 10px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--ink-3); -} -.trek-dash .st-ex-opt { - display: flex; - align-items: flex-start; - gap: 10px; - width: 100%; - padding: 10px 11px; - text-align: left; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface-2); - color: var(--ink-2); -} -.trek-dash .st-ex-opt:hover { border-color: color-mix(in oklch, var(--accent) 40%, var(--line)); } -.trek-dash .st-ex-opt.is-on { - border-color: var(--accent); - background: color-mix(in oklch, var(--accent) 9%, var(--surface-2)); - color: var(--ink); -} -.trek-dash .st-ex-opt svg { margin-top: 1px; flex: 0 0 auto; } -.trek-dash .st-ex-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.trek-dash .st-ex-name { font-size: 12.5px; font-weight: 600; color: var(--ink); } -.trek-dash .st-ex-hint { font-size: 11px; line-height: 1.35; color: var(--ink-2); } -.trek-dash .st-ex-note { - margin: 0; - font-size: 11px; - line-height: 1.45; - color: var(--ink-3); -} -.trek-dash .st-ex-foot { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 12px 14px; - border-top: 1px solid var(--line); -} -.trek-dash .st-ex-btn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 32px; - padding: 0 13px; - border-radius: 9px; - border: 1px solid var(--line-2); - background: var(--surface-2); - font-size: 12.5px; - font-weight: 600; - color: var(--ink); -} -.trek-dash .st-ex-btn:hover { background: color-mix(in oklch, var(--ink) 6%, var(--surface-2)); } -.trek-dash .st-ex-btn.is-primary { - border-color: transparent; - background: var(--accent); - color: var(--accent-text, #fff); -} -.trek-dash .st-ex-btn.is-primary:hover { filter: brightness(1.06); } -.trek-dash .st-ex-btn[disabled] { opacity: 0.55; } - /* ── Presence and pointers (#1973) ──────────────────────────────────────── The arrow is positioned in the sheet's millimetres and scales itself back out, so it stays one size at every zoom — see PeerCursors.tsx. */ diff --git a/client/tests/unit/studio/StudioExport.test.tsx b/client/tests/unit/studio/StudioExport.test.tsx index 0a82a84c8..1621ea94b 100644 --- a/client/tests/unit/studio/StudioExport.test.tsx +++ b/client/tests/unit/studio/StudioExport.test.tsx @@ -42,8 +42,8 @@ afterEach(() => { vi.useRealTimers() }) describe('the choices', () => { it('starts on single pages, which is what a printer takes', () => { open() - expect(screen.getByText('journey.studio.exportPages').closest('button')!.className) - .toContain('is-on') + expect(screen.getByText('journey.studio.exportPages').closest('button')!.getAttribute('aria-pressed')) + .toBe('true') }) it('starts with crop marks on', () => { @@ -55,10 +55,10 @@ describe('the choices', () => { it('switches to spreads when asked', () => { open() fireEvent.click(screen.getByText('journey.studio.exportSpreads')) - expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.className) - .toContain('is-on') - expect(screen.getByText('journey.studio.exportPages').closest('button')!.className) - .not.toContain('is-on') + expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.getAttribute('aria-pressed')) + .toBe('true') + expect(screen.getByText('journey.studio.exportPages').closest('button')!.getAttribute('aria-pressed')) + .toBe('false') }) it('turns the marks off again', () => {