mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: run the app to the top of the window, with the window's controls in its own toolbars
The desktop app and an installed PWA had a row across the whole window with the title and the window controls. That row is gone. The rail, sidebar, list and reader run to the top of the window in their own colours, and the controls sit in the top row of whatever is rightmost. - `lib/window-controls-overlay.ts` finds and marks those rows before paint, and they reserve the controls' width through `--titlebar-right-inset` - the rows and the rail are the drag area, everything clickable is `no-drag`, and an invisible band lets a page with no row still move - settings and the admin shell get a row only under the overlay, Pro's tab strip and Mountain View's bar match the controls' height - Alt floats the menu over the top left, in `window-menu-bar.tsx` - skeletons carry the same marks, so nothing shifts when the app loads
This commit is contained in:
@@ -38,6 +38,7 @@ import { MainWindowOnly } from "@/components/popout/main-window-only";
|
||||
import { PopoutHost } from "@/components/popout/popout-host";
|
||||
import { WindowSyncHost } from "@/components/popout/window-sync-host";
|
||||
import { DesktopHost } from "@/components/desktop/desktop-host";
|
||||
import { WindowMenuBar } from "@/components/layout/window-menu-bar";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -90,6 +91,7 @@ export default async function LocaleLayout({
|
||||
{!IS_LITE && <PopoutHost />}
|
||||
</MainWindowOnly>
|
||||
{!IS_LITE && <WindowSyncHost />}
|
||||
{!IS_LITE && <WindowMenuBar />}
|
||||
{!IS_LITE && <DesktopHost />}
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
|
||||
@@ -1208,7 +1208,7 @@ function LoginPageContent() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
|
||||
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
@@ -1374,7 +1374,7 @@ function LoginPageContent() {
|
||||
}}
|
||||
/>
|
||||
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
|
||||
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { TourProvider } from '@/components/tour/tour-provider';
|
||||
import { defaultLocale } from '@/i18n/routing';
|
||||
import { AdminLayoutClient } from './_layout-client';
|
||||
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
|
||||
|
||||
/**
|
||||
* /admin sits outside the [locale] tree, so it loads the message catalog
|
||||
@@ -25,6 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
<TourProvider>
|
||||
<WindowMenuBar />
|
||||
<AdminLayoutClient>{children}</AdminLayoutClient>
|
||||
</TourProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { locales, defaultLocale } from "@/i18n/routing";
|
||||
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
|
||||
import { accessibilityBootScript } from "@/lib/accessibility-settings";
|
||||
import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script";
|
||||
import { WindowTitleBar } from "@/components/layout/window-title-bar";
|
||||
import { WindowControlsHost } from "@/components/layout/window-controls-host";
|
||||
import "../globals.css";
|
||||
import "../print.css";
|
||||
import "../desktop.css";
|
||||
@@ -334,7 +334,7 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<WindowTitleBar />
|
||||
<WindowControlsHost />
|
||||
{!themeColorConfigured && <ThemeColorSync />}
|
||||
<FaviconBadge />
|
||||
<ErrorReportingRoot />
|
||||
|
||||
+1
-2
@@ -36,8 +36,7 @@ html[data-desktop] :is(
|
||||
[role="treeitem"],
|
||||
[role="row"],
|
||||
[data-composer-header],
|
||||
[data-mv-compose-titlebar],
|
||||
[data-window-title-bar]
|
||||
[data-mv-compose-titlebar]
|
||||
) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
+126
-11
@@ -569,29 +569,144 @@ optgroup {
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/*
|
||||
* The top of the page in an installed app without a native title bar. 0
|
||||
* without one: then --app-top-inset is exactly env(safe-area-inset-top), what
|
||||
* the page roots used before. env() and not the display-mode media query,
|
||||
* which is false in Electron with titleBarOverlay. Set at the root, so it
|
||||
* holds from the first paint, for the boot skeletons too;
|
||||
* lib/window-controls-overlay.ts keeps it up to date afterwards.
|
||||
* An installed app or the desktop app with no native title bar. No row is kept
|
||||
* for it: --titlebar-inset is 0, and the app's columns run to the top of the
|
||||
* window. The window's controls sit in the top row of the column beneath them,
|
||||
* which leaves them their room. env() and not the display-mode media query,
|
||||
* which is false in Electron with titleBarOverlay. Set at the root, so it holds
|
||||
* from the first paint, for the boot skeletons too;
|
||||
* lib/window-controls-overlay.ts keeps it exact afterwards, and marks the rows.
|
||||
*/
|
||||
:root {
|
||||
--titlebar-inset: env(titlebar-area-height, 0px);
|
||||
--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));
|
||||
--titlebar-inset: 0px;
|
||||
--app-top-inset: env(safe-area-inset-top, 0px);
|
||||
--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw));
|
||||
--titlebar-left-inset: env(titlebar-area-x, 0px);
|
||||
--titlebar-row-height: env(titlebar-area-height, 0px);
|
||||
}
|
||||
|
||||
/* components/layout/window-title-bar.tsx: what the window is dragged by. */
|
||||
.window-title-bar {
|
||||
/* A skeleton's row that will hold the controls has them from the first frame. */
|
||||
html[data-window-controls] [data-window-row-right] {
|
||||
padding-right: calc(var(--titlebar-right-inset) + 0.5rem) !important;
|
||||
}
|
||||
|
||||
/* Something floating at the top end of a page with no row: next to the controls, lined up. */
|
||||
html[data-window-controls]:not([dir="rtl"]) [data-window-beside-controls] {
|
||||
right: calc(var(--titlebar-right-inset) + 0.5rem);
|
||||
top: calc(var(--titlebar-row-height) / 2 - 1.25rem);
|
||||
}
|
||||
|
||||
/* A column docked at the window's end starts below the controls, level with its neighbours' rows. */
|
||||
html[data-window-controls]:not([data-interface-layout="mountain-view"]) [data-window-end-column] {
|
||||
padding-top: var(--titlebar-row-height);
|
||||
}
|
||||
|
||||
/* A composer that is its own window closes with the window's button, one row over. */
|
||||
html[data-window-controls] [data-own-window-close] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
|
||||
html[data-window-controls] [data-window-row] {
|
||||
min-height: var(--titlebar-row-height);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mountain View's bar is the top row there, and lines up with the controls by being as
|
||||
* tall. An installed PWA's controls are shorter than what's in that bar.
|
||||
*/
|
||||
html[data-window-controls] [data-mv-topbar] {
|
||||
height: max(var(--titlebar-row-height), 3.5rem);
|
||||
}
|
||||
|
||||
/* Pro's tab strip is the top row there: as tall as the controls, its tabs along the bottom edge. */
|
||||
html[data-window-controls] [data-pane-strip] {
|
||||
height: max(var(--titlebar-row-height), 2.25rem);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
html[data-window-controls] [data-window-row-left] {
|
||||
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* The empty parts of the app's top rows are what the window is dragged by, and a
|
||||
* double-click there maximises. Anything to click stays clickable, and so does
|
||||
* whatever floats over a row: a region's drag wins over what's painted above it.
|
||||
*
|
||||
* Under the rows lies the band the controls have, invisible and first in the page, so
|
||||
* a page with no row of its own (the login, an error) can still be moved. Everything
|
||||
* later in the page cuts its own shape out of it.
|
||||
*/
|
||||
html[data-window-controls] body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: var(--titlebar-row-height);
|
||||
pointer-events: none;
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.window-title-bar-item {
|
||||
html[data-window-controls] :is([data-window-row], [data-window-rail]) {
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* A row that's only there to hold the controls: the settings page's name, for one. */
|
||||
[data-window-only] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-window-controls]:not([data-interface-layout="mountain-view"]) [data-window-only] {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
html[data-window-controls] :is(
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
summary,
|
||||
textarea,
|
||||
[contenteditable="true"],
|
||||
[role="button"],
|
||||
iframe,
|
||||
[role="checkbox"],
|
||||
[role="combobox"],
|
||||
[role="link"],
|
||||
[role="menu"],
|
||||
[role="menuitem"],
|
||||
[role="option"],
|
||||
[role="searchbox"],
|
||||
[role="switch"],
|
||||
[role="tab"],
|
||||
[role="treeitem"],
|
||||
[tabindex]:not([tabindex="-1"]),
|
||||
[draggable="true"]
|
||||
) {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
html[data-window-controls] :is(.fixed, [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="tooltip"]) {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* A dialog's own top row, when it fills the window, is a row like any other. */
|
||||
html[data-window-controls] :is(.fixed, [role="dialog"]) [data-window-row] {
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* macOS: the traffic lights sit at the top of the rail, with room there only. */
|
||||
html[data-window-controls-left] [data-window-rail] {
|
||||
padding-top: var(--titlebar-row-height);
|
||||
}
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
scope: withBase("/"),
|
||||
display: "standalone",
|
||||
// The page draws up to the top of the window, next to the native controls
|
||||
// (components/layout/window-title-bar.tsx). `display` is the fallback.
|
||||
// (components/layout/window-controls-host.tsx). `display` is the fallback.
|
||||
display_override: ["window-controls-overlay", "standalone"],
|
||||
orientation: "portrait-primary",
|
||||
theme_color: themeColor,
|
||||
|
||||
@@ -442,6 +442,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Only for the window's controls: the sidebar says which page is open. */}
|
||||
<div data-window-only="" aria-hidden="true" className="h-14 box-content shrink-0 items-center border-b border-border px-6 text-sm font-semibold">
|
||||
<span className="truncate">{pageName ?? 'Admin'}</span>
|
||||
</div>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
{readOnlyBanner}
|
||||
|
||||
@@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div data-window-only="" data-window-row-right="" className="h-14 box-content shrink-0 items-center border-b border-border px-6">
|
||||
<TextLine className="text-sm" w="6rem" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
<AdminContentSkeleton page={page} />
|
||||
|
||||
@@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
|
||||
const views = text?.views;
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
|
||||
<div data-window-row-right="" className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
|
||||
<ToolbarIcon className="me-1">
|
||||
<Menu />
|
||||
</ToolbarIcon>
|
||||
|
||||
@@ -199,7 +199,7 @@ function FilesSidebarSkeleton() {
|
||||
function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
|
||||
const on = "bg-primary/10 text-primary";
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
|
||||
<div data-window-row-right="" className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
|
||||
{picker ? null : <ToolIcon icon={Menu} className="lg:hidden" />}
|
||||
<div className="flex-1 min-w-0 flex items-center text-sm">
|
||||
<span className="flex items-center px-1.5 py-1 h-7">
|
||||
|
||||
@@ -65,7 +65,7 @@ export function LoginSkeleton({ label, accessibilityButton = false, className }:
|
||||
)}
|
||||
>
|
||||
{/* The theme menu button: its icon, and the theme's name from sm up. */}
|
||||
<div aria-hidden="true" className="absolute top-5 end-5">
|
||||
<div aria-hidden="true" data-window-beside-controls="" className="absolute top-5 end-5">
|
||||
<span className="flex items-center gap-2 px-3 py-2 rounded-xl border text-sm bg-background/60 border-border/50">
|
||||
<Block className="w-4 h-4 rounded" />
|
||||
<TextLine className="hidden sm:block w-11" w="100%" />
|
||||
|
||||
@@ -411,7 +411,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
|
||||
const button = "h-8 w-20 rounded-md";
|
||||
return (
|
||||
<div className="bg-background flex-shrink-0 border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
<div data-window-row-right="" className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
{/* Phones: back, then icon-over-label actions. */}
|
||||
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
|
||||
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
|
||||
@@ -34,6 +34,8 @@ function TabStripSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-pane-strip=""
|
||||
data-window-row-right=""
|
||||
className="relative flex items-stretch h-9 bg-secondary px-1 overflow-hidden flex-shrink-0"
|
||||
style={STRIP_BORDER}
|
||||
>
|
||||
|
||||
@@ -163,7 +163,7 @@ function SettingsPage({ sections }: { sections: number }) {
|
||||
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
|
||||
function PhoneHeader({ list }: { list: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<div data-window-row-right="" className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<span className="grid place-items-center h-11 w-11 shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
@@ -270,6 +270,11 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean
|
||||
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
|
||||
<SettingsNav embedded={embedded} />
|
||||
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
|
||||
{embedded ? null : (
|
||||
<div data-window-only="" data-window-row-right="" className="items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0">
|
||||
<Line className="text-sm" bar="h-3.5" w="6rem" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<SettingsPage sections={3} />
|
||||
</div>
|
||||
|
||||
@@ -225,7 +225,7 @@ export function CalendarToolbar({
|
||||
<div className="border-b border-border">
|
||||
{/* ── MOBILE TOOLBAR ── */}
|
||||
{isMobile && (
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
<div data-window-top-row="" className="flex flex-col gap-1 px-2 py-2">
|
||||
{/* Row 1 while searching: the search box with a way back. */}
|
||||
{mobileSearchField ? (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1037,7 +1037,7 @@ export function EventModal({
|
||||
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div data-window-top-row="" className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
|
||||
@@ -222,7 +222,7 @@ export function TaskModal({
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div data-window-top-row="" className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 id={`${id}-heading`} className="text-sm font-semibold">
|
||||
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||
</h2>
|
||||
|
||||
@@ -99,7 +99,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
<div className={cn("relative flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
{/* The window's controls lie over this end, and its empty top is something to drag by. */}
|
||||
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
|
||||
<BookUser className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="text-sm">{t("detail.no_contact_selected")}</p>
|
||||
</div>
|
||||
@@ -139,7 +141,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div data-window-top-row="" className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "flex-wrap items-start justify-between")}>
|
||||
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
|
||||
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
|
||||
|
||||
@@ -704,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
// The app checks the fields itself and says what is wrong inline; the
|
||||
// browser's own bubble covered that message.
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col h-full bg-background">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div data-window-top-row="" className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("edit_title") : t("create_title")}
|
||||
</h2>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ContactGroupDetail({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div data-window-top-row="" className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function ContactGroupForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<div data-window-top-row="" className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("groups.edit") : t("groups.create")}
|
||||
</h2>
|
||||
|
||||
@@ -4783,6 +4783,7 @@ export function EmailComposer({
|
||||
className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11"
|
||||
label={tCommon('close')}
|
||||
data-testid="composer-close"
|
||||
data-own-window-close={ownWindow ? "" : undefined}
|
||||
>
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</IconButton>
|
||||
|
||||
@@ -4111,7 +4111,9 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
// in a split pane - the empty reading pane should always say what it is
|
||||
// and offer a way forward.
|
||||
return shell(
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
<div className="relative flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
{/* An empty reading pane's top is still the window's top, to drag by. */}
|
||||
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
|
||||
@@ -82,6 +82,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
|
||||
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
|
||||
the iOS status bar (#936). The insets are physical, so pl/pr. */}
|
||||
<div
|
||||
data-window-top-row=""
|
||||
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+var(--app-top-inset))] pb-3 bg-background/90 backdrop-blur border-b border-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-background', className)}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
|
||||
<div data-window-top-row="" className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
|
||||
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
|
||||
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
|
||||
<X />
|
||||
|
||||
@@ -596,6 +596,7 @@ export function NavigationRail({
|
||||
})()}
|
||||
|
||||
<nav
|
||||
data-window-rail=""
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
|
||||
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
|
||||
/**
|
||||
* The window's controls over the app, with no title bar row: see
|
||||
* lib/window-controls-overlay.ts. Renders nothing, and changes nothing on the
|
||||
* page, without an overlay. In the root layout, so that the pages outside [locale]
|
||||
* (admin, setup) get it too; there are no messages up there.
|
||||
*/
|
||||
export function WindowControlsHost() {
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
applyControlsArea(next);
|
||||
setArea(next);
|
||||
}), []);
|
||||
|
||||
const key = area ? `${area.x},${area.width},${area.height}` : "";
|
||||
useEffect(() => {
|
||||
if (!area) return;
|
||||
return watchWindowRows(area);
|
||||
// `key` stands for area, a new object at each change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
|
||||
|
||||
/**
|
||||
* Alt floats the menu's names over the top left for a moment, since a window with no
|
||||
* frame has no native bar. Each name opens the app's menu as a native one
|
||||
* (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it.
|
||||
*/
|
||||
export function WindowMenuBar() {
|
||||
const t = useTranslations("desktopUi.menu");
|
||||
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
// What to do once the names are on screen: open that menu, or null to focus the first name.
|
||||
const afterShow = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const openMenu = useCallback((id: string) => {
|
||||
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
|
||||
if (!button) return;
|
||||
const rect = button.getBoundingClientRect();
|
||||
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setEntries(null);
|
||||
previousFocus.current?.focus?.();
|
||||
previousFocus.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = getDesktopBridge()?.menu?.bar;
|
||||
if (!bar) return;
|
||||
return bar.onEvent((event) => {
|
||||
if (!event.visible) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
void bar.entries().then((list) => {
|
||||
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
afterShow.current = event.open ?? null;
|
||||
setEntries(list);
|
||||
});
|
||||
});
|
||||
}, [dismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = afterShow.current;
|
||||
if (!entries || next === undefined) return;
|
||||
afterShow.current = undefined;
|
||||
if (next) openMenu(next);
|
||||
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
|
||||
}, [entries, openMenu]);
|
||||
|
||||
if (!entries) return null;
|
||||
|
||||
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
|
||||
const at = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
buttons[at]?.click();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stripRef}
|
||||
role="menubar"
|
||||
aria-label={t("bar_label")}
|
||||
data-window-menu-bar=""
|
||||
onKeyDown={onStripKey}
|
||||
className="fixed start-2 top-2 z-[100002] flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 text-sm text-popover-foreground shadow-lg select-none"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
data-menu-id={entry.id}
|
||||
onClick={() => openMenu(entry.id)}
|
||||
className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
|
||||
import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
|
||||
/**
|
||||
* The strip above the app in a window without a native title bar: the part
|
||||
* the user drags the window by, with the window's title in small text. The
|
||||
* native window controls sit next to it, outside the area it is given. The
|
||||
* page roots leave this row empty through --titlebar-inset.
|
||||
*
|
||||
* Renders nothing, and changes nothing on the page, without an overlay. It
|
||||
* repeats the title for sighted users only, so it stays out of the
|
||||
* accessibility tree and out of the tab order.
|
||||
*
|
||||
* In the desktop app on Windows, Alt swaps the title for the menu's names, since a
|
||||
* window without a native frame has no native bar. Each name opens the app's menu
|
||||
* as a native one (src/main/menu.ts in the desktop repo).
|
||||
*/
|
||||
export function WindowTitleBar() {
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
// What to do once the names are on screen: open that menu, or null to focus the first name.
|
||||
const afterShow = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const openMenu = useCallback((id: string) => {
|
||||
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
|
||||
if (!button) return;
|
||||
const rect = button.getBoundingClientRect();
|
||||
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = getDesktopBridge()?.menu?.bar;
|
||||
if (!bar) return;
|
||||
return bar.onEvent((event) => {
|
||||
if (!event.visible) {
|
||||
setEntries(null);
|
||||
previousFocus.current?.focus?.();
|
||||
previousFocus.current = null;
|
||||
return;
|
||||
}
|
||||
void bar.entries().then((list) => {
|
||||
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
afterShow.current = event.open ?? null;
|
||||
setEntries(list);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const next = afterShow.current;
|
||||
if (!entries || next === undefined) return;
|
||||
afterShow.current = undefined;
|
||||
if (next) openMenu(next);
|
||||
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
|
||||
}, [entries, openMenu]);
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
applyTitlebarInset(next);
|
||||
setArea(next);
|
||||
}), []);
|
||||
|
||||
const visible = area !== null;
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const read = () => setTitle(document.title);
|
||||
read();
|
||||
// Each surface sets its own title, and Next puts <title> back on navigation.
|
||||
const observer = new MutationObserver(read);
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
|
||||
if (!area) return null;
|
||||
|
||||
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
|
||||
const at = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
buttons[at]?.click();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setEntries(null);
|
||||
previousFocus.current?.focus?.();
|
||||
previousFocus.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (entries) {
|
||||
return (
|
||||
<div
|
||||
ref={stripRef}
|
||||
role="menubar"
|
||||
data-window-title-bar=""
|
||||
data-window-menu-bar=""
|
||||
onKeyDown={onStripKey}
|
||||
className="window-title-bar fixed z-[100002] flex items-center gap-0.5 bg-background ps-1 pe-3 text-xs text-foreground select-none"
|
||||
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
data-menu-id={entry.id}
|
||||
onClick={() => openMenu(entry.id)}
|
||||
className="window-title-bar-item rounded px-2 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-window-title-bar=""
|
||||
className="window-title-bar fixed z-[100002] flex items-center bg-background ps-3 pe-3 text-xs text-muted-foreground select-none"
|
||||
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -348,6 +348,7 @@ export function useAppSidePanel({ view, context, dock = true, hidden = false, on
|
||||
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
|
||||
data-app-side-panel={overlay ? 'overlay' : 'docked'}
|
||||
data-app-side-panel-view={view}
|
||||
data-window-end-column=""
|
||||
className={cn(
|
||||
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
|
||||
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
|
||||
|
||||
@@ -142,6 +142,7 @@ export function PluginSidePanel() {
|
||||
{active && (
|
||||
<aside
|
||||
data-plugin-side-panel={active.pluginId}
|
||||
data-window-end-column=""
|
||||
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
|
||||
aria-label={active.title}
|
||||
>
|
||||
@@ -150,6 +151,7 @@ export function PluginSidePanel() {
|
||||
)}
|
||||
{inRail.length > 0 && <nav
|
||||
data-plugin-side-rail=""
|
||||
data-window-end-column=""
|
||||
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
|
||||
aria-label={t('mv_side_panel')}
|
||||
>
|
||||
|
||||
@@ -226,6 +226,7 @@ export function ProTabBar({
|
||||
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
role="tablist"
|
||||
data-pane-strip={paneId}
|
||||
data-window-top-row
|
||||
onDragLeave={handleStripDragLeave}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
|
||||
@@ -1208,8 +1208,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
row. The same element with or without the panel, so a page keeps
|
||||
what was typed when the panel starts or stops docking. */}
|
||||
<div className="flex-1 min-w-0 flex flex-col" data-settings-side-panel-column="">
|
||||
{sidePanel.docks && (
|
||||
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-settings-panel-row="">
|
||||
{(sidePanel.docks || !isEmbedded) && (
|
||||
<div data-window-only={sidePanel.docks ? undefined : ""} className={cn("items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0", sidePanel.docks ? "flex" : "hidden")} data-settings-panel-row="">
|
||||
{/* The page's h1 below names it for screen readers. */}
|
||||
<div className="flex-1 min-w-0 truncate text-sm font-semibold" aria-hidden="true">{activeTabLabel}</div>
|
||||
{sidePanel.toggle}
|
||||
|
||||
@@ -155,8 +155,8 @@ export function Dialog({
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-inset))] animate-in fade-in duration-150",
|
||||
size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-inset)]"
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-row-height))] animate-in fade-in duration-150",
|
||||
size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-row-height)]"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
|
||||
@@ -176,7 +176,7 @@ async function draftsCalled(subject: string): Promise<Array<{ id: string; subjec
|
||||
|
||||
test.beforeAll(() => requireEntEnv());
|
||||
|
||||
test.describe('the manifest and the title bar', () => {
|
||||
test.describe('the manifest and the window controls', () => {
|
||||
test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => {
|
||||
const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`);
|
||||
const manifest = await res.json();
|
||||
@@ -186,7 +186,8 @@ test.describe('the manifest and the title bar', () => {
|
||||
|
||||
test('without an overlay nothing changes', async ({ browser }) => {
|
||||
const page = await appPage(browser);
|
||||
await expect(page.locator('[data-window-title-bar]')).toHaveCount(0);
|
||||
await expect(page.locator('html[data-window-controls]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-window-row]')).toHaveCount(0);
|
||||
const padding = await page.evaluate(() => {
|
||||
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top };
|
||||
@@ -195,27 +196,24 @@ test.describe('the manifest and the title bar', () => {
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => {
|
||||
test('with one, the app runs to the top and its rightmost row leaves the controls free', async ({ browser }) => {
|
||||
const page = await appPage(browser, OVERLAY);
|
||||
const strip = page.locator('[data-window-title-bar]');
|
||||
await expect(strip).toBeVisible();
|
||||
const box = await strip.boundingBox();
|
||||
expect(box).toMatchObject({ x: 0, y: 0, height: 40 });
|
||||
expect(box!.width).toBe(1440 - 140);
|
||||
const region = await strip.evaluate((el) => {
|
||||
await expect(page.locator('html[data-window-controls]')).toHaveCount(1);
|
||||
const root = await page.evaluate(() => {
|
||||
const el = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(el).paddingTop, top: el.getBoundingClientRect().top };
|
||||
});
|
||||
expect(root).toEqual({ padding: '0px', top: 0 });
|
||||
const row = page.locator('[data-window-row-right]').first();
|
||||
await expect(row).toBeVisible();
|
||||
const seen = await row.evaluate((el) => {
|
||||
const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string };
|
||||
return style.appRegion ?? style.webkitAppRegion;
|
||||
return { region: style.appRegion ?? style.webkitAppRegion, padding: parseFloat(style.paddingRight), right: el.getBoundingClientRect().right };
|
||||
});
|
||||
expect(region).toBe('drag');
|
||||
// The window title, in small text.
|
||||
await expect(strip).toContainText(/\S/);
|
||||
const rail = await page.evaluate(() => {
|
||||
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(root).paddingTop };
|
||||
});
|
||||
expect(rail.padding).toBe('40px');
|
||||
await shot(page, 'pwa-windows-title-bar');
|
||||
// A full-page dialog keeps clear of it too.
|
||||
expect(seen.region).toBe('drag');
|
||||
expect(seen.right).toBe(1440);
|
||||
expect(seen.padding).toBeGreaterThanOrEqual(140);
|
||||
await shot(page, 'pwa-windows-controls');
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WindowControlsHost } from '@/components/layout/window-controls-host';
|
||||
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
|
||||
import { CONTROLS_ATTRIBUTE, CONTROLS_LEFT_ATTRIBUTE, ROW_ATTRIBUTE, ROW_LEFT_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, TITLEBAR_RIGHT_VAR, TITLEBAR_ROW_VAR, markWindowRows, readTitlebarArea, watchWindowRows } from '@/lib/window-controls-overlay';
|
||||
|
||||
class FakeOverlay extends EventTarget {
|
||||
visible = true;
|
||||
rect = { x: 0, y: 0, width: 867, height: 40 };
|
||||
getTitlebarAreaRect() {
|
||||
return this.rect;
|
||||
}
|
||||
change(next: Partial<FakeOverlay>) {
|
||||
Object.assign(this, next);
|
||||
this.dispatchEvent(new Event('geometrychange'));
|
||||
}
|
||||
}
|
||||
|
||||
function installOverlay(overlay: FakeOverlay | undefined) {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const rightInset = () => root.style.getPropertyValue(TITLEBAR_RIGHT_VAR);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
installOverlay(undefined);
|
||||
root.style.removeProperty(TITLEBAR_RIGHT_VAR);
|
||||
root.style.removeProperty(TITLEBAR_ROW_VAR);
|
||||
root.removeAttribute(CONTROLS_ATTRIBUTE);
|
||||
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
|
||||
});
|
||||
|
||||
describe('the menu strip in the desktop app', () => {
|
||||
type BarEvent = { visible: boolean; open?: string };
|
||||
|
||||
function withBar() {
|
||||
let send: (event: BarEvent) => void = () => {};
|
||||
const open = vi.fn();
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: {
|
||||
setState: () => {},
|
||||
bar: {
|
||||
entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }],
|
||||
open,
|
||||
onEvent: (listener: (event: BarEvent) => void) => {
|
||||
send = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return { open, send: (event: BarEvent) => send(event) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const overlayOn = () => {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }),
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay;
|
||||
});
|
||||
|
||||
it('Alt shows the names with the focus on the first, and Escape gives it back', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
const before = document.createElement('button');
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
const file = await screen.findByRole('menuitem', { name: 'File' });
|
||||
await waitFor(() => expect(document.activeElement).toBe(file));
|
||||
fireEvent.keyDown(file, { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' }));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it('a name opens that menu natively', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it('Alt and a letter opens that one right away', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true, open: 'file' }));
|
||||
await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything()));
|
||||
});
|
||||
|
||||
it('the shell can take it away again', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
await screen.findByRole('menubar');
|
||||
await act(async () => bar.send({ visible: false }));
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
|
||||
it('with no bridge there is none, and the title stays', async () => {
|
||||
overlayOn();
|
||||
render(<WindowMenuBar />);
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the window controls over the app', () => {
|
||||
const overlayAt = (x: number, width: number, height = 56) => {
|
||||
const fake = Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x, y: 0, width, height }) });
|
||||
installOverlay(fake as never);
|
||||
return fake;
|
||||
};
|
||||
|
||||
it('the root layout has no messages: the host there reads none, and the bar sits below', () => {
|
||||
const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8');
|
||||
expect(read('components/layout/window-controls-host.tsx')).not.toContain('next-intl');
|
||||
expect(read('app/(main)/layout.tsx')).toContain('<WindowControlsHost />');
|
||||
expect(read('app/(main)/layout.tsx')).not.toContain('<WindowMenuBar');
|
||||
for (const layout of ['app/(main)/[locale]/layout.tsx', 'app/(main)/admin/layout.tsx']) expect(read(layout)).toContain('<WindowMenuBar />');
|
||||
});
|
||||
|
||||
it('does nothing without the API', () => {
|
||||
installOverlay(undefined);
|
||||
render(<WindowControlsHost />);
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
expect(rightInset()).toBe('');
|
||||
expect(readTitlebarArea()).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when the overlay is there but hidden', () => {
|
||||
installOverlay(Object.assign(new EventTarget(), { visible: false, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 56 }) }) as never);
|
||||
render(<WindowControlsHost />);
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('draws no row of its own: no title, no strip, nothing on the page at all', () => {
|
||||
overlayAt(0, window.innerWidth - 138);
|
||||
document.title = 'A subject - Bulwark';
|
||||
const { container } = render(<WindowControlsHost />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
expect(document.body.textContent).not.toContain('A subject');
|
||||
});
|
||||
|
||||
it('says how much room the controls take, on the right', () => {
|
||||
overlayAt(0, window.innerWidth - 138);
|
||||
render(<WindowControlsHost />);
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(true);
|
||||
expect(rightInset()).toBe('138px');
|
||||
expect(root.style.getPropertyValue(TITLEBAR_ROW_VAR)).toBe('56px');
|
||||
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('and on the left, on macOS', () => {
|
||||
overlayAt(78, window.innerWidth - 78);
|
||||
render(<WindowControlsHost />);
|
||||
expect(rightInset()).toBe('0px');
|
||||
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('follows geometrychange, and takes everything back when the overlay goes', () => {
|
||||
const fake = overlayAt(0, window.innerWidth - 138);
|
||||
render(<WindowControlsHost />);
|
||||
act(() => {
|
||||
(fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 100, height: 56 });
|
||||
fake.dispatchEvent(new Event('geometrychange'));
|
||||
});
|
||||
expect(rightInset()).toBe('100px');
|
||||
act(() => {
|
||||
(fake as unknown as { visible: boolean }).visible = false;
|
||||
fake.dispatchEvent(new Event('geometrychange'));
|
||||
});
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
expect(rightInset()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('which rows hold the controls', () => {
|
||||
const W = window.innerWidth;
|
||||
const AREA = { x: 0, y: 0, width: W - 138, height: 56 };
|
||||
const row = (className: string, rect: { left: number; right: number; top?: number; height?: number }, parent: HTMLElement = document.body, attrs: Record<string, string> = {}) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = className;
|
||||
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
|
||||
const top = rect.top ?? 0;
|
||||
const height = rect.height ?? 56;
|
||||
el.getBoundingClientRect = () => ({ left: rect.left, right: rect.right, top, bottom: top + height, width: rect.right - rect.left, height, x: rect.left, y: top, toJSON: () => ({}) });
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('three panes: all are rows to drag by, and only the reader\'s toolbar makes room', () => {
|
||||
const sidebar = row('h-14', { left: 56, right: 300 });
|
||||
const list = row('h-14', { left: 300, right: 700 });
|
||||
const reader = row('h-14', { left: 700, right: W });
|
||||
markWindowRows(AREA);
|
||||
for (const el of [sidebar, list, reader]) expect(el.hasAttribute(ROW_ATTRIBUTE)).toBe(true);
|
||||
expect(sidebar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('with no reader, the list\'s header is the one', () => {
|
||||
const list = row('h-14', { left: 300, right: W });
|
||||
markWindowRows(AREA);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('a row further down is no row of the window\'s', () => {
|
||||
const lower = row('h-14', { left: 0, right: W, top: 100 });
|
||||
markWindowRows(AREA);
|
||||
expect(lower.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
|
||||
expect(lower.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a tall box at the top is a pane, unless it says otherwise', () => {
|
||||
const pane = row('h-14', { left: 0, right: W, height: 600 });
|
||||
const header = row('', { left: 0, right: W, height: 72 }, document.body, { 'data-window-top-row': '' });
|
||||
markWindowRows(AREA);
|
||||
expect(pane.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
|
||||
expect(header.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('the outermost box of a row takes it, not one inside it too', () => {
|
||||
const outer = row('h-14', { left: 0, right: W });
|
||||
const inner = row('h-14', { left: 0, right: W }, outer);
|
||||
markWindowRows(AREA);
|
||||
expect(outer.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
expect(inner.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('macOS: the row under the traffic lights makes room on the left', () => {
|
||||
const sidebar = row('h-14', { left: 56, right: 300 });
|
||||
const list = row('h-14', { left: 300, right: W });
|
||||
markWindowRows({ x: 78, y: 0, width: W - 78, height: 56 });
|
||||
expect(sidebar.hasAttribute(ROW_LEFT_ATTRIBUTE)).toBe(true);
|
||||
expect(list.hasAttribute(ROW_LEFT_ATTRIBUTE)).toBe(false);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a mark goes when the row no longer lies there', () => {
|
||||
const reader = row('h-14', { left: 700, right: W });
|
||||
markWindowRows(AREA);
|
||||
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
reader.getBoundingClientRect = () => ({ left: 500, right: 800, top: 0, bottom: 56, width: 300, height: 56, x: 700, y: 0, toJSON: () => ({}) });
|
||||
markWindowRows(AREA);
|
||||
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a pane that finishes changing width, with nothing else changing, is noticed', async () => {
|
||||
const list = row('h-14', { left: 300, right: 700 });
|
||||
const stop = watchWindowRows(AREA);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
list.getBoundingClientRect = () => ({ left: 300, right: W, top: 0, bottom: 56, width: W - 300, height: 56, x: 300, y: 0, toJSON: () => ({}) });
|
||||
document.body.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('a row that appears is marked before the next paint', async () => {
|
||||
const stop = watchWindowRows(AREA);
|
||||
const dialogRow = row('h-12', { left: 0, right: W });
|
||||
// A mutation callback, then a microtask: both ahead of any paint.
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(dialogRow.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page roots and the stylesheet', () => {
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
|
||||
|
||||
it('keeps no row: the inset is 0, and the page roots are on the safe area alone', () => {
|
||||
expect(css).toMatch(/--titlebar-inset:\s*0px/);
|
||||
expect(css).toMatch(/--app-top-inset:\s*env\(safe-area-inset-top, 0px\)/);
|
||||
expect(css).not.toMatch(/--titlebar-inset:\s*env/);
|
||||
});
|
||||
|
||||
it('takes the room from env(), so it holds from the first paint', () => {
|
||||
expect(css).toContain('--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))');
|
||||
expect(css).toContain('--titlebar-left-inset: env(titlebar-area-x, 0px)');
|
||||
});
|
||||
|
||||
it('does not key on the display-mode media query, which is false in Electron', () => {
|
||||
expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
|
||||
});
|
||||
|
||||
it('drags by the app\'s own rows, and leaves what\'s clickable alone', () => {
|
||||
expect(css).toMatch(/:is\(\[data-window-row\], \[data-window-rail\]\) \{\s*app-region: drag;/);
|
||||
expect(css).toMatch(/\[contenteditable="true"\][\s\S]*?app-region: no-drag;/);
|
||||
expect(css).not.toContain('.window-title-bar');
|
||||
});
|
||||
|
||||
function tsxFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (name === '__tests__' || name === 'node_modules') return [];
|
||||
return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
|
||||
const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
|
||||
.filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each skeleton the same top inset as the page it stands in for', () => {
|
||||
for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
|
||||
expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WindowTitleBar } from '@/components/layout/window-title-bar';
|
||||
import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay';
|
||||
|
||||
class FakeOverlay extends EventTarget {
|
||||
visible = true;
|
||||
rect = { x: 0, y: 0, width: 867, height: 40 };
|
||||
getTitlebarAreaRect() {
|
||||
return this.rect;
|
||||
}
|
||||
change(next: Partial<FakeOverlay>) {
|
||||
Object.assign(this, next);
|
||||
this.dispatchEvent(new Event('geometrychange'));
|
||||
}
|
||||
}
|
||||
|
||||
function installOverlay(overlay: FakeOverlay | undefined) {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
|
||||
}
|
||||
|
||||
const inset = () => document.documentElement.style.getPropertyValue(TITLEBAR_INSET_VAR);
|
||||
const strip = () => document.querySelector<HTMLElement>('[data-window-title-bar]');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
installOverlay(undefined);
|
||||
document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
});
|
||||
|
||||
describe('the menu strip in the desktop app', () => {
|
||||
type BarEvent = { visible: boolean; open?: string };
|
||||
|
||||
function withBar() {
|
||||
let send: (event: BarEvent) => void = () => {};
|
||||
const open = vi.fn();
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: {
|
||||
setState: () => {},
|
||||
bar: {
|
||||
entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }],
|
||||
open,
|
||||
onEvent: (listener: (event: BarEvent) => void) => {
|
||||
send = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return { open, send: (event: BarEvent) => send(event) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const overlayOn = () => {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }),
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay;
|
||||
});
|
||||
|
||||
it('Alt shows the names with the focus on the first, and Escape gives it back', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
const before = document.createElement('button');
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
const file = await screen.findByRole('menuitem', { name: 'File' });
|
||||
await waitFor(() => expect(document.activeElement).toBe(file));
|
||||
fireEvent.keyDown(file, { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' }));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it('a name opens that menu natively', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it('Alt and a letter opens that one right away', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true, open: 'file' }));
|
||||
await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything()));
|
||||
});
|
||||
|
||||
it('the shell can take it away again', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
await screen.findByRole('menubar');
|
||||
await act(async () => bar.send({ visible: false }));
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
|
||||
it('with no bridge there is none, and the title stays', async () => {
|
||||
overlayOn();
|
||||
render(<WindowTitleBar />);
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the title bar', () => {
|
||||
it('shows nothing and touches nothing without the API', () => {
|
||||
installOverlay(undefined);
|
||||
expect(readTitlebarArea()).toBeNull();
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('shows nothing when the overlay is there but hidden', () => {
|
||||
const overlay = new FakeOverlay();
|
||||
overlay.visible = false;
|
||||
installOverlay(overlay);
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('draws the strip next to the controls and pushes the page down', () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
document.title = 'Inbox - Mail';
|
||||
render(<WindowTitleBar />);
|
||||
const bar = strip()!;
|
||||
expect(bar.style.width).toBe('867px');
|
||||
expect(bar.style.height).toBe('40px');
|
||||
expect(bar.textContent).toBe('Inbox - Mail');
|
||||
expect(bar.classList.contains('window-title-bar')).toBe(true);
|
||||
expect(inset()).toBe('40px');
|
||||
});
|
||||
|
||||
it('stays out of the accessibility tree and the tab order', () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(strip()!.querySelector('a, button, input, [tabindex]')).toBeNull();
|
||||
});
|
||||
|
||||
it('follows geometrychange, and takes everything back when the overlay goes', () => {
|
||||
const overlay = new FakeOverlay();
|
||||
installOverlay(overlay);
|
||||
render(<WindowTitleBar />);
|
||||
act(() => overlay.change({ rect: { x: 72, y: 0, width: 500, height: 32 } }));
|
||||
expect(strip()!.style.left).toBe('72px');
|
||||
expect(inset()).toBe('32px');
|
||||
act(() => overlay.change({ visible: false }));
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('follows the window title as the surface changes it', async () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
document.title = 'First';
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => {
|
||||
document.title = 'Second';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(strip()!.textContent).toBe('Second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page roots and the stylesheet', () => {
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
|
||||
|
||||
it('is 0 without an overlay, and exactly the old safe area then', () => {
|
||||
expect(css).toContain('--titlebar-inset: env(titlebar-area-height, 0px);');
|
||||
expect(css).toContain('--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));');
|
||||
});
|
||||
|
||||
it('does not key on the display-mode media query, which is false in Electron', () => {
|
||||
expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
|
||||
});
|
||||
|
||||
it('makes the strip the part that drags the window', () => {
|
||||
expect(css).toMatch(/\.window-title-bar\s*\{[^}]*app-region:\s*drag/);
|
||||
});
|
||||
|
||||
function tsxFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (name === '__tests__' || name === 'node_modules') return [];
|
||||
return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
|
||||
const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
|
||||
.filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each skeleton the same top inset as the page it stands in for', () => {
|
||||
for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
|
||||
expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -4,5 +4,6 @@
|
||||
* frame. Nothing happens in a browser, which has no bridge.
|
||||
*/
|
||||
export function desktopBootScript(): string {
|
||||
return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');`;
|
||||
return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');
|
||||
if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', '');`;
|
||||
}
|
||||
|
||||
+114
-12
@@ -3,15 +3,20 @@
|
||||
* titleBarOverlay window) draws the page up to the top of the window and lays
|
||||
* the native minimise, maximise and close buttons over it.
|
||||
*
|
||||
* There's no title bar row. The app's own columns run to the top of the window,
|
||||
* the window's controls sit in the top row of whichever column is there, and that
|
||||
* row leaves them room. Its empty parts are what the window is dragged by, and a
|
||||
* double-click on them maximises, as on a title bar.
|
||||
*
|
||||
* Which row that is depends on the page and the width: the reader's toolbar, the
|
||||
* list's header when there's no reader, a dialog that fills the window. So this
|
||||
* looks: an app row that sits at the top of the window gets marked, and CSS in
|
||||
* app/globals.css does the rest. A row that starts the window's top row is an h-14
|
||||
* (or h-12, h-16) box all over the app; anything else says `data-window-top-row`.
|
||||
*
|
||||
* Asks navigator.windowControlsOverlay, never the media query:
|
||||
* `(display-mode: window-controls-overlay)` is false in Electron with
|
||||
* titleBarOverlay, while the API and env(titlebar-area-*) work there.
|
||||
*
|
||||
* app/globals.css gives --titlebar-inset its first value from
|
||||
* env(titlebar-area-height, 0px), so the page has the right top padding from
|
||||
* the first paint, before any script runs. This module keeps the variable in
|
||||
* step with the API afterwards, which is what a browser or bridge without the
|
||||
* env() values relies on.
|
||||
*/
|
||||
|
||||
export interface TitlebarArea {
|
||||
@@ -26,7 +31,19 @@ interface OverlayLike extends EventTarget {
|
||||
getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export const TITLEBAR_INSET_VAR = '--titlebar-inset';
|
||||
export const TITLEBAR_RIGHT_VAR = '--titlebar-right-inset';
|
||||
export const TITLEBAR_LEFT_VAR = '--titlebar-left-inset';
|
||||
export const TITLEBAR_ROW_VAR = '--titlebar-row-height';
|
||||
|
||||
/** The window has its controls over the page. */
|
||||
export const CONTROLS_ATTRIBUTE = 'data-window-controls';
|
||||
/** They're at the left (macOS): the rail makes room at its top. */
|
||||
export const CONTROLS_LEFT_ATTRIBUTE = 'data-window-controls-left';
|
||||
/** This row is at the top of the window: its empty parts drag. */
|
||||
export const ROW_ATTRIBUTE = 'data-window-row';
|
||||
/** The window's controls are over this end of it. */
|
||||
export const ROW_RIGHT_ATTRIBUTE = 'data-window-row-right';
|
||||
export const ROW_LEFT_ATTRIBUTE = 'data-window-row-left';
|
||||
|
||||
function overlay(): OverlayLike | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
@@ -43,13 +60,28 @@ export function readTitlebarArea(): TitlebarArea | null {
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
/** What the controls take of each side, from the strip they leave. */
|
||||
export function controlsInsets(area: TitlebarArea, viewportWidth: number): { left: number; right: number } {
|
||||
return { left: Math.max(0, area.x), right: Math.max(0, viewportWidth - area.x - area.width) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets --titlebar-inset for this area. With no area the inline value goes, so
|
||||
* the stylesheet's value (0 without an overlay) is what counts again.
|
||||
* Says where the controls are, for the CSS. With no area the inline values go, so
|
||||
* what the stylesheet has from env() (nothing without an overlay) counts again.
|
||||
*/
|
||||
export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
|
||||
if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`);
|
||||
else root.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
|
||||
if (!area) {
|
||||
for (const name of [TITLEBAR_RIGHT_VAR, TITLEBAR_LEFT_VAR, TITLEBAR_ROW_VAR]) root.style.removeProperty(name);
|
||||
root.removeAttribute(CONTROLS_ATTRIBUTE);
|
||||
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
|
||||
return;
|
||||
}
|
||||
const { left, right } = controlsInsets(area, root.ownerDocument.defaultView?.innerWidth ?? 0);
|
||||
root.style.setProperty(TITLEBAR_RIGHT_VAR, `${right}px`);
|
||||
root.style.setProperty(TITLEBAR_LEFT_VAR, `${left}px`);
|
||||
root.style.setProperty(TITLEBAR_ROW_VAR, `${area.height}px`);
|
||||
root.setAttribute(CONTROLS_ATTRIBUTE, '');
|
||||
root.toggleAttribute(CONTROLS_LEFT_ATTRIBUTE, left > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,3 +98,73 @@ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void)
|
||||
current.addEventListener('geometrychange', onChange);
|
||||
return () => current.removeEventListener('geometrychange', onChange);
|
||||
}
|
||||
|
||||
const ROW_CANDIDATES = '[data-window-top-row], [data-composer-header], header, div.h-14, div.min-h-14, div.h-12, div.h-16, div.min-h-16';
|
||||
/** No toolbar is taller than this: past it, it's a tile or a pane that happens to sit at the top. */
|
||||
const MAX_ROW_HEIGHT = 96;
|
||||
|
||||
/**
|
||||
* Marks the rows at the top of the window, for what lies over them, and returns them.
|
||||
* One pass, cheap: a dozen rects.
|
||||
*/
|
||||
export function markWindowRows(area: TitlebarArea, doc: Document = document): HTMLElement[] {
|
||||
const viewportWidth = doc.defaultView?.innerWidth ?? 0;
|
||||
const { left, right } = controlsInsets(area, viewportWidth);
|
||||
const seen = new Set<HTMLElement>();
|
||||
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(ROW_CANDIDATES))) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const explicit = el.hasAttribute('data-window-top-row');
|
||||
const fits = rect.top <= 1 && rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT);
|
||||
// The outermost box of a row: an inner one sits inside its padding already.
|
||||
const nested = fits && [...seen].some((outer) => outer.contains(el));
|
||||
if (!fits || nested) {
|
||||
el.removeAttribute(ROW_ATTRIBUTE);
|
||||
el.removeAttribute(ROW_RIGHT_ATTRIBUTE);
|
||||
el.removeAttribute(ROW_LEFT_ATTRIBUTE);
|
||||
continue;
|
||||
}
|
||||
seen.add(el);
|
||||
el.setAttribute(ROW_ATTRIBUTE, '');
|
||||
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, right > 0 && rect.right > viewportWidth - right);
|
||||
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, left > 0 && rect.left < left);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps those marks right as the page changes, before the next paint. Mutation and
|
||||
* resize callbacks both run ahead of painting, so a row never shows without its room.
|
||||
* A pane can also change width with no change to the page, in a transition or by
|
||||
* being dragged, so the rows themselves are watched too, and transitions' ends.
|
||||
*/
|
||||
export function watchWindowRows(area: TitlebarArea, doc: Document = document): () => void {
|
||||
let queued = false;
|
||||
let watched = new Set<HTMLElement>();
|
||||
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => queue());
|
||||
const run = () => {
|
||||
queued = false;
|
||||
const rows = new Set(markWindowRows(area, doc));
|
||||
if (!resize) return;
|
||||
for (const row of watched) if (!rows.has(row)) resize.unobserve(row);
|
||||
for (const row of rows) if (!watched.has(row)) resize.observe(row);
|
||||
watched = rows;
|
||||
};
|
||||
function queue() {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
queueMicrotask(run);
|
||||
}
|
||||
run();
|
||||
const mutations = new MutationObserver((records) => {
|
||||
// Our own marks changing say nothing new.
|
||||
if (records.some((record) => record.type !== 'attributes' || !record.attributeName?.startsWith('data-window-'))) queue();
|
||||
});
|
||||
mutations.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'hidden', 'style'] });
|
||||
resize?.observe(doc.documentElement);
|
||||
doc.addEventListener('transitionend', queue, true);
|
||||
return () => {
|
||||
mutations.disconnect();
|
||||
resize?.disconnect();
|
||||
doc.removeEventListener('transitionend', queue, true);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "الإعدادات…",
|
||||
"about": "حول Bulwark",
|
||||
"from_plugins": "المزيد من الإضافات…",
|
||||
"pick_gone": "هذه القائمة قديمة. افتحها مرة أخرى وحاول ثانية."
|
||||
"pick_gone": "هذه القائمة قديمة. افتحها مرة أخرى وحاول ثانية.",
|
||||
"bar_label": "القائمة"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "إضافة «{word}» إلى القاموس",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Configuració…",
|
||||
"about": "Quant a Bulwark",
|
||||
"from_plugins": "Més dels connectors…",
|
||||
"pick_gone": "Aquest menú estava desfasat. Torneu-lo a obrir i proveu-ho una altra vegada."
|
||||
"pick_gone": "Aquest menú estava desfasat. Torneu-lo a obrir i proveu-ho una altra vegada.",
|
||||
"bar_label": "Menú"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Afegeix «{word}» al diccionari",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Nastavení…",
|
||||
"about": "O aplikaci Bulwark",
|
||||
"from_plugins": "Více z doplňků…",
|
||||
"pick_gone": "Tato nabídka byla zastaralá. Otevřete ji znovu a zkuste to ještě jednou."
|
||||
"pick_gone": "Tato nabídka byla zastaralá. Otevřete ji znovu a zkuste to ještě jednou.",
|
||||
"bar_label": "Nabídka"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Přidat „{word}“ do slovníku",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Indstillinger…",
|
||||
"about": "Om Bulwark",
|
||||
"from_plugins": "Mere fra plugins…",
|
||||
"pick_gone": "Den menu var forældet. Åbn den igen, og prøv en gang til."
|
||||
"pick_gone": "Den menu var forældet. Åbn den igen, og prøv en gang til.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Føj “{word}” til ordbogen",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Einstellungen…",
|
||||
"about": "Über Bulwark",
|
||||
"from_plugins": "Mehr von Plugins…",
|
||||
"pick_gone": "Dieses Menü war veraltet. Öffnen Sie es erneut und versuchen Sie es noch einmal."
|
||||
"pick_gone": "Dieses Menü war veraltet. Öffnen Sie es erneut und versuchen Sie es noch einmal.",
|
||||
"bar_label": "Menü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "„{word}“ zum Wörterbuch hinzufügen",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Settings…",
|
||||
"about": "About Bulwark",
|
||||
"from_plugins": "More from plugins…",
|
||||
"pick_gone": "That menu was out of date. Open it again and try once more."
|
||||
"pick_gone": "That menu was out of date. Open it again and try once more.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Add \"{word}\" to dictionary",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Configuración…",
|
||||
"about": "Acerca de Bulwark",
|
||||
"from_plugins": "Más de los complementos…",
|
||||
"pick_gone": "Ese menú estaba desactualizado. Ábralo de nuevo e inténtelo otra vez."
|
||||
"pick_gone": "Ese menú estaba desactualizado. Ábralo de nuevo e inténtelo otra vez.",
|
||||
"bar_label": "Menú"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Añadir «{word}» al diccionario",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Seaded…",
|
||||
"about": "Teave Bulwarki kohta",
|
||||
"from_plugins": "Veel pistikprogrammidelt…",
|
||||
"pick_gone": "See menüü oli aegunud. Avage see uuesti ja proovige veel kord."
|
||||
"pick_gone": "See menüü oli aegunud. Avage see uuesti ja proovige veel kord.",
|
||||
"bar_label": "Menüü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Lisa „{word}” sõnastikku",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "تنظیمات…",
|
||||
"about": "درباره Bulwark",
|
||||
"from_plugins": "موارد بیشتر از افزونهها…",
|
||||
"pick_gone": "این منو قدیمی شده بود. دوباره آن را باز کنید و یک بار دیگر امتحان کنید."
|
||||
"pick_gone": "این منو قدیمی شده بود. دوباره آن را باز کنید و یک بار دیگر امتحان کنید.",
|
||||
"bar_label": "منو"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "افزودن «{word}» به واژهنامه",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Paramètres…",
|
||||
"about": "À propos de Bulwark",
|
||||
"from_plugins": "Plus d’options des modules…",
|
||||
"pick_gone": "Ce menu n’était plus à jour. Rouvrez-le et réessayez."
|
||||
"pick_gone": "Ce menu n’était plus à jour. Rouvrez-le et réessayez.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Ajouter « {word} » au dictionnaire",
|
||||
|
||||
@@ -6605,7 +6605,8 @@
|
||||
"settings": "הגדרות…",
|
||||
"about": "מידע על Bulwark",
|
||||
"from_plugins": "עוד מהתוספים…",
|
||||
"pick_gone": "התפריט הזה היה מיושן. פתחו אותו שוב ונסו פעם נוספת."
|
||||
"pick_gone": "התפריט הזה היה מיושן. פתחו אותו שוב ונסו פעם נוספת.",
|
||||
"bar_label": "תפריט"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "הוספת “{word}” למילון",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Beállítások…",
|
||||
"about": "A Bulwark névjegye",
|
||||
"from_plugins": "Továbbiak a bővítményektől…",
|
||||
"pick_gone": "Ez a menü elavult volt. Nyissa meg újra, és próbálja meg még egyszer."
|
||||
"pick_gone": "Ez a menü elavult volt. Nyissa meg újra, és próbálja meg még egyszer.",
|
||||
"bar_label": "Menü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "„{word}” hozzáadása a szótárhoz",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Impostazioni…",
|
||||
"about": "Informazioni su Bulwark",
|
||||
"from_plugins": "Altro dai plugin…",
|
||||
"pick_gone": "Questo menu non era aggiornato. Riaprilo e riprova."
|
||||
"pick_gone": "Questo menu non era aggiornato. Riaprilo e riprova.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Aggiungi «{word}» al dizionario",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "設定…",
|
||||
"about": "Bulwarkについて",
|
||||
"from_plugins": "プラグインのその他の項目…",
|
||||
"pick_gone": "このメニューは古くなっています。もう一度開いて、やり直してください。"
|
||||
"pick_gone": "このメニューは古くなっています。もう一度開いて、やり直してください。",
|
||||
"bar_label": "メニュー"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "「{word}」を辞書に追加",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "설정…",
|
||||
"about": "Bulwark 정보",
|
||||
"from_plugins": "플러그인 더 보기…",
|
||||
"pick_gone": "이 메뉴는 오래된 것입니다. 다시 열어 한 번 더 시도해 주세요."
|
||||
"pick_gone": "이 메뉴는 오래된 것입니다. 다시 열어 한 번 더 시도해 주세요.",
|
||||
"bar_label": "메뉴"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "“{word}”을(를) 사전에 추가",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Iestatījumi…",
|
||||
"about": "Par Bulwark",
|
||||
"from_plugins": "Vairāk no spraudņiem…",
|
||||
"pick_gone": "Šī izvēlne bija novecojusi. Atveriet to vēlreiz un mēģiniet vēlreiz."
|
||||
"pick_gone": "Šī izvēlne bija novecojusi. Atveriet to vēlreiz un mēģiniet vēlreiz.",
|
||||
"bar_label": "Izvēlne"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Pievienot “{word}” vārdnīcai",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Тохиргоо…",
|
||||
"about": "Bulwark-ийн тухай",
|
||||
"from_plugins": "Залгаснуудын бусад зүйл…",
|
||||
"pick_gone": "Энэ цэс хуучирсан байна. Дахин нээгээд нэг удаа оролдоно уу."
|
||||
"pick_gone": "Энэ цэс хуучирсан байна. Дахин нээгээд нэг удаа оролдоно уу.",
|
||||
"bar_label": "Цэс"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "“{word}”-ийг толь бичигт нэмэх",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Innstillinger…",
|
||||
"about": "Om Bulwark",
|
||||
"from_plugins": "Mer fra utvidelser…",
|
||||
"pick_gone": "Den menyen var utdatert. Åpne den på nytt og prøv igjen."
|
||||
"pick_gone": "Den menyen var utdatert. Åpne den på nytt og prøv igjen.",
|
||||
"bar_label": "Meny"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Legg til «{word}» i ordboken",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Instellingen…",
|
||||
"about": "Over Bulwark",
|
||||
"from_plugins": "Meer van plug-ins…",
|
||||
"pick_gone": "Dat menu was verouderd. Open het opnieuw en probeer het nog eens."
|
||||
"pick_gone": "Dat menu was verouderd. Open het opnieuw en probeer het nog eens.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Voeg ‘{word}’ toe aan woordenboek",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Ustawienia…",
|
||||
"about": "O programie Bulwark",
|
||||
"from_plugins": "Więcej z wtyczek…",
|
||||
"pick_gone": "To menu było nieaktualne. Otwórz je ponownie i spróbuj jeszcze raz."
|
||||
"pick_gone": "To menu było nieaktualne. Otwórz je ponownie i spróbuj jeszcze raz.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Dodaj „{word}” do słownika",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Definições…",
|
||||
"about": "Acerca do Bulwark",
|
||||
"from_plugins": "Mais dos plugins…",
|
||||
"pick_gone": "Esse menu estava desatualizado. Abra-o de novo e tente outra vez."
|
||||
"pick_gone": "Esse menu estava desatualizado. Abra-o de novo e tente outra vez.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Adicionar «{word}» ao dicionário",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Setări…",
|
||||
"about": "Despre Bulwark",
|
||||
"from_plugins": "Mai multe de la pluginuri…",
|
||||
"pick_gone": "Meniul era depășit. Deschideți-l din nou și încercați încă o dată."
|
||||
"pick_gone": "Meniul era depășit. Deschideți-l din nou și încercați încă o dată.",
|
||||
"bar_label": "Meniu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Adăugare „{word}” în dicționar",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Настройки…",
|
||||
"about": "О программе Bulwark",
|
||||
"from_plugins": "Ещё из плагинов…",
|
||||
"pick_gone": "Это меню устарело. Откройте его снова и повторите попытку."
|
||||
"pick_gone": "Это меню устарело. Откройте его снова и повторите попытку.",
|
||||
"bar_label": "Меню"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Добавить «{word}» в словарь",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Nastavenia…",
|
||||
"about": "O aplikácii Bulwark",
|
||||
"from_plugins": "Viac z doplnkov…",
|
||||
"pick_gone": "Táto ponuka bola zastaraná. Otvorte ju znova a skúste to ešte raz."
|
||||
"pick_gone": "Táto ponuka bola zastaraná. Otvorte ju znova a skúste to ešte raz.",
|
||||
"bar_label": "Ponuka"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Pridať „{word}“ do slovníka",
|
||||
|
||||
@@ -6612,7 +6612,8 @@
|
||||
"settings": "การตั้งค่า…",
|
||||
"about": "เกี่ยวกับ Bulwark",
|
||||
"from_plugins": "เพิ่มเติมจากปลั๊กอิน…",
|
||||
"pick_gone": "เมนูนี้เก่าแล้ว โปรดเปิดใหม่แล้วลองอีกครั้ง"
|
||||
"pick_gone": "เมนูนี้เก่าแล้ว โปรดเปิดใหม่แล้วลองอีกครั้ง",
|
||||
"bar_label": "เมนู"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "เพิ่ม “{word}” ในพจนานุกรม",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Ayarlar…",
|
||||
"about": "Bulwark Hakkında",
|
||||
"from_plugins": "Eklentilerden Daha Fazlası…",
|
||||
"pick_gone": "Bu menü eskimişti. Yeniden açın ve bir kez daha deneyin."
|
||||
"pick_gone": "Bu menü eskimişti. Yeniden açın ve bir kez daha deneyin.",
|
||||
"bar_label": "Menü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "“{word}” sözcüğünü sözlüğe ekle",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "Налаштування…",
|
||||
"about": "Про Bulwark",
|
||||
"from_plugins": "Більше від плагінів…",
|
||||
"pick_gone": "Це меню застаріло. Відкрийте його знову й спробуйте ще раз."
|
||||
"pick_gone": "Це меню застаріло. Відкрийте його знову й спробуйте ще раз.",
|
||||
"bar_label": "Меню"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Додати «{word}» до словника",
|
||||
|
||||
@@ -6616,7 +6616,8 @@
|
||||
"settings": "Cài đặt…",
|
||||
"about": "Giới thiệu về Bulwark",
|
||||
"from_plugins": "Thêm từ các plugin…",
|
||||
"pick_gone": "Menu này đã cũ. Hãy mở lại và thử một lần nữa."
|
||||
"pick_gone": "Menu này đã cũ. Hãy mở lại và thử một lần nữa.",
|
||||
"bar_label": "Trình đơn"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Thêm “{word}” vào từ điển",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "設定…",
|
||||
"about": "關於 Bulwark",
|
||||
"from_plugins": "更多外掛程式選項…",
|
||||
"pick_gone": "此選單已過期。請重新開啟後再試一次。"
|
||||
"pick_gone": "此選單已過期。請重新開啟後再試一次。",
|
||||
"bar_label": "選單"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "將「{word}」加入字典",
|
||||
|
||||
@@ -6604,7 +6604,8 @@
|
||||
"settings": "设置…",
|
||||
"about": "关于 Bulwark",
|
||||
"from_plugins": "更多插件选项…",
|
||||
"pick_gone": "该菜单已过期。请重新打开后再试一次。"
|
||||
"pick_gone": "该菜单已过期。请重新打开后再试一次。",
|
||||
"bar_label": "菜单"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "将“{word}”添加到词典",
|
||||
|
||||
Reference in New Issue
Block a user