Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 101 additions & 68 deletions webapp/e2e/studio-nocturne.spec.ts

Large diffs are not rendered by default.

683 changes: 683 additions & 0 deletions webapp/e2e/workspace-pages.spec.ts

Large diffs are not rendered by default.

231 changes: 231 additions & 0 deletions webapp/src/components/action-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import {
type KeyboardEvent,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";

import { Icon } from "./icons";

export type ActionMenuItem = {
key: string;
label: string;
run: () => void;
/** Drawn in the danger colour: the entry that destroys something. */
danger?: boolean;
};

/**
* A "⋯" button and the short menu it opens: the actions on one card that do
* not deserve a button of their own.
*
* Drawn as the track menu is — the same panel and entries, the same keys of
* the ARIA menu pattern — but anchored to its button rather than to a pointer.
* Portalled to the body and fixed to the viewport for the track menu's reason:
* a page block rising into place carries a `translate`, and a fixed child of
* it would be placed inside the block instead of beside the button.
*
* Closing hands focus back to the button, before the chosen entry runs.
*/
export function ActionMenu({
label,
items,
disabled = false,
}: {
/** The button's accessible name, which says whose actions these are. */
label: string;
items: ActionMenuItem[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const button = useRef<HTMLButtonElement>(null);
const menuId = useId();

function close(refocus: boolean) {
setOpen(false);
if (refocus) button.current?.focus();
}

return (
<>
<button
ref={button}
type="button"
className="action-menu-button"
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
>
<Icon name="more" size={18} />
</button>
{open && button.current
? createPortal(
<ActionMenuPanel
id={menuId}
label={label}
anchor={button.current}
items={items}
onClose={close}
/>,
document.body,
)
: null}
</>
);
}

function ActionMenuPanel({
id,
label,
anchor,
items,
onClose,
}: {
id: string;
label: string;
anchor: HTMLElement;
items: ActionMenuItem[];
onClose: (refocus: boolean) => void;
}) {
const list = useRef<HTMLDivElement>(null);
const [place, setPlace] = useState<{ left: number; top: number } | null>(
null,
);

// Under the button, its right edge on the button's, and moved back inside
// the window when that would hang off an edge — above the button when there
// is no room below it.
useLayoutEffect(() => {
const node = list.current;
if (!node) return;
const from = anchor.getBoundingClientRect();
const rect = node.getBoundingClientRect();
const margin = 8;
const below = from.bottom + 4;
const top =
below + rect.height + margin > window.innerHeight
? from.top - rect.height - 4
: below;
setPlace({
left: Math.max(
margin,
Math.min(
from.right - rect.width,
window.innerWidth - rect.width - margin,
),
),
top: Math.max(margin, top),
});
}, [anchor]);

// Focus moves in once the menu is placed: hidden for its measurement, it
// could not take focus before.
const placed = place !== null;
useEffect(() => {
if (placed) {
list.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}
}, [placed]);

// Anything outside closes it, as it closes the track menu; a press on the
// button is left to the button, whose click toggles it shut.
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const away = (event: Event) => {
const target = event.target as Node;
if (list.current?.contains(target) || anchor.contains(target)) return;
closeRef.current(false);
};
const leave = () => closeRef.current(false);
document.addEventListener("pointerdown", away, true);
document.addEventListener("wheel", leave, { capture: true, passive: true });
window.addEventListener("blur", leave);
window.addEventListener("resize", leave);
return () => {
document.removeEventListener("pointerdown", away, true);
document.removeEventListener("wheel", leave, true);
window.removeEventListener("blur", leave);
window.removeEventListener("resize", leave);
};
}, [anchor]);

function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const entries = Array.from(
list.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
);
const at = entries.indexOf(document.activeElement as HTMLElement);
const go = (index: number) => {
event.preventDefault();
entries[(index + entries.length) % entries.length]?.focus();
};
switch (event.key) {
case "ArrowDown":
go(at + 1);
break;
case "ArrowUp":
go(at - 1);
break;
case "Home":
go(0);
break;
case "End":
go(entries.length - 1);
break;
case "Escape":
event.preventDefault();
onClose(true);
break;
// Focus goes back to the button first and the Tab is left to run from
// there, so it lands on what follows the button (or, with Shift, what
// precedes it). Left in the menu, which sits at the end of the body,
// the Tab ran off the page and the menu's unmounting dropped focus on
// the body.
case "Tab":
onClose(true);
break;
Comment thread
InstaZDLL marked this conversation as resolved.
}
}

return (
<div
ref={list}
id={id}
className="track-menu action-menu"
role="menu"
aria-label={label}
// Measured hidden on the first frame, then placed: shown at the window's
// corner for a frame, it would flash there before moving.
style={
place
? { left: place.left, top: place.top }
: { left: 0, top: 0, visibility: "hidden" }
}
onKeyDown={onKeyDown}
>
{items.map((item) => (
<button
key={item.key}
type="button"
className={`track-menu-item${item.danger ? " danger" : ""}`}
role="menuitem"
tabIndex={-1}
onClick={() => {
// Focus goes back to the button first, so an entry that opens a
// dialog finds it there to hand back when the dialog closes.
onClose(true);
item.run();
}}
>
<span>{item.label}</span>
</button>
))}
</div>
);
}
29 changes: 26 additions & 3 deletions webapp/src/components/page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";

import { useI18n } from "../state/i18n";
import { Icon, type IconName } from "./icons";

/** Eight placeholder cards, keyed by name because their order is their only identity. */
const SKELETON_KEYS = [
Expand Down Expand Up @@ -139,10 +140,32 @@ export function PageHeader({
* second copy of four lines is how two screens start saying "nothing here"
* with different spacing.
*/
export function EmptyState({ message }: { message: string }) {
export function EmptyState({
message,
icon,
detail,
children,
}: {
message: string;
/** A discreet symbol over the message, for a page that is new to someone. */
icon?: IconName;
/** What the page is for, said under the message while there is nothing. */
detail?: string;
/** The way out of the empty state, for those who may take it. */
children?: ReactNode;
}) {
return (
<div className="empty-state">
<p>{message}</p>
<div className={`empty-state${icon || detail ? " empty-state-rich" : ""}`}>
{icon ? (
<span className="empty-state-icon">
<Icon name={icon} size={28} />
</span>
) : null}
<p className={icon || detail ? "empty-state-title" : undefined}>
{message}
</p>
{detail ? <p className="empty-state-detail">{detail}</p> : null}
{children ? <div className="empty-state-actions">{children}</div> : null}
</div>
);
}
36 changes: 34 additions & 2 deletions webapp/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Outlet,
RouterProvider,
redirect,
useMatches,
useNavigate,
} from "@tanstack/react-router";
import {
Expand Down Expand Up @@ -699,6 +700,12 @@ function ScopedOutlet() {
/** Compose the authenticated desktop and mobile application chrome. */
function Shell() {
const { t } = useI18n();
// Read from the routes rather than set by each page, so the width holds
// while a page is still loading and the hub's sections share it.
const workspace = useMatches({
select: (matches) =>
matches.some((match) => match.staticData.layout === "workspace"),
});
return (
<LibraryScopeProvider>
<PlayerProvider>
Expand All @@ -721,7 +728,11 @@ function Shell() {
<Brand />
<LibraryPicker />
</header>
<main id="main-content" tabIndex={-1}>
<main
id="main-content"
tabIndex={-1}
className={workspace ? "workspace" : undefined}
>
<ScopedOutlet />
</main>
<Navigation mobile />
Expand Down Expand Up @@ -922,13 +933,22 @@ const queueRoute = createRoute({
const radioRoute = createRoute({
getParentRoute: () => authedRoute,
path: "/radio",
staticData: { layout: "workspace" },
component: RadioPage,
});

const podcastsRoute = createRoute({
getParentRoute: () => authedRoute,
path: "/podcasts",
component: PodcastsPage,
staticData: { layout: "workspace" },
// The channel whose episodes are open, so Back and a reload keep it.
validateSearch: (search: Record<string, unknown>): { channel?: string } => ({
channel: typeof search.channel === "string" ? search.channel : undefined,
}),
component: function PodcastsRoute() {
const { channel } = podcastsRoute.useSearch();
return <PodcastsPage channel={channel} />;
},
});

/**
Expand Down Expand Up @@ -1037,6 +1057,7 @@ const legacyAdminRoute = createRoute({
const foldersRoute = createRoute({
getParentRoute: () => authedRoute,
path: "/folders",
staticData: { layout: "workspace" },
// The folder: absent or empty for the root, `unfiled` for the tracks filed
// nowhere, otherwise a folder id. Optional, so the rail links to the root.
validateSearch: (search: Record<string, unknown>): { folder?: string } => ({
Expand All @@ -1056,6 +1077,9 @@ const foldersRoute = createRoute({
const maintenanceRoute = createRoute({
getParentRoute: () => authedRoute,
path: "/maintenance",
// Every section under the hub inherits it: a child route's match carries
// its own static data, but the hub's match is in the list beside it.
staticData: { layout: "workspace" },
component: MaintenanceHub,
});

Expand Down Expand Up @@ -1198,6 +1222,14 @@ declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
interface StaticDataRouteOption {
/**
* `workspace` for the pages that manage something rather than present
* it — Radio, Podcasts, Folders and the Maintenance hub. They take more of
* a wide window; the editorial pages keep the contained column.
*/
layout?: "workspace";
}
}

const container = document.getElementById("root");
Expand Down
Loading
Loading