Files
webmail/components/search/filter-panel-host.tsx
T
lucamzanon d632956231 feat: add Mountain View, an optional alternative layout
Add an "Interface layout" setting at the top of Settings > Layout with two
options, Default and Mountain View. Default is the existing layout and stays
the default, so nothing changes unless a user opts in.

Mountain View rearranges the chrome while keeping the theme's colours, icons
and type:

- desktop: a top bar with a wide search field (filters drop from the field),
  an apps grid and the account avatar instead of the left app rail; flat
  navigation with Starred under Inbox and the rest behind "More"; a Compose
  pill; one list toolbar with select-all, refresh, overflow menu, a 1-N of
  total range and the batch actions; rows led by a checkbox and a star; an
  icon-only message toolbar with Reply / Reply all / Forward under the
  message; compose in a docked window; an account popover with per-account
  unread counts; j/k cursor, x to select and g-sequences
- phone: rounded search header, compact rows with the star on the preview
  line, and Reply / Reply all / Forward at the end of a message instead of
  the bottom action bar

It is one setting (`interfaceLayout`), written to a data attribute on <html>
that keys a block of CSS tokens in globals.css, plus a few components gated
on the flag. The mail layout in force is derived (effectiveMailLayout), so
the user's saved mail layout is never rewritten. The setting is listed in
the admin policy tab, and the new strings are translated in all locales.

Includes unit tests and a Playwright smoke test against a running instance
(scripts/mountain-view-smoke/).
2026-10-05 10:36:20 +02:00

84 lines
2.7 KiB
TypeScript

"use client";
import { useLayoutEffect, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
/**
* Where the advanced-filter panel is drawn.
*
* With no anchor it stays where it has always been: in the flow of the
* message-list column, directly under the header that carries the filter
* button. That is the default layout, and nothing about it changes.
*
* Mountain View moves the filter button up into the global top bar, next to
* the search field. Given the anchor the top bar renders under that field, the
* panel drops from it instead: same leading edge, same width, directly
* below.
*
* It is portalled to <body> and positioned from the anchor's box rather than
* rendered inside the anchor. Inside the top bar it shared the root stacking
* context with the sidebar column, which carries the same z-index and comes
* later in the document, so the sidebar painted over the panel's leading half.
* At the end of <body> it wins that tie, and it still loses to any dialog
* opened afterwards, which is the order one wants.
*/
export function FilterPanelHost({
anchor,
children,
}: {
anchor: HTMLElement | null;
children: ReactNode;
}) {
const box = useAnchorBox(anchor);
const panel = (
<div className="px-3 pb-3 space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
{children}
</div>
);
if (!anchor) return panel;
if (!box) return null;
return createPortal(
<div
data-mv-filter-panel=""
style={box}
className="fixed z-50 max-h-[calc(100dvh-5rem)] overflow-y-auto rounded-lg border border-border bg-popover pt-3 shadow-xl"
>
{panel}
</div>,
document.body
);
}
/** The anchor's leading edge, width and bottom, kept current as the bar resizes. */
function useAnchorBox(anchor: HTMLElement | null): CSSProperties | null {
const [box, setBox] = useState<CSSProperties | null>(null);
useLayoutEffect(() => {
if (!anchor) {
setBox(null);
return;
}
const measure = () => {
const rect = anchor.getBoundingClientRect();
setBox({ top: rect.bottom + 4, left: rect.left, width: rect.width });
};
measure();
// The field is flex-sized, so it moves with the sidebar and the window;
// either observer alone misses one of the two.
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
observer?.observe(anchor);
if (anchor.parentElement) observer?.observe(anchor.parentElement);
window.addEventListener("resize", measure);
return () => {
observer?.disconnect();
window.removeEventListener("resize", measure);
};
}, [anchor]);
return box;
}