diff --git a/app/globals.css b/app/globals.css index ffdf51c34..97dd1c3a8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 0d92ac0e0..4532896ac 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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(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 = ( + + + + ); + return (
{/* Color accent bar */}
{/* Header */} -
+
+ {closeFirst && viewClose}
@@ -1052,9 +1063,7 @@ export function EventModal({ )} - - - + {!closeFirst && viewClose}
{/* Content */} @@ -1245,15 +1254,20 @@ export function EventModal({ ); } + const editClose = ( + + + + ); + return (
-
-

+
+ {closeFirst && editClose} +

{isEdit ? t("events.edit") : t("events.create")}

- - - + {!closeFirst && editClose}
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index b6c111697..0c2d5a45c 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -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(null); + const closeFirst = useCloseBesideControls(header); const id = useId(); const isEdit = !!task; const titleRef = useRef(null); @@ -212,6 +216,12 @@ export function TaskModal({ } }, [requestClose, handleSave]); + const close = ( + + + + ); + 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 */} -
-

+
+ {closeFirst && close} +

{isEdit ? t("tasks.edit") : t("tasks.create")}

- - - + {!closeFirst && close}
{/* Body */} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index b5390e778..fac3205b0 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -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(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 = ( + + + + ); + return ( // The app checks the fields itself and says what is wrong inline; the // browser's own bubble covered that message.
-
-

+
+ {closeFirst && close} +

{isEditing ? t("edit_title") : t("create_title")}

- - - + {!closeFirst && close}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0826f1069..6fd3b448e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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(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 = ( + 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} + > + + + ); + const popOutButton = (className: string, iconClassName: string) => popOut && (
) : ( -
+
+ {closeBesideControls && composerCloseButton} {/* The left panel's button at the start, the right one's at the end: where each panel is. */} {leftPanelToggle &&
{leftPanelToggle}
}
@@ -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. */} - 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="" - > - - + {!closeBesideControls && composerCloseButton}
)} diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index 652e73dba..e349033c6 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -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(null); + const closeFirst = useCloseBesideControls(bar); + const close = ( + + + + ); 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. */}
e.stopPropagation()} > + {closeFirst && close}

{name}

{neighbours && ( @@ -105,10 +116,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c )}
- {/* Its own child of the bar, so it can move to the bar's other end beside the window's controls. */} - - - + {!closeFirst && close}
{children}
diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx index 926971b98..f584561d1 100644 --- a/components/layout/inline-app-view.tsx +++ b/components/layout/inline-app-view.tsx @@ -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(null); + const closeFirst = useCloseBesideControls(header); + const close = ( + + + + ); return (
{/* Header bar */} -
-

{activeApp?.name}

- - - +
+ {closeFirst && close} +

{activeApp?.name}

+ {!closeFirst && close}
{/* Iframes - active one visible, rest hidden but alive */}
diff --git a/components/layout/mv-side-panel.tsx b/components/layout/mv-side-panel.tsx index 40d8ba4dd..f33c11b93 100644 --- a/components/layout/mv-side-panel.tsx +++ b/components/layout/mv-side-panel.tsx @@ -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(null); + const closeFirst = useCloseBesideControls(head); + const close = ( + + ); return ( -
+
+ {closeFirst && close}
{title}
{subtitle &&
{subtitle}
} @@ -161,15 +177,7 @@ function PanelHeader({ title, subtitle, href, onClose, children }: { > - + {!closeFirst && close}
); } diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 66e545e40..e7f87f5af 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -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(); + 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(); + 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: [] }); diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx index 7706303ea..4efe20b44 100644 --- a/components/offline/offline-banner.tsx +++ b/components/offline/offline-banner.tsx @@ -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(hiddenFor); + const [banner, setBanner] = useState(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 = ( + + + + ); return ( <> + {hidden !== says && (
+ {closeFirst && close} +
{offline && (
+ {!closeFirst && close}
+ )} setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog"> setOpen(false)} /> diff --git a/components/org/org-announcement.tsx b/components/org/org-announcement.tsx index 2c7933013..73726e9cd 100644 --- a/components/org/org-announcement.tsx +++ b/components/org/org-announcement.tsx @@ -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(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(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 && ( + + ); + return (
+ {closeFirst && dismiss} {firstShow && }
); } diff --git a/components/plugins/plugin-side-panels.tsx b/components/plugins/plugin-side-panels.tsx index d6d4bee15..734e57628 100644 --- a/components/plugins/plugin-side-panels.tsx +++ b/components/plugins/plugin-side-panels.tsx @@ -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(null); + const closeFirst = useCloseBesideControls(head); + const close = ( + + ); return ( <> -
+
+ {closeFirst && close}
{entry.title}
- + {!closeFirst && close}
container.querySelector('[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( + {} } }} />, + ); + 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(); + expect(closeOf(container).className).toContain('opacity-0'); + vi.useRealTimers(); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index a3d96a2be..0e06c8273 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -305,7 +305,8 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
)} - {/* 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. */}