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]>
123 lines
3.7 KiB
TypeScript
123 lines
3.7 KiB
TypeScript
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 };
|
|
}
|