diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index a94735cf9..7f39d002a 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -29,6 +29,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onSelect, onNewSession, onOpenThread, + onHideDm, }: { channel: ChannelSummary; session: RelaySession; @@ -43,6 +44,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onSelect: (id: string) => void; onNewSession: (id: string) => void; onOpenThread: (channelId: string, rootId: string) => void; + onHideDm?: (id: string) => void; }) { const Icon = channel.channelType === "dm" @@ -97,6 +99,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onPrepare={(id) => session.channels.prepare?.(id)} onSelect={onSelect} onNewSession={onNewSession} + {...(onHideDm ? { onHideDm } : {})} /> ); }); diff --git a/src/bundled/channels/ChannelSidebarRow.module.css b/src/bundled/channels/ChannelSidebarRow.module.css index df6b322ff..007660ffd 100644 --- a/src/bundled/channels/ChannelSidebarRow.module.css +++ b/src/bundled/channels/ChannelSidebarRow.module.css @@ -8,7 +8,7 @@ background: transparent; transition: color var(--duration-state) var(--easing-state), - background-color var(--duration-state) var(--easing-state); + background-color 90ms cubic-bezier(0.23, 1, 0.32, 1); } .row:hover:not([data-selected]) { background: var(--affordance-subtle); @@ -27,12 +27,32 @@ opacity: 0; pointer-events: none; flex-shrink: 0; + transition: opacity 90ms cubic-bezier(0.23, 1, 0.32, 1); +} +.row .remove { + position: absolute; + inset-block: 0; + right: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: flex-end; + width: calc(var(--size-row) + var(--space-control-inset)); + border-radius: inherit; + background-color: inherit; } .row:is(:hover, :has(:focus-visible)) .more, .row .more:has([data-popup-open]) { opacity: 1; pointer-events: auto; } +.row .remove [data-icon-variant] { + color: var(--text-subtle); + transition-duration: 90ms; +} +.row .remove [data-icon-variant]:is(:hover, :focus-visible) { + color: var(--text-standard); +} .iconSpace { width: 17px; flex: 0 0 17px; diff --git a/src/bundled/channels/ChannelSidebarRow.test.tsx b/src/bundled/channels/ChannelSidebarRow.test.tsx index 8db2d9f7c..ad63a91b1 100644 --- a/src/bundled/channels/ChannelSidebarRow.test.tsx +++ b/src/bundled/channels/ChannelSidebarRow.test.tsx @@ -138,6 +138,70 @@ it("hides session actions when the Sessions plugin is unavailable", () => { screen.queryByRole("button", { name: "More options for Engineering" }), ).not.toBeInTheDocument(); }); +it("offers a separate hide action only for DM rows", async () => { + const onHideDm = vi.fn(); + const onSelect = vi.fn(); + render( + {}} + icon={} + sessions={[]} + sessionsEnabled={false} + draft={false} + draftSelected={false} + onSelect={onSelect} + onPrepare={() => {}} + onNewSession={() => {}} + onHideDm={onHideDm} + />, + ); + const remove = screen.getByRole("button", { + name: "Remove Alice from DMs", + }); + expect(remove).not.toHaveAttribute("title"); + await userEvent.setup().click(remove); + expect(onHideDm).toHaveBeenCalledWith("dm"); + expect(onSelect).not.toHaveBeenCalled(); +}); +it("keeps focus in the sidebar when removing its last visible DM", async () => { + function OnlyDm() { + const [visible, setVisible] = useState(true); + return ( +
+
+ Channels +
+ {visible && ( +
+ DMs + {}} + icon={} + sessions={[]} + sessionsEnabled={false} + draft={false} + draftSelected={false} + onSelect={() => {}} + onPrepare={() => {}} + onNewSession={() => {}} + onHideDm={() => setVisible(false)} + /> +
+ )} +
+ ); + } + render(); + const remove = screen.getByRole("button", { name: "Remove Alice from DMs" }); + remove.focus(); + await userEvent.setup().keyboard("{Enter}"); + expect(screen.queryByText("DMs")).not.toBeInTheDocument(); + expect(screen.getByText("Channels")).toHaveFocus(); +}); it("opens saved child sessions and retained drafts without a channel icon", async () => { const user = userEvent.setup(); const callbacks = mount(); diff --git a/src/bundled/channels/ChannelSidebarRow.tsx b/src/bundled/channels/ChannelSidebarRow.tsx index 2d372f705..0c90b58b2 100644 --- a/src/bundled/channels/ChannelSidebarRow.tsx +++ b/src/bundled/channels/ChannelSidebarRow.tsx @@ -4,6 +4,7 @@ import { CaretDownIcon, CaretRightIcon, DotsThreeVerticalIcon, + XIcon, } from "../../shared/design-system/icons/index"; import { useId, useRef, type ReactElement, type ReactNode } from "react"; import { Menu } from "@base-ui/react/menu"; @@ -27,6 +28,7 @@ export function ChannelSidebarRow({ onSelect, onPrepare, onNewSession, + onHideDm, }: { channel: ChannelSummary; icon: ReactNode; @@ -43,6 +45,7 @@ export function ChannelSidebarRow({ onSelect: (id: string) => void; onPrepare: (id: string) => void; onNewSession: (id: string) => void; + onHideDm?: (id: string) => void; }) { const starting = useRef(false); const childrenId = useId(); @@ -162,6 +165,35 @@ export function ChannelSidebarRow({ )} + {channel.channelType === "dm" && onHideDm && ( + + { + const section = event.currentTarget.closest("details"); + const rows = [ + ...(section?.querySelectorAll( + "button[data-channel-id]", + ) ?? []), + ]; + const index = rows.findIndex( + (row) => row.dataset.channelId === channel.id, + ); + const otherSection = [ + ...(section?.parentElement?.querySelectorAll( + "details > summary", + ) ?? []), + ].find((summary) => summary.parentElement !== section); + (rows[index + 1] ?? rows[index - 1] ?? otherSection)?.focus(); + onHideDm(channel.id); + }} + icon={ + )}