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
12 changes: 7 additions & 5 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,14 +637,16 @@ left-to-right fade on hover or keyboard focus (always visible on touch, no anima
for reduced motion). The pencil follows the final text line;
balanced side padding keeps the text centered and lets long titles wrap without clipping it.
Description has a small pencil after its label,
revealed with a short left-to-right fade on whole-row hover or keyboard focus
revealed with a short left-to-right fade on row hover or keyboard focus
(always visible on touch, no animation for reduced motion) without shifting layout;
the whole row opens the shared Dialog with one Name/Description/Duration draft.
only the label, a 24px target, opens the shared Dialog with one Name/Description/Duration draft.
Every viewer sees the description rendered as message Markdown: its URLs, Buzz links
and `#channel` references open their targets, so its value is not part of the Edit target.
Opening Description focuses its textarea; the title focuses Name.
Only authorized editors get the interactive rows; other viewers retain plain metadata.
Only authorized editors get the edit actions; other viewers see the same linked description.
Members has a right chevron and opens the existing member list. Channel ID has a
small inline copy icon with the same hover/focus/touch behavior; selecting its row
copies the exact ID. Only a successful copy opens a confirmation tooltip, without
small inline copy icon revealed on whole-row hover or keyboard focus (always visible
on touch, no animation for reduced motion); selecting its row copies the exact ID. Only a successful copy opens a confirmation tooltip, without
growing the row; hovering or focusing does not open a hint or replay old feedback.
Clipboard failure shows an inline error and leaves the row available to retry. The redundant standalone
Edit details button, header-menu Edit details entry and informational Channel type
Expand Down
74 changes: 74 additions & 0 deletions src/bundled/channels/ChannelDetailsEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
DetailsAttempt,
} from "../../features/relay/channel-details";
import type { ChannelDetails } from "../../features/relay/channel-details-protocol";
import { createRelaySession } from "../../features/relay/session";

afterEach(() => {
cleanup();
Expand Down Expand Up @@ -438,6 +439,79 @@ it("orders metadata before actions and dismisses each edit layer with Escape", a
await user.keyboard("{Escape}");
expect(close).toHaveBeenCalledOnce();
});
it.each([
["an editor", true],
["a member", false],
])(
"links the description for %s without the editor taking its clicks",
async (_, canEdit) => {
const h = harness();
h.load.mockResolvedValue({ ...base, canEdit });
const user = userEvent.setup();
const owned = createRelaySession(null);
const ops = { id: "ops", name: "ops", channelType: "stream" as const };
const session = {
...owned.session,
channels: {
...owned.session.channels,
list: () => ({ status: "ready" as const, channels: [channel, ops] }),
},
};
const open = vi.fn(() => true);
const panel = (description: string | undefined) => (
<ChannelSettingsPanel
scope="community:viewer"
channel={{ ...channel, description }}
details={h.capability}
session={session}
onOpenLink={open}
close={() => {}}
>
Diagnostics
</ChannelSettingsPanel>
);
const { rerender } = render(
panel("R2 runbook: https://example.com/r2-runbook · see #ops"),
);
await waitFor(() =>
expect(
screen.getByRole("complementary", { name: "Channel settings" }),
).toHaveAttribute("aria-busy", "false"),
);
const edit = screen.queryByRole("button", { name: "Edit description" });
expect(!!edit).toBe(canEdit);
const description = screen.getAllByRole("definition")[0];
if (!description) throw new Error("Missing description");
await user.click(
within(description).getByRole("link", {
name: "https://example.com/r2-runbook",
}),
);
expect(open).toHaveBeenLastCalledWith("https://example.com/r2-runbook");
await user.click(within(description).getByRole("link", { name: "#ops" }));
expect(open).toHaveBeenLastCalledWith("buzz://channel/ops");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
if (edit) {
// The rendered value follows; its Markdown source would read twice.
expect(edit).not.toHaveAccessibleDescription();
await user.click(edit);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Description" }),
).toHaveFocus(),
);
await user.keyboard("{Escape}");
}
for (const [value, shown] of [
["", "No description"],
[undefined, "Not available"],
] as const) {
rerender(panel(value));
expect(screen.getAllByRole("definition")[0]).toHaveTextContent(shown);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
}
},
);
it("only enables Save for normalized, changed, valid fields with connected errors", async () => {
const h = harness();
const user = userEvent.setup();
Expand Down
31 changes: 30 additions & 1 deletion src/bundled/channels/ChannelSettingsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createPortal } from "react-dom";
import { afterEach, assert, expect, it, vi } from "vitest";
import { ChannelSettingsPanel } from "./ChannelSettingsPanel";
import type { ChannelSummary } from "../../features/relay/contracts";
Expand Down Expand Up @@ -78,6 +86,27 @@ it("closes with Escape or its close button without swallowing other keys", async
expect(close).toHaveBeenCalledTimes(3);
});

it("leaves Escape to descendants that handle it and to portalled layers", () => {
const close = vi.fn();
render(
<ChannelSettingsPanel
scope="community:viewer"
channel={undefined}
close={close}
>
<button type="button" onKeyDown={(event) => event.preventDefault()}>
Handled
</button>
{createPortal(<button type="button">Portalled</button>, document.body)}
</ChannelSettingsPanel>,
);
fireEvent.keyDown(screen.getByText("Handled"), { key: "Escape" });
fireEvent.keyDown(screen.getByText("Portalled"), { key: "Escape" });
expect(close).not.toHaveBeenCalled();
fireEvent.keyDown(screen.getByText("Diagnostics"), { key: "Escape" });
expect(close).toHaveBeenCalledOnce();
});

it("does not invent unknown metadata and still exposes diagnostics without a channel", async () => {
const user = userEvent.setup();
const { rerender } = render(
Expand Down
91 changes: 87 additions & 4 deletions src/bundled/channels/ChannelSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
import { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
import { managesCommunity } from "../../features/communities/roster";
import type { CommunityMembership } from "../moderation/membership";
import type { RelaySession } from "../../features/relay/session";
import { MessageMarkdown } from "../../features/messages/MessageMarkdown";
import { useReferenceDirectory } from "../../features/messages/ReferenceText";
import { channelIcon } from "../../features/channels/channel-icon";
import {
GearIcon,
Expand Down Expand Up @@ -48,6 +51,9 @@ export function ChannelSettingsPanel({
lifecycle,
membership,
openMembers,
session,
canOpenLink,
onOpenLink,
}: {
channel: ChannelSummary | undefined;
scope: string;
Expand All @@ -59,6 +65,10 @@ export function ChannelSettingsPanel({
lifecycle?: ChannelLifecycleCapability | undefined;
membership?: CommunityMembership | undefined;
openMembers?(trigger: HTMLButtonElement): void;
/** Links the description like a message; without these it stays plain text. */
session?: RelaySession | undefined;
canOpenLink?(target: string): boolean;
onOpenLink?(url: string): boolean;
}) {
const tabbed = !!usePanelTabHost();
const header = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -86,7 +96,12 @@ export function ChannelSettingsPanel({
aria-label="Channel settings"
aria-busy={loading}
onKeyDown={(event) => {
if (event.key === "Escape") {
// Portalled menus and cards bubble here through React but own their Escape.
if (
event.key === "Escape" &&
!event.defaultPrevented &&
event.currentTarget.contains(event.target as Node)
) {
event.stopPropagation();
close();
}
Expand Down Expand Up @@ -175,7 +190,22 @@ export function ChannelSettingsPanel({
? "Not available"
: channel.description || "No description"
}
content={
channel.description &&
session &&
onOpenLink && (
<ChannelDescription
channelId={channel.id}
content={channel.description}
session={session}
scope={scope}
onOpenLink={onOpenLink}
canOpenLink={canOpenLink}
/>
)
}
valueClassName={styles.settingsDescription}
labelOnly
actionLabel="Edit"
loading={edit?.pending}
onOpen={
Expand Down Expand Up @@ -336,10 +366,53 @@ function AutoJoinSetting({
);
}

/** Only a linked description reads the reference directory, so its snapshots
* re-render this value rather than the whole panel. */
function ChannelDescription({
channelId,
content,
session,
scope,
onOpenLink,
canOpenLink,
}: {
channelId: string;
content: string;
session: RelaySession;
scope: string;
onOpenLink(url: string): boolean;
canOpenLink?: ((target: string) => boolean) | undefined;
}) {
const directory = useReferenceDirectory(session);
return (
<MessageMarkdown
row={{
id: "channel-description",
authorId: "",
createdAt: 0,
channelId,
content,
mentions: [],
participants: [],
attachments: [],
reactions: [],
replyCount: 0,
}}
directory={directory}
session={session}
scope={scope}
media={session.media}
onOpenLink={onOpenLink}
canOpenLink={canOpenLink}
/>
);
}

/** A native button's hit area covers the row without nesting controls or losing list semantics. */
function SettingsDetail({
label,
value,
content,
valueClassName,
feedback,
actionLabel,
Expand All @@ -349,10 +422,14 @@ function SettingsDetail({
hasPopup = "dialog",
disabled = false,
loading = false,
labelOnly = false,
renderAction = (action) => action,
}: {
label: string;
/** Plain value; also describes a whole-row action. */
value: string;
/** Rendered in place of `value` when present. */
content?: ReactNode;
valueClassName?: string | undefined;
feedback?: ReactNode;
actionLabel?: string | undefined;
Expand All @@ -361,6 +438,8 @@ function SettingsDetail({
hasPopup?: "dialog" | false;
disabled?: boolean;
loading?: boolean | undefined;
/** Only the label opens the action, so the value's own links stay usable. */
labelOnly?: boolean;
renderAction?(action: ReactElement<ComponentProps<"button">>): ReactNode;
onOpen?: ((trigger: HTMLButtonElement) => void) | undefined;
}) {
Expand All @@ -371,13 +450,17 @@ function SettingsDetail({
? renderAction(
<button
type="button"
className={styles.settingsDetailAction}
className={
labelOnly
? `${styles.settingsDetailAction} ${styles.settingsLabelAction}`
: styles.settingsDetailAction
}
aria-label={
actionLabel
? `${actionLabel} ${label.toLowerCase()}`
: `View ${label.toLowerCase()}`
}
aria-description={value}
aria-description={labelOnly ? undefined : value}
aria-haspopup={hasPopup}
disabled={loading}
aria-busy={loading || undefined}
Expand Down Expand Up @@ -410,7 +493,7 @@ function SettingsDetail({
: label}
</dt>
<dd className={valueClassName}>
{value}
{content || value}
{feedback}
</dd>
</div>
Expand Down
26 changes: 21 additions & 5 deletions src/bundled/channels/Channels.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -799,10 +799,12 @@ html[data-keyboard-navigation]
border-end-start-radius: inherit;
border-end-end-radius: inherit;
}
.settingsDetails > div:has(.settingsDetailAction:hover) {
.settingsDetails
> div:has(.settingsDetailAction:not(.settingsLabelAction):hover) {
background: var(--affordance-panel-hover);
}
.settingsDetails > div:has(.settingsDetailAction:active) {
.settingsDetails
> div:has(.settingsDetailAction:not(.settingsLabelAction):active) {
background: var(--affordance-subtle-pressed);
}
.settingsDetailAction {
Expand Down Expand Up @@ -835,7 +837,8 @@ html[data-keyboard-navigation]
}
.settingsDetailAction[aria-busy="true"] .settingsDetailIcon,
.settingsDetailAction:hover .settingsDetailIcon,
.settingsDetailAction:focus-visible .settingsDetailIcon {
.settingsDetailAction:focus-visible .settingsDetailIcon,
.settingsDetails > div:hover .settingsLabelAction .settingsDetailIcon {
opacity: 1;
transform: translateX(0);
}
Expand All @@ -851,20 +854,28 @@ html[data-keyboard-navigation]
.settingsTitleAction:focus-visible .settingsTitlePencil,
.settingsDetailIcon,
.settingsDetailAction:hover .settingsDetailIcon,
.settingsDetailAction:focus-visible .settingsDetailIcon {
.settingsDetailAction:focus-visible .settingsDetailIcon,
.settingsDetails > div:hover .settingsLabelAction .settingsDetailIcon {
transform: none;
transition: none;
}
}
.settingsDetailAction:focus-visible {
outline: none;
}
.settingsDetailAction::after {
.settingsDetailAction:not(.settingsLabelAction)::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
/* The value holds its own links, so only the label opens the editor. Its
24px target overhangs the label's line box without moving the row. */
.settingsLabelAction {
width: fit-content;
min-height: var(--space-6);
margin-block: calc((1lh - var(--space-6)) / 2);
}
.settingsDetails > div + div {
border-top: 1px solid var(--border-standard);
}
Expand Down Expand Up @@ -1103,6 +1114,11 @@ html[data-keyboard-navigation]
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* The dd spaces the value from its label and sets the values' line height. */
.settingsDescription > :first-child {
margin-top: 0;
line-height: inherit;
}
.detailsEditor {
display: grid;
gap: var(--space-4);
Expand Down
Loading
Loading