feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058): - A collapsible left sidebar replaces the header-and-column layout. It is icons and labels when expanded and icons only when collapsed; each collapsed item keeps its name, visually hidden, and shows a tooltip on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes first, then the server's groups and modules. There is no top bar. - The expanded/collapsed choice is remembered in this browser's localStorage, behind a SidebarPreference interface that a synced store can replace later. - An account menu at the bottom of the sidebar follows the WAI-ARIA menu button pattern and offers only Sign out, which now lives there rather than in the header. - On phones (below 48em) the same navigation opens in a modal drawer: the page behind it is inert and focus is trapped inside it. Escape, its close button, and the backdrop close it and return focus to the menu button. Choosing a page closes it and focuses the page. Changing layout keeps the route, the desktop mode, and the mounted module. - Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon vocabulary. Every module shows one generic icon until manifest v9 lets modules declare their own; there is no per-module icon map. - The identity slot is a neutral placeholder with no inputs, waiting for branding (#101). The tooltip, menu, and drawer stay shell-local until a second consumer needs them (ADR 0039). The deploy smoke now signs out through the account menu, and drives the drawer at phone size in a real browser. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3b7e19651
commit
af31724cb4
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap
|
||||
* onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */
|
||||
const HIDE_DELAY_MS = 150;
|
||||
|
||||
interface Anchor {
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
/** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */
|
||||
export interface NavTooltipTriggerProps {
|
||||
onBlur(event: FocusEvent<HTMLElement>): void;
|
||||
onFocus(event: FocusEvent<HTMLElement>): void;
|
||||
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
|
||||
onMouseEnter(event: MouseEvent<HTMLElement>): void;
|
||||
onMouseLeave(event: MouseEvent<HTMLElement>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible name of an icon-only control in the collapsed sidebar (ADR 0058).
|
||||
*
|
||||
* The control's accessible name is its own text, kept in the DOM and visually hidden, so the
|
||||
* tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the
|
||||
* name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as
|
||||
* well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can
|
||||
* move onto it without it vanishing, and it stays until focus or the pointer leaves.
|
||||
*
|
||||
* Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar
|
||||
* cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already
|
||||
* shows every label.
|
||||
*
|
||||
* Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent
|
||||
* consumers need it; when a module needs a tooltip, this is the shape to promote.
|
||||
*/
|
||||
export function useNavTooltip(
|
||||
label: string,
|
||||
enabled: boolean,
|
||||
): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } {
|
||||
const [anchor, setAnchor] = useState<Anchor | undefined>(undefined);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const cancelHide = useCallback(() => {
|
||||
if (hideTimer.current !== undefined) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
cancelHide();
|
||||
setAnchor(undefined);
|
||||
}, [cancelHide]);
|
||||
|
||||
const show = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
cancelHide();
|
||||
const rect = element.getBoundingClientRect();
|
||||
setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 });
|
||||
},
|
||||
[cancelHide],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
hide();
|
||||
}
|
||||
}, [enabled, hide]);
|
||||
|
||||
useEffect(() => cancelHide, [cancelHide]);
|
||||
|
||||
const triggerProps: NavTooltipTriggerProps = {
|
||||
onBlur: hide,
|
||||
onFocus: (event) => {
|
||||
if (enabled) {
|
||||
show(event.currentTarget);
|
||||
}
|
||||
},
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Escape" && anchor !== undefined) {
|
||||
// Only this tooltip is dismissed; the event goes no further.
|
||||
event.stopPropagation();
|
||||
hide();
|
||||
}
|
||||
},
|
||||
onMouseEnter: (event) => {
|
||||
if (enabled) {
|
||||
show(event.currentTarget);
|
||||
}
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
cancelHide();
|
||||
hideTimer.current = setTimeout(hide, HIDE_DELAY_MS);
|
||||
},
|
||||
};
|
||||
|
||||
const tooltip =
|
||||
enabled && anchor !== undefined ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ek-shell-tooltip"
|
||||
data-testid="nav-tooltip"
|
||||
onMouseEnter={cancelHide}
|
||||
onMouseLeave={hide}
|
||||
style={{ left: anchor.left, top: anchor.top }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return { tooltip, triggerProps };
|
||||
}
|
||||
Reference in New Issue
Block a user