Skip to content

Commit 747c08f

Browse files
committed
fix(editor): keep the top bar usable when the window gets narrow
The bar packed every control on one row at a fixed size, so a narrow window pushed Export off the edge and squeezed the mode switch until its labels clipped. Four breakpoints now shed weight in order of what matters least: the gaps and the project field tighten, the wordmark and the language chevron go, then the separators, the saved-state label and the language icon, and finally the Export label — leaving the icon buttons, the mode tabs and Export itself always reachable. Everything that loses its text keeps a title and an accessible name, so nothing becomes anonymous. The mode switch stops shrinking to make room; a max-width plus ellipsis handles a verbose locale instead. Also moves the language button and its menu out of inline styles into the stylesheet, since the breakpoints need to reach them.
1 parent 83c5bad commit 747c08f

3 files changed

Lines changed: 223 additions & 43 deletions

File tree

src/components/ai-edition/v4/EditorShellV4.module.css

Lines changed: 168 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,8 @@
193193
background: var(--border);
194194
}
195195
.iconBtn {
196-
width: 32px;
197-
height: 32px;
196+
width: var(--topbar-icon);
197+
height: var(--topbar-icon);
198198
display: grid;
199199
place-items: center;
200200
border-radius: 9px;
@@ -302,6 +302,58 @@
302302
box-shadow: 0 0 0 3px var(--accent-soft);
303303
}
304304

305+
.langAnchor {
306+
position: relative;
307+
flex-shrink: 0;
308+
}
309+
.langBtn {
310+
width: auto;
311+
padding: 0 8px;
312+
gap: 6px;
313+
display: inline-flex;
314+
align-items: center;
315+
}
316+
.langIcon {
317+
flex-shrink: 0;
318+
}
319+
.langChevron {
320+
color: var(--muted);
321+
flex-shrink: 0;
322+
}
323+
.langMenu {
324+
position: absolute;
325+
top: calc(100% + 4px);
326+
right: 0;
327+
min-width: 160px;
328+
background: var(--surface);
329+
border: 1px solid var(--border);
330+
border-radius: var(--r-md);
331+
box-shadow: var(--elev-pop);
332+
padding: 4px;
333+
z-index: 60;
334+
}
335+
.langMenuItem {
336+
display: block;
337+
width: 100%;
338+
text-align: left;
339+
padding: 6px 10px;
340+
border: 0;
341+
background: transparent;
342+
color: var(--fg-2);
343+
border-radius: var(--r-sm);
344+
cursor: pointer;
345+
font: 500 12px var(--font-body);
346+
transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease);
347+
}
348+
.langMenuItem:hover {
349+
background: var(--surface-2);
350+
color: var(--fg);
351+
}
352+
.langMenuItem[data-active="true"] {
353+
background: var(--accent-wash);
354+
color: var(--accent);
355+
}
356+
305357
/* One box for every short locale label — see --topbar-lang-label-w. Without it
306358
the language button resized between "EN", "PT-BR" and the CJK "简中", moving
307359
everything to its right. */
@@ -330,12 +382,8 @@
330382
background: var(--surface-1);
331383
border: 1px solid var(--border);
332384
border-radius: 11px;
333-
/* The only shrinkable item in the bar (everything else is flex-shrink: 0), so
334-
a verbose locale — fr "Enregistrement" is nearly twice "Médias" — narrows
335-
this control instead of shoving Export off the edge of a 1200px window. The
336-
columns stay equal to each other while it happens; only the labels clip. */
337-
flex-shrink: 1;
338-
min-width: 0;
385+
flex-shrink: 0;
386+
min-width: fit-content;
339387
}
340388
.modeSwitch button {
341389
padding: 5px 14px;
@@ -360,13 +408,18 @@
360408
grid-template-columns: minmax(0, 1fr);
361409
place-items: center;
362410
min-width: 0;
411+
max-width: 140px;
363412
overflow: hidden;
364413
}
365414
.modeSwitch button::before {
366415
content: attr(data-label);
367416
grid-area: label;
368417
font-weight: 600;
369418
visibility: hidden;
419+
max-width: 100%;
420+
overflow: hidden;
421+
text-overflow: ellipsis;
422+
white-space: nowrap;
370423
}
371424
.modeLabel {
372425
grid-area: label;
@@ -406,6 +459,113 @@
406459
cursor: not-allowed;
407460
}
408461

462+
/* ─── Responsive topbar rules ─── */
463+
@media (max-width: 1240px) {
464+
.topbar {
465+
--topbar-gap: 10px;
466+
--topbar-project-w: 130px;
467+
}
468+
.modeSwitch button {
469+
padding: 4px 10px;
470+
}
471+
}
472+
473+
@media (max-width: 1080px) {
474+
.topbar {
475+
--topbar-gap: 8px;
476+
--topbar-icon: 30px;
477+
--topbar-project-w: 100px;
478+
padding-left: calc(12px + var(--titlebar-inset-left, 0px));
479+
padding-right: calc(
480+
12px + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px)
481+
);
482+
}
483+
.brand .name {
484+
display: none;
485+
}
486+
.brandBtn {
487+
margin: 0;
488+
padding: 4px;
489+
}
490+
.langBtn {
491+
padding: 0 5px;
492+
gap: 4px;
493+
}
494+
.langChevron {
495+
display: none;
496+
}
497+
.modeSwitch button {
498+
padding: 4px 8px;
499+
font-size: 11px;
500+
max-width: 90px;
501+
}
502+
.exportBtn {
503+
padding: 0 10px;
504+
gap: 5px;
505+
font-size: 12px;
506+
}
507+
}
508+
509+
@media (max-width: 960px) {
510+
.topbar {
511+
--topbar-gap: 6px;
512+
--topbar-project-w: 80px;
513+
padding-left: calc(8px + var(--titlebar-inset-left, 0px));
514+
padding-right: calc(
515+
8px + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px)
516+
);
517+
}
518+
.sep {
519+
display: none;
520+
}
521+
.topbarLead {
522+
width: var(--topbar-icon);
523+
gap: 0;
524+
}
525+
.savedLabel {
526+
display: none;
527+
}
528+
.saved {
529+
width: auto;
530+
}
531+
.savedState {
532+
gap: 0;
533+
}
534+
.langIcon {
535+
display: none;
536+
}
537+
.langShort {
538+
width: auto;
539+
font-size: 11px;
540+
}
541+
}
542+
543+
@media (max-width: 850px) {
544+
.topbar {
545+
--topbar-gap: 4px;
546+
--topbar-project-w: 70px;
547+
}
548+
.modeSwitch button {
549+
padding: 3px 6px;
550+
font-size: 10.5px;
551+
max-width: 70px;
552+
}
553+
.exportLabel {
554+
display: none;
555+
}
556+
.exportBtn {
557+
padding: 0;
558+
width: var(--topbar-icon);
559+
justify-content: center;
560+
}
561+
.langBtn {
562+
padding: 0 4px;
563+
}
564+
.langShort {
565+
font-size: 10px;
566+
}
567+
}
568+
409569
/* ─── BODY ─────────────────────────────────────────────────────────── */
410570
.body {
411571
position: relative;

src/components/ai-edition/v4/EditorTopBar.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,44 @@ describe("AppMenu", () => {
237237
}
238238
});
239239
});
240+
241+
describe("EditorTopBar responsive affordances and tooltips", () => {
242+
it("provides accessible name and title on the export button", () => {
243+
renderTopBar("Demo Project");
244+
const exportBtn = screen.getByRole("button", { name: "topbar.export" });
245+
expect(exportBtn).toBeInTheDocument();
246+
expect(exportBtn).toHaveAttribute("title", "topbar.export");
247+
});
248+
249+
it("provides title tooltips for mode switch tabs", () => {
250+
renderTopBar("Demo Project");
251+
const tabs = screen.getAllByRole("tab");
252+
expect(tabs).toHaveLength(3);
253+
expect(tabs[0]).toHaveAttribute("title", "topbar.modes.media");
254+
expect(tabs[1]).toHaveAttribute("title", "topbar.modes.edit");
255+
expect(tabs[2]).toHaveAttribute("title", "topbar.modes.rec");
256+
});
257+
258+
it("provides title tooltips on the saved status indicator", () => {
259+
renderTopBar("Demo Project");
260+
const savedIndicator = screen.getByTitle("topbar.saved");
261+
expect(savedIndicator).toBeInTheDocument();
262+
expect(savedIndicator).toHaveTextContent("topbar.saved");
263+
});
264+
265+
it("keeps the brand trigger accessible by label and title even when text collapses", () => {
266+
renderTopBar("Demo Project");
267+
const brandBtn = screen.getByRole("button", { name: "OpenScreen" });
268+
expect(brandBtn).toHaveAttribute("title", "OpenScreen");
269+
expect(brandBtn).toHaveAttribute("aria-label", "OpenScreen");
270+
});
271+
272+
it("provides accessible language toggle with short code and options", () => {
273+
renderTopBar("Demo Project");
274+
const langBtn = screen.getByRole("button", { name: "topbar.changeLanguage" });
275+
expect(langBtn).toBeInTheDocument();
276+
expect(langBtn).toHaveTextContent("EN");
277+
fireEvent.click(langBtn);
278+
expect(screen.getByText("English")).toBeInTheDocument();
279+
});
280+
});

src/components/ai-edition/v4/EditorTopBar.tsx

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -140,18 +140,18 @@ export function EditorTopBar({
140140
keeps the width of the longer label and the bar doesn't twitch every
141141
time the document goes dirty. The inactive one is visibility:hidden,
142142
which also takes it out of the accessibility tree. */}
143-
<span className={styles.saved}>
143+
<span className={styles.saved} title={dirty ? t("topbar.unsaved") : t("topbar.saved")}>
144144
<span className={styles.savedState} data-on={!dirty}>
145145
<span className={styles.dot} aria-hidden />
146-
{t("topbar.saved")}
146+
<span className={styles.savedLabel}>{t("topbar.saved")}</span>
147147
</span>
148148
<span className={styles.savedState} data-on={dirty}>
149149
<span
150150
className={styles.dot}
151151
aria-hidden
152152
style={{ background: "var(--warn)", boxShadow: "0 0 0 3px var(--warn-soft)" }}
153153
/>
154-
{t("topbar.unsaved")}
154+
<span className={styles.savedLabel}>{t("topbar.unsaved")}</span>
155155
</span>
156156
</span>
157157

@@ -162,6 +162,7 @@ export function EditorTopBar({
162162
type="button"
163163
role="tab"
164164
aria-selected={mode === m.id}
165+
title={t(m.labelKey)}
165166
// Feeds the hidden bold copy that reserves the selected width — see
166167
// .modeSwitch button::before.
167168
data-label={t(m.labelKey)}
@@ -190,7 +191,7 @@ export function EditorTopBar({
190191
disabled={!canExport}
191192
>
192193
<Download size={15} />
193-
{t("topbar.export")}
194+
<span className={styles.exportLabel}>{t("topbar.export")}</span>
194195
</button>
195196
</header>
196197
);
@@ -384,6 +385,8 @@ function AppMenu({ actions }: { actions: TopBarActions }) {
384385
className={`${styles.brand} ${styles.brandBtn}`}
385386
aria-haspopup="menu"
386387
aria-expanded={open}
388+
aria-label="OpenScreen"
389+
title="OpenScreen"
387390
onClick={() => setOpen((v) => !v)}
388391
>
389392
{/* Decorative: the wordmark beside it already names the app — and, being the
@@ -462,53 +465,29 @@ function LangButton() {
462465
return () => document.removeEventListener("mousedown", onDocClick);
463466
}, [open]);
464467
return (
465-
<div ref={ref} style={{ position: "relative", flexShrink: 0 }}>
468+
<div ref={ref} className={styles.langAnchor}>
466469
<button
467470
type="button"
468-
className={styles.iconBtn}
469-
style={{ width: "auto", padding: "0 8px", gap: 6, display: "inline-flex" }}
471+
className={`${styles.iconBtn} ${styles.langBtn}`}
470472
onClick={() => setOpen((v) => !v)}
471473
aria-label={t("topbar.changeLanguage")}
472474
aria-pressed={open}
473475
>
474-
<Languages size={15} />
476+
<Languages size={15} className={styles.langIcon} />
475477
{/* Fixed-width, centred: the short labels run from "EN" to "PT-BR" to
476478
the CJK "简中", and letting the button size to them moved everything
477479
to its right on each language change. */}
478480
<span className={styles.langShort}>{getLocaleShort(locale)}</span>
479-
<ChevronDown size={9} style={{ color: "var(--muted)" }} />
481+
<ChevronDown size={9} className={styles.langChevron} />
480482
</button>
481483
{open ? (
482-
<div
483-
style={{
484-
position: "absolute",
485-
top: "calc(100% + 4px)",
486-
right: 0,
487-
minWidth: 160,
488-
background: "var(--surface)",
489-
border: "1px solid var(--border)",
490-
borderRadius: "var(--r-md)",
491-
boxShadow: "var(--elev-pop)",
492-
padding: 4,
493-
zIndex: 60,
494-
}}
495-
>
484+
<div className={styles.langMenu}>
496485
{getAvailableLocales().map((code) => (
497486
<button
498487
key={code}
499488
type="button"
500-
style={{
501-
display: "block",
502-
width: "100%",
503-
textAlign: "left",
504-
padding: "6px 10px",
505-
border: 0,
506-
background: code === locale ? "var(--accent-wash)" : "transparent",
507-
color: code === locale ? "var(--accent)" : "var(--fg-2)",
508-
borderRadius: "var(--r-sm)",
509-
cursor: "pointer",
510-
font: "500 12px var(--font-body)",
511-
}}
489+
className={styles.langMenuItem}
490+
data-active={code === locale}
512491
onClick={() => {
513492
setLocale(code);
514493
setOpen(false);

0 commit comments

Comments
 (0)