fix: keep notices and the offline bar clear of the window's controls, give each a close, and put a pane's close first where it would sit beside the window's

This commit is contained in:
Linus Rath
2026-11-04 07:02:53 +01:00
parent 111a505078
commit e29a6553e6
48 changed files with 483 additions and 103 deletions
-12
View File
@@ -612,18 +612,6 @@ html[data-window-controls] [data-own-window-close] {
display: none;
}
/*
* A pane's own close right beside the window's read as a second close for the
* window. Where the row ends at the controls, it goes to the row's other end.
* Geometry, not direction: the controls stay at the right on Windows in RTL too.
*/
html[data-window-controls]:not([dir="rtl"]) [data-window-row-right] > [data-pane-close],
html[data-window-controls][dir="rtl"] [data-window-row-left] > [data-pane-close] {
order: -1;
margin-inline-start: -0.25rem;
margin-inline-end: 0;
}
/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
html[data-window-controls] [data-toast-container][data-dodging] {
top: calc(var(--titlebar-row-height) + 0.75rem) !important;
+23 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations, useLocale } from "next-intl";
import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
@@ -278,6 +279,9 @@ export function EventModal({
const labelIdBase = useId();
const labelFor = (key: string) => `${labelIdBase}-${key}`;
const tCommon = useTranslations("common");
// The header's close goes to its start when the window's own close is at its end.
const [header, setHeader] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(header);
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
@@ -1031,13 +1035,20 @@ export function EventModal({
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = paint(getEventColor(event, eventCalendar));
const viewClose = (
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground">
<X />
</IconButton>
);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
{/* Color accent bar */}
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Header */}
<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 ref={setHeader} data-window-top-row="" className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
{closeFirst && viewClose}
<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 }} />
@@ -1052,9 +1063,7 @@ export function EventModal({
<MoreVertical />
</IconButton>
)}
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground" data-pane-close="">
<X />
</IconButton>
{!closeFirst && viewClose}
</div>
{/* Content */}
@@ -1245,15 +1254,20 @@ export function EventModal({
);
}
const editClose = (
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground">
<X />
</IconButton>
);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
<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">
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-6 py-4 border-b border-border flex-shrink-0">
{closeFirst && editClose}
<h2 className="min-w-0 flex-1 text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground" data-pane-close="">
<X />
</IconButton>
{!closeFirst && editClose}
</div>
<div className="flex-1 overflow-y-auto">
+14 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -61,6 +62,9 @@ export function TaskModal({
}: TaskModalProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
// The header's close goes to its start when the window's own close is at its end.
const [header, setHeader] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(header);
const id = useId();
const isEdit = !!task;
const titleRef = useRef<HTMLInputElement>(null);
@@ -212,6 +216,12 @@ export function TaskModal({
}
}, [requestClose, handleSave]);
const close = (
<IconButton label={tCommon("close")} onClick={requestClose}>
<X />
</IconButton>
);
return (
// The desktop side panel is a dialog named by its title, like the event
// editor; on a phone the full-screen wrapper around it is.
@@ -222,13 +232,12 @@ export function TaskModal({
onKeyDown={handleKeyDown}
>
{/* Header */}
<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">
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-4 py-3 border-b border-border">
{closeFirst && close}
<h2 id={`${id}-heading`} className="min-w-0 flex-1 text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose} data-pane-close="">
<X />
</IconButton>
{!closeFirst && close}
</div>
{/* Body */}
+14 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
import { Button } from "@/components/ui/button";
@@ -220,6 +221,9 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
// The header's close goes to its start when the window's own close is at its end.
const [header, setHeader] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(header);
// Ids that tie each visible label to its field.
const fieldIdBase = useId();
const fieldId = (key: string) => `${fieldIdBase}-${key}`;
@@ -700,17 +704,22 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
const close = (
<IconButton label={t("close")} onClick={onCancel}>
<X />
</IconButton>
);
return (
// 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 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">
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-6 py-4 border-b border-border flex-shrink-0">
{closeFirst && close}
<h2 className="min-w-0 flex-1 text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<IconButton label={t("close")} onClick={onCancel} data-pane-close="">
<X />
</IconButton>
{!closeFirst && close}
</div>
<div className="flex-1 overflow-y-auto">
+23 -13
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { useMvComposeWindow } from "@/components/email/mv-compose-context";
import { useLocale, useTranslations } from "next-intl";
import { isServerOutage } from "@/lib/jmap/offline-hooks";
@@ -2003,6 +2004,10 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveSessionKey]);
// The header's close goes to its start when the window's own close is at its end.
const [composerHeader, setComposerHeader] = useState<HTMLDivElement | null>(null);
const closeBesideControls = useCloseBesideControls(composerHeader);
// Auto-save draft to server on page close (best-effort)
const ownWindowRef = useRef(ownWindow);
ownWindowRef.current = ownWindow;
@@ -4516,6 +4521,21 @@ export function EmailComposer({
}
};
// Close at the top end (R4). On a phone it moves to the start, as the phone's
// mail apps have it, with Send at the end. Beside the window's own close it
// goes to the start too.
const composerCloseButton = (
<IconButton
onClick={() => handleClose()}
className={cn(closeBesideControls ? "-ms-2" : "-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>
);
const popOutButton = (className: string, iconClassName: string) => popOut && (
<button
type="button"
@@ -4816,7 +4836,8 @@ export function EmailComposer({
</button>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
<div ref={setComposerHeader} className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
{closeBesideControls && composerCloseButton}
{/* The left panel's button at the start, the right one's at the end: where each panel is. */}
{leftPanelToggle && <div className="hidden md:flex -ms-2">{leftPanelToggle}</div>}
<div className="flex-1 min-w-0 flex items-center gap-3">
@@ -4888,18 +4909,7 @@ export function EmailComposer({
)}
{rightPanelToggle}
{popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")}
{/* Close at the top end (R4). On a phone it moves to the start, as
the phone's mail apps have it, with Send at the end. */}
<IconButton
onClick={() => handleClose()}
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}
data-pane-close=""
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
{!closeBesideControls && composerCloseButton}
</div>
)}
+13 -5
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
export interface PreviewNeighbours {
/** The previous and next previewable file of the folder, if any. */
@@ -36,6 +37,14 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
// Tab stays in the preview and the focus goes back to the file that was
// opened when it closes. Escape is the listener below.
const shellRef = useFocusTrap({ isActive: true, restoreFocus: true });
// Its own close goes to the bar's start when the window's close is at its end.
const [bar, setBar] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(bar);
const close = (
<IconButton label={t("close_preview")} onClick={onClose} className="shrink-0">
<X />
</IconButton>
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -82,10 +91,12 @@ 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
ref={setBar}
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()}
>
{closeFirst && close}
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
<div className="flex items-center gap-1 shrink-0 ms-auto">
{neighbours && (
@@ -105,10 +116,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
</IconButton>
)}
</div>
{/* Its own child of the bar, so it can move to the bar's other end beside the window's controls. */}
<IconButton label={t("close_preview")} onClick={onClose} className="shrink-0 -ms-1" data-pane-close="">
<X />
</IconButton>
{!closeFirst && close}
</div>
{children}
</div>
+13 -5
View File
@@ -1,10 +1,12 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { X } from '@/components/icons';
import { IconButton } from '@/components/ui/icon-button';
import { cn } from '@/lib/utils';
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
interface InlineAppViewProps {
apps: InlineAppState[];
@@ -16,15 +18,21 @@ interface InlineAppViewProps {
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
const tCommon = useTranslations('common');
const activeApp = apps.find((a) => a.id === activeAppId);
const [header, setHeader] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(header);
const close = (
<IconButton label={tCommon("close")} onClick={onClose} className="shrink-0 text-muted-foreground">
<X />
</IconButton>
);
return (
<div className={cn('flex flex-col h-full bg-background', className)}>
{/* Header bar */}
<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" data-pane-close="">
<X />
</IconButton>
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
{closeFirst && close}
<h3 className="min-w-0 flex-1 text-sm font-medium truncate">{activeApp?.name}</h3>
{!closeFirst && close}
</div>
{/* Iframes - active one visible, rest hidden but alive */}
<div className="flex-1 relative">
+18 -10
View File
@@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform";
import { Avatar } from "@/components/ui/avatar";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels";
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
@@ -146,8 +147,23 @@ function PanelHeader({ title, subtitle, href, onClose, children }: {
}) {
const t = useTranslations("sidebar");
const tCommon = useTranslations("common");
// Right under the window's own close, at the window's end: this one goes to the start.
const [head, setHead] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(head);
const close = (
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon("close")}
aria-label={tCommon("close")}
>
<X className="h-[18px] w-[18px]" />
</button>
);
return (
<div className="flex items-start gap-1 px-4 pt-4 pb-2">
<div ref={setHead} data-window-pane-head="" className={cn("flex items-start gap-1 pt-4 pb-2", closeFirst ? "ps-2 pe-4" : "px-4")}>
{closeFirst && close}
<div className="min-w-0 flex-1">
<div className={cn(subtitle ? "text-[0.6875rem] font-medium text-muted-foreground" : "text-[1.375rem] leading-7")}>{title}</div>
{subtitle && <div className="text-sm font-medium text-primary">{subtitle}</div>}
@@ -161,15 +177,7 @@ function PanelHeader({ title, subtitle, href, onClose, children }: {
>
<ExternalLink className="h-[18px] w-[18px]" />
</Link>
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon("close")}
aria-label={tCommon("close")}
>
<X className="h-[18px] w-[18px]" />
</button>
{!closeFirst && close}
</div>
);
}
@@ -150,6 +150,23 @@ describe('the offline banner', () => {
expect(screen.getByTestId('offline-outbox-row').textContent).toContain('Quarterly');
});
it('can be hidden, and comes back when what it says changes', async () => {
const { OfflineBanner } = await import('../offline-banner');
sessionStorage.clear();
useOfflineStore.setState({ enabled: ['acc'], browserOffline: true, outbox: [] });
const { unmount } = render(<OfflineBanner />);
fireEvent.click(screen.getByRole('button', { name: 'hide_offline_notice' }));
expect(screen.queryByTestId('offline-banner')).toBeNull();
// Another page of the app, the same state: still hidden.
unmount();
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner')).toBeNull();
// A message now waits: that's news.
act(() => useOfflineStore.setState({ outbox: [{ id: 2, account: 'acc', createdAt: Date.now(), subject: 'Later', to: ['b@example.org'], status: 'waiting' }] }));
expect(screen.getByTestId('offline-banner')).toBeInTheDocument();
sessionStorage.clear();
});
it('stays away for an account that doesn\'t keep mail here', async () => {
const { OfflineBanner } = await import('../offline-banner');
useOfflineStore.setState({ enabled: [], browserOffline: true, outbox: [] });
+43 -2
View File
@@ -2,8 +2,10 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { CloudOff } from '@/components/icons';
import { CloudOff, X } from '@/components/icons';
import { Dialog } from '@/components/ui/dialog';
import { IconButton } from '@/components/ui/icon-button';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import { IS_LITE } from '@/lib/lite';
import { serverKey, useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
@@ -15,7 +17,20 @@ import { OutboxList } from './outbox-list';
* reached), and how many messages wait in the outbox. The count opens the
* outbox in a dialog, which works without a network (a page change might
* not). Shown only for an account that keeps mail here.
*
* It can be hidden until what it says changes (going offline again, another
* message waiting), for this session of the window.
*/
const HIDDEN_KEY = 'bulwark.offlineBanner.hidden';
function hiddenFor(): string | null {
try {
return sessionStorage.getItem(HIDDEN_KEY);
} catch {
return null;
}
}
export function OfflineBanner() {
const t = useTranslations('offline');
const account = useAuthStore((s) => s.activeAccountId);
@@ -29,15 +44,38 @@ export function OfflineBanner() {
const waitingDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status !== 'refused').length);
const tConflicts = useTranslations('offlineConflicts');
const tDrafts = useTranslations('offlineDrafts');
const tChrome = useTranslations('windowChrome');
const [open, setOpen] = useState(false);
const [hidden, setHidden] = useState<string | null>(hiddenFor);
const [banner, setBanner] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(banner);
const offline = browserOffline || serverDown;
const says = `${browserOffline ? 'offline' : serverDown ? 'down' : 'online'}|${waiting}|${conflicts}|${waitingDrafts}|${refusedDrafts}`;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null;
const hide = () => {
try {
sessionStorage.setItem(HIDDEN_KEY, says);
} catch {
// Hidden for now all the same.
}
setHidden(says);
};
const close = (
<IconButton label={tChrome('hide_offline_notice')} onClick={hide} className="-my-1 shrink-0" data-testid="offline-banner-hide">
<X />
</IconButton>
);
return (
<>
{hidden !== says && (
<div
ref={setBanner}
data-testid="offline-banner"
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
data-window-top-row=""
className="flex shrink-0 items-center gap-2 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
>
{closeFirst && close}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
{offline && (
<span className="flex min-w-0 items-start gap-2" role="status">
<CloudOff className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
@@ -88,7 +126,10 @@ export function OfflineBanner() {
{tDrafts('banner_refused', { count: refusedDrafts })}
</button>
)}
</div>
{!closeFirst && close}
</div>
)}
<Dialog isOpen={open} onClose={() => setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
<OutboxList heading={false} onOpenDraft={() => setOpen(false)} />
</Dialog>
+24 -15
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, type MouseEvent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { AlertCircle, AlertTriangle, Info, X } from '@/components/icons';
import { usePolicyRefresh } from '@/hooks/use-policy-refresh';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import {
announcementActive,
announcementKey,
@@ -93,6 +94,9 @@ export function OrgAnnouncement() {
const announcement = usePolicyStore((s) => s.policy.organisation?.announcement);
const accountId = useAuthStore((s) => s.activeAccountId);
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
// At the window's top, its close goes to the start when the window's own close is at its end.
const [row, setRow] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(row);
const text = announcement ? announcementText(announcement, locale) : '';
const now = useClock(text ? nextAnnouncementChange(announcement, Date.now()) : null);
@@ -113,14 +117,33 @@ export function OrgAnnouncement() {
const Icon = ICONS[severity];
const severityLabel = t(`severity_${severity}`);
const dismiss = announcement.dismissible && (
<button
type="button"
onClick={() => {
dismissAnnouncement(announcement, accountId);
setDismissedKey(`${accountId}|${key}`);
}}
aria-label={t('dismiss')}
title={t('dismiss')}
data-testid="org-announcement-dismiss"
className={cn(closeFirst ? '-ms-2' : '-me-2', 'inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md hover:bg-foreground/10 focus-visible:outline-2 focus-visible:outline-offset-[-2px]')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
);
return (
<div
ref={setRow}
role="region"
aria-label={t('label')}
data-testid="org-announcement"
data-severity={severity}
data-window-top-row=""
className={cn('flex shrink-0 items-start gap-2 border-b px-3 text-sm sm:px-4', STYLES[severity])}
>
{closeFirst && dismiss}
{firstShow && <LiveOnce text={`${severityLabel}: ${text}`} assertive={severity === 'critical'} />}
<Icon className="mt-3 h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 whitespace-pre-line break-words py-2.5 leading-snug [overflow-wrap:anywhere]">
@@ -141,21 +164,7 @@ export function OrgAnnouncement() {
<span key={index}>{part.text}</span>
))}
</p>
{announcement.dismissible && (
<button
type="button"
onClick={() => {
dismissAnnouncement(announcement, accountId);
setDismissedKey(`${accountId}|${key}`);
}}
aria-label={t('dismiss')}
title={t('dismiss')}
data-testid="org-announcement-dismiss"
className="-me-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md hover:bg-foreground/10 focus-visible:outline-2 focus-visible:outline-offset-[-2px]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
{!closeFirst && dismiss}
</div>
);
}
+18 -10
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { useTranslations } from 'next-intl';
import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
@@ -102,19 +103,26 @@ export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
}) {
const tCommon = useTranslations('common');
const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
// Right under the window's own close, at the window's end: this one goes to the start.
const [head, setHead] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(head);
const close = (
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
);
return (
<>
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
<div ref={setHead} data-window-pane-head="" className={cn("flex shrink-0 items-center gap-1 pt-3 pb-1", closeFirst ? "ps-2 pe-4" : "ps-4 pe-2")}>
{closeFirst && close}
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
{!closeFirst && close}
</div>
<div
data-plugin-slot="side-panel"
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { ToastItem } from '../toast';
const closeOf = (container: HTMLElement) => container.querySelector<HTMLElement>('[data-toast-close]')!;
describe('the close button of a toast', () => {
it('shows all the time on a toast that stays until closed', () => {
const { container } = render(
<ToastItem onClose={vi.fn()} toast={{ id: 's', type: 'info', title: 'Mail is kept on this device', duration: 0, action: { label: 'Open settings', onClick: () => {} } }} />,
);
expect(closeOf(container).className).toContain('opacity-100');
expect(closeOf(container).className).not.toContain('opacity-0');
});
it('shows on hover or focus on a toast that goes by itself', () => {
vi.useFakeTimers();
const { container } = render(<ToastItem onClose={vi.fn()} toast={{ id: 't', type: 'success', title: 'Sent', duration: 5000 }} />);
expect(closeOf(container).className).toContain('opacity-0');
vi.useRealTimers();
});
});
+5 -2
View File
@@ -305,7 +305,8 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
</div>
)}
{/* Close button. Phones swipe the toast away instead. */}
{/* Close button. Phones swipe the toast away instead. A toast that stays
until closed shows it all the time: that's the only way it goes. */}
<button
type="button"
data-toast-close=""
@@ -319,7 +320,9 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
"flex-shrink-0 p-1 rounded-md transition-all max-sm:hidden",
TOUCH_HIT_AREA,
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
!toast.duration || toast.duration <= 0
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
)}
>
<X className="w-3.5 h-3.5" />
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useState } from "react";
import { CLOSE_MOVES_ATTRIBUTE, CONTROLS_ATTRIBUTE } from "@/lib/window-controls-overlay";
/**
* Whether this row's own close would sit right beside the window's close, in an
* installed app or the desktop app: then it goes to the row's other end, in the
* page's order too, so Tab reaches it where it's seen. The row says so through
* the mark lib/window-controls-overlay.ts keeps on it; without window controls
* over the page this is always false.
*/
export function useCloseBesideControls(row: HTMLElement | null): boolean {
const [beside, setBeside] = useState(false);
useEffect(() => {
if (!row) {
setBeside(false);
return;
}
const root = row.ownerDocument.documentElement;
const read = () => setBeside(root.hasAttribute(CONTROLS_ATTRIBUTE) && row.hasAttribute(CLOSE_MOVES_ATTRIBUTE));
read();
if (typeof MutationObserver === "undefined") return;
const marks = new MutationObserver(read);
marks.observe(row, { attributes: true, attributeFilter: [CLOSE_MOVES_ATTRIBUTE] });
marks.observe(root, { attributes: true, attributeFilter: [CONTROLS_ATTRIBUTE] });
return () => marks.disconnect();
}, [row]);
return beside;
}
@@ -0,0 +1,47 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { CLOSE_MOVES_ATTRIBUTE, CONTROLS_ATTRIBUTE } from '@/lib/window-controls-overlay';
const root = document.documentElement;
afterEach(() => {
cleanup();
root.removeAttribute(CONTROLS_ATTRIBUTE);
});
function buttonsOfHeader(): HTMLElement[] {
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
return Array.from(header.querySelectorAll('button'));
}
describe('a pane\'s close beside the window\'s', () => {
const apps = [{ id: 'notes', name: 'Notes', url: 'https://notes.example.org' }] as never;
it('is at the end of its row without window controls', () => {
render(<InlineAppView apps={apps} activeAppId="notes" onClose={() => {}} />);
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
expect(header.lastElementChild?.tagName).toBe('BUTTON');
});
it('moves to the start, in the page\'s order too, when the row ends at the window\'s controls', async () => {
root.setAttribute(CONTROLS_ATTRIBUTE, '');
render(<InlineAppView apps={apps} activeAppId="notes" onClose={() => {}} />);
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
act(() => header.setAttribute(CLOSE_MOVES_ATTRIBUTE, ''));
await waitFor(() => expect(header.firstElementChild?.tagName).toBe('BUTTON'));
// Tab reaches it first, where it's seen.
expect(buttonsOfHeader()[0]).toBe(screen.getByRole('button', { name: 'close' }));
// The window narrows and the row no longer ends there: back to the end.
act(() => header.removeAttribute(CLOSE_MOVES_ATTRIBUTE));
await waitFor(() => expect(header.lastElementChild?.tagName).toBe('BUTTON'));
});
it('stays at the end when the window has no controls over the page, whatever the row says', async () => {
render(<InlineAppView apps={apps} activeAppId="notes" onClose={() => {}} />);
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
act(() => header.setAttribute(CLOSE_MOVES_ATTRIBUTE, ''));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(header.lastElementChild?.tagName).toBe('BUTTON');
});
});
+48 -8
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ROW_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, markWindowRows } from '@/lib/window-controls-overlay';
import { CLOSE_MOVES_ATTRIBUTE, PANE_HEAD_ATTRIBUTE, ROW_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, markWindowRows } from '@/lib/window-controls-overlay';
/** The controls take the right 138px of the window, 56px high. */
const AREA = { x: 0, y: 0, width: 1024 - 138, height: 56 };
@@ -67,14 +67,42 @@ describe('rows under the window\'s controls', () => {
const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8');
describe('a pane\'s close next to the window\'s', () => {
it('moves to the other end of the row, on the side away from the controls', () => {
const css = read('app/globals.css');
expect(css).toMatch(/html\[data-window-controls\]:not\(\[dir="rtl"\]\) \[data-window-row-right\] > \[data-pane-close\]/);
expect(css).toMatch(/html\[data-window-controls\]\[dir="rtl"\] \[data-window-row-left\] > \[data-pane-close\]/);
describe("a close at the end of a row beside the window's", () => {
it('is marked on a top row whose end has the controls', () => {
const toolbar = row();
box(toolbar, 0, 56);
markWindowRows(AREA);
expect(toolbar.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(true);
});
it('is marked on every pane that can end up at the window\'s top right', () => {
it("isn't, right to left, where the row ends at the left and the controls are at the right", () => {
document.documentElement.dir = 'rtl';
try {
const toolbar = row();
box(toolbar, 0, 56);
markWindowRows(AREA);
expect(toolbar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
expect(toolbar.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(false);
} finally {
document.documentElement.dir = '';
}
});
it("is marked on a side panel's head right under the controls, and not further down", () => {
const head = document.createElement('div');
head.setAttribute(PANE_HEAD_ATTRIBUTE, '');
document.body.appendChild(head);
box(head, 60, 40, 700, 1000);
markWindowRows(AREA);
expect(head.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(true);
// No room is made in it: it's below the controls.
expect(head.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
box(head, 200, 40, 700, 1000);
markWindowRows(AREA);
expect(head.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(false);
});
it("is asked by every pane that can end up at the window's top right", () => {
for (const file of [
'components/email/email-composer.tsx',
'components/layout/inline-app-view.tsx',
@@ -82,12 +110,24 @@ describe('a pane\'s close next to the window\'s', () => {
'components/files/preview-shell.tsx',
'components/calendar/task-modal.tsx',
'components/contacts/contact-form.tsx',
'components/offline/offline-banner.tsx',
'components/org/org-announcement.tsx',
'components/plugins/plugin-side-panels.tsx',
'components/layout/mv-side-panel.tsx',
]) {
expect(read(file), file).toContain('data-pane-close');
expect(read(file), file).toContain('useCloseBesideControls(');
}
});
});
describe('notices at the top', () => {
it('toasts that step out of the way stay below the controls', () => {
expect(read('app/globals.css')).toMatch(/html\[data-window-controls\] \[data-toast-container\]\[data-dodging\]\s*\{[^}]*--titlebar-row-height/);
});
it("the offline bar and the organisation's notice are rows that make room for the controls", () => {
for (const file of ['components/offline/offline-banner.tsx', 'components/org/org-announcement.tsx']) {
expect(read(file), file).toContain('data-window-top-row=""');
}
});
});
+21 -2
View File
@@ -44,6 +44,14 @@ 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';
/**
* The end of this row or pane head, where its own close usually goes, is at the
* window's controls: a close there would read as a second one for the window, so
* it goes to the other end (hooks/use-close-beside-controls.ts).
*/
export const CLOSE_MOVES_ATTRIBUTE = 'data-window-close-moves';
/** The head of a pane at the window's end, a row below the controls: its close can sit right under the window's. */
export const PANE_HEAD_ATTRIBUTE = 'data-window-pane-head';
function overlay(): OverlayLike | null {
if (typeof navigator === 'undefined') return null;
@@ -110,6 +118,8 @@ const MAX_ROW_HEIGHT = 96;
export function markWindowRows(area: TitlebarArea, doc: Document = document): HTMLElement[] {
const viewportWidth = doc.defaultView?.innerWidth ?? 0;
const { left, right } = controlsInsets(area, viewportWidth);
// A row's own close is at its end: the right, or the left in a right-to-left page.
const rtl = doc.documentElement.dir === 'rtl';
const seen = new Set<HTMLElement>();
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(ROW_CANDIDATES))) {
const rect = el.getBoundingClientRect();
@@ -127,12 +137,21 @@ export function markWindowRows(area: TitlebarArea, doc: Document = document): HT
el.removeAttribute(ROW_ATTRIBUTE);
el.removeAttribute(ROW_RIGHT_ATTRIBUTE);
el.removeAttribute(ROW_LEFT_ATTRIBUTE);
el.removeAttribute(CLOSE_MOVES_ATTRIBUTE);
continue;
}
seen.add(el);
const onRight = right > 0 && rect.right > viewportWidth - right;
const onLeft = left > 0 && rect.left < left;
el.toggleAttribute(ROW_ATTRIBUTE, atTop);
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, right > 0 && rect.right > viewportWidth - right);
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, left > 0 && rect.left < left);
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, onRight);
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, onLeft);
el.toggleAttribute(CLOSE_MOVES_ATTRIBUTE, rtl ? onLeft : onRight);
}
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(`[${PANE_HEAD_ATTRIBUTE}]`))) {
const rect = el.getBoundingClientRect();
const near = rect.height > 0 && rect.top < area.height * 2;
el.toggleAttribute(CLOSE_MOVES_ATTRIBUTE, near && (rtl ? left > 0 && rect.left < left : right > 0 && rect.right > viewportWidth - right));
}
return [...seen];
}
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "خيارات أخرى"
},
"windowChrome": {
"hide_offline_notice": "إخفاء إشعار عدم الاتصال"
},
"shell": {
"resize": "تغيير الحجم",
"update_available": "يتوفر تحديث",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Més opcions"
},
"windowChrome": {
"hide_offline_notice": "Amaga l'avís de fora de línia"
},
"shell": {
"resize": "Canvia la mida",
"update_available": "Hi ha una actualització",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Další možnosti"
},
"windowChrome": {
"hide_offline_notice": "Skrýt upozornění na režim offline"
},
"shell": {
"resize": "Změnit velikost",
"update_available": "Je k dispozici aktualizace",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Flere muligheder"
},
"windowChrome": {
"hide_offline_notice": "Skjul offline-besked"
},
"shell": {
"resize": "Tilpas størrelse",
"update_available": "Opdatering tilgængelig",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Weitere Optionen"
},
"windowChrome": {
"hide_offline_notice": "Offline-Hinweis ausblenden"
},
"shell": {
"resize": "Größe ändern",
"update_available": "Update verfügbar",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "More options"
},
"windowChrome": {
"hide_offline_notice": "Hide offline notice"
},
"shell": {
"resize": "Resize",
"update_available": "Update available",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Más opciones"
},
"windowChrome": {
"hide_offline_notice": "Ocultar aviso de sin conexión"
},
"shell": {
"resize": "Cambiar tamaño",
"update_available": "Actualización disponible",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Rohkem valikuid"
},
"windowChrome": {
"hide_offline_notice": "Peida võrguühenduseta teade"
},
"shell": {
"resize": "Muuda suurust",
"update_available": "Saadaval on värskendus",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "گزینه‌های بیشتر"
},
"windowChrome": {
"hide_offline_notice": "پنهان کردن اعلان آفلاین"
},
"shell": {
"resize": "تغییر اندازه",
"update_available": "به‌روزرسانی موجود است",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Plus d'options"
},
"windowChrome": {
"hide_offline_notice": "Masquer l'avis hors ligne"
},
"shell": {
"resize": "Redimensionner",
"update_available": "Mise à jour disponible",
+3
View File
@@ -6418,6 +6418,9 @@
"top_bar": {
"more": "אפשרויות נוספות"
},
"windowChrome": {
"hide_offline_notice": "הסתרת ההודעה על מצב לא מקוון"
},
"shell": {
"resize": "שינוי גודל",
"update_available": "יש עדכון",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "További lehetőségek"
},
"windowChrome": {
"hide_offline_notice": "Offline értesítés elrejtése"
},
"shell": {
"resize": "Átméretezés",
"update_available": "Frissítés érhető el",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Altre opzioni"
},
"windowChrome": {
"hide_offline_notice": "Nascondi l'avviso offline"
},
"shell": {
"resize": "Ridimensiona",
"update_available": "Aggiornamento disponibile",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "その他のオプション"
},
"windowChrome": {
"hide_offline_notice": "オフラインのお知らせを非表示"
},
"shell": {
"resize": "サイズを変更",
"update_available": "アップデートがあります",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "추가 옵션"
},
"windowChrome": {
"hide_offline_notice": "오프라인 알림 숨기기"
},
"shell": {
"resize": "크기 조정",
"update_available": "업데이트가 있습니다",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Citas iespējas"
},
"windowChrome": {
"hide_offline_notice": "Paslēpt bezsaistes paziņojumu"
},
"shell": {
"resize": "Mainīt izmēru",
"update_available": "Pieejams atjauninājums",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Бусад сонголт"
},
"windowChrome": {
"hide_offline_notice": "Офлайн мэдэгдлийг нуух"
},
"shell": {
"resize": "Хэмжээ өөрчлөх",
"update_available": "Шинэчлэл гарсан",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Flere alternativer"
},
"windowChrome": {
"hide_offline_notice": "Skjul frakoblet-melding"
},
"shell": {
"resize": "Endre størrelse",
"update_available": "Oppdatering tilgjengelig",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Meer opties"
},
"windowChrome": {
"hide_offline_notice": "Offlinemelding verbergen"
},
"shell": {
"resize": "Formaat wijzigen",
"update_available": "Update beschikbaar",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Więcej opcji"
},
"windowChrome": {
"hide_offline_notice": "Ukryj powiadomienie o trybie offline"
},
"shell": {
"resize": "Zmień rozmiar",
"update_available": "Dostępna aktualizacja",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Mais opções"
},
"windowChrome": {
"hide_offline_notice": "Ocultar aviso de sem ligação"
},
"shell": {
"resize": "Redimensionar",
"update_available": "Atualização disponível",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Mai multe opțiuni"
},
"windowChrome": {
"hide_offline_notice": "Ascunde notificarea offline"
},
"shell": {
"resize": "Redimensionează",
"update_available": "Actualizare disponibilă",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Другие действия"
},
"windowChrome": {
"hide_offline_notice": "Скрыть уведомление об офлайн-режиме"
},
"shell": {
"resize": "Изменить размер",
"update_available": "Доступно обновление",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Ďalšie možnosti"
},
"windowChrome": {
"hide_offline_notice": "Skryť upozornenie na režim offline"
},
"shell": {
"resize": "Zmeniť veľkosť",
"update_available": "K dispozícii je aktualizácia",
+3
View File
@@ -44,6 +44,9 @@
"top_bar": {
"more": "ตัวเลือกเพิ่มเติม"
},
"windowChrome": {
"hide_offline_notice": "ซ่อนประกาศออฟไลน์"
},
"shell": {
"resize": "ปรับขนาด",
"update_available": "มีอัปเดต",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Diğer seçenekler"
},
"windowChrome": {
"hide_offline_notice": "Çevrimdışı bildirimini gizle"
},
"shell": {
"resize": "Yeniden boyutlandır",
"update_available": "Güncelleme var",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "Інші дії"
},
"windowChrome": {
"hide_offline_notice": "Сховати сповіщення про офлайн-режим"
},
"shell": {
"resize": "Змінити розмір",
"update_available": "Доступне оновлення",
+3
View File
@@ -6429,6 +6429,9 @@
"top_bar": {
"more": "Nhiều lựa chọn hơn"
},
"windowChrome": {
"hide_offline_notice": "Ẩn thông báo ngoại tuyến"
},
"shell": {
"resize": "Thay đổi kích thước",
"update_available": "Đã có bản cập nhật",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "更多選項"
},
"windowChrome": {
"hide_offline_notice": "隱藏離線提示"
},
"shell": {
"resize": "調整大小",
"update_available": "有可用的更新",
+3
View File
@@ -6417,6 +6417,9 @@
"top_bar": {
"more": "更多选项"
},
"windowChrome": {
"hide_offline_notice": "隐藏离线提示"
},
"shell": {
"resize": "调整大小",
"update_available": "有可用更新",