Polish channel modal forms (#1000)

This commit is contained in:
klopez4212
2026-06-15 20:43:59 +01:00
committed by GitHub
parent 19656fff46
commit ee34ca818b
11 changed files with 587 additions and 353 deletions

View File

@@ -1,52 +1,20 @@
import * as React from "react";
import {
Compass,
FileText,
Hash,
LogIn,
Search,
Users,
type LucideIcon,
} from "lucide-react";
import { Compass, Search, X, type LucideIcon } from "lucide-react";
import type { Channel } from "@/shared/api/types";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Badge } from "@/shared/ui/badge";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O";
function formatRelativeTime(isoString: string | null) {
if (!isoString) {
return "No activity";
}
const diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1_000);
if (diff < 60) {
return "just now";
}
if (diff < 60 * 60) {
return `${Math.floor(diff / 60)}m ago`;
}
if (diff < 60 * 60 * 24) {
return `${Math.floor(diff / (60 * 60))}h ago`;
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(new Date(isoString));
}
type BrowserTab = "all" | "joined" | "archived";
function BrowseState({
icon: Icon,
@@ -60,7 +28,7 @@ function BrowseState({
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
<Icon className="h-4 w-4" />
</div>
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
@@ -88,24 +56,34 @@ export function ChannelBrowserDialog({
onSelectChannel,
}: ChannelBrowserDialogProps) {
const [query, setQuery] = React.useState("");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [activeTab, setActiveTab] = React.useState<BrowserTab>("all");
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
null,
);
const inputRef = React.useRef<HTMLInputElement>(null);
const tabListRef = React.useRef<HTMLDivElement>(null);
const tabTriggerRefs = React.useRef<
Record<BrowserTab, HTMLButtonElement | null>
>({
all: null,
joined: null,
archived: null,
});
const [tabIndicator, setTabIndicator] = React.useState({
left: 0,
width: 0,
});
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
const isForumMode = channelTypeFilter === "forum";
const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels";
const browseDescription = isForumMode
? "Discover and join open forums."
: "Discover and join open channels.";
const searchPlaceholder = isForumMode
? "Search forums by name or description"
: "Search channels by name or description";
const entityLabel = isForumMode ? "forum" : "channel";
const browsableChannels = React.useMemo(() => {
const matchingChannels = React.useMemo(() => {
const filtered = channels.filter(
(channel) =>
channel.channelType !== "dm" &&
@@ -126,53 +104,114 @@ export function ChannelBrowserDialog({
);
}, [channels, channelTypeFilter, deferredQuery]);
const notJoined = React.useMemo(
() => browsableChannels.filter((channel) => !channel.isMember),
[browsableChannels],
const currentChannels = React.useMemo(
() => matchingChannels.filter((channel) => channel.archivedAt === null),
[matchingChannels],
);
const joined = React.useMemo(
() => browsableChannels.filter((channel) => channel.isMember),
[browsableChannels],
);
const hasArchivedJoinedChannels = React.useMemo(
() => joined.some((channel) => channel.archivedAt !== null),
[joined],
const joinedChannels = React.useMemo(
() => currentChannels.filter((channel) => channel.isMember),
[currentChannels],
);
// Flat list for keyboard navigation: not-joined first, then joined
const allItems = React.useMemo(
() => [...notJoined, ...joined],
[notJoined, joined],
const archivedChannels = React.useMemo(
() => matchingChannels.filter((channel) => channel.archivedAt !== null),
[matchingChannels],
);
const visibleChannels =
activeTab === "archived"
? archivedChannels
: activeTab === "joined"
? joinedChannels
: matchingChannels;
const orderedVisibleChannels = React.useMemo(
() => [
...visibleChannels.filter((channel) => !channel.isMember),
...visibleChannels.filter((channel) => channel.isMember),
],
[visibleChannels],
);
const allTabLabel = isForumMode ? "All forums" : "All channels";
const updateTabIndicator = React.useCallback(() => {
const list = tabListRef.current;
const trigger = tabTriggerRefs.current[activeTab];
if (!open || !list || !trigger) {
return;
}
const nextIndicator = {
left: trigger.offsetLeft,
width: trigger.offsetWidth,
};
setTabIndicator((current) =>
Math.abs(current.left - nextIndicator.left) < 0.5 &&
Math.abs(current.width - nextIndicator.width) < 0.5
? current
: nextIndicator,
);
}, [activeTab, open]);
React.useLayoutEffect(() => {
updateTabIndicator();
if (!open) {
return;
}
let isCancelled = false;
const updateIfActive = () => {
if (!isCancelled) {
updateTabIndicator();
}
};
const frameId = window.requestAnimationFrame(updateIfActive);
const observer = new ResizeObserver(updateTabIndicator);
const list = tabListRef.current;
void document.fonts.ready.then(updateIfActive);
if (list) {
observer.observe(list);
}
for (const trigger of Object.values(tabTriggerRefs.current)) {
if (trigger) {
observer.observe(trigger);
}
}
return () => {
isCancelled = true;
window.cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [open, updateTabIndicator]);
React.useEffect(() => {
if (!open) {
setQuery("");
setSelectedIndex(0);
setActiveTab("all");
setSelectedIndex(null);
setJoiningChannelId(null);
return;
}
const timeout = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(timeout);
};
}, [open]);
React.useEffect(() => {
setSelectedIndex((current) => {
if (allItems.length === 0) {
return 0;
if (current === null || orderedVisibleChannels.length === 0) {
return null;
}
return Math.min(current, allItems.length - 1);
return Math.min(current, orderedVisibleChannels.length - 1);
});
}, [allItems]);
}, [orderedVisibleChannels]);
async function handleJoin(channelId: string) {
setJoiningChannelId(channelId);
@@ -191,144 +230,199 @@ export function ChannelBrowserDialog({
onSelectChannel(channel.id);
}
const selectedItem = allItems[selectedIndex];
const selectedItem =
selectedIndex !== null ? orderedVisibleChannels[selectedIndex] : undefined;
const emptyTitle =
deferredQuery.length > 0
? `No ${entityLabel}s match your search`
: activeTab === "archived"
? `No archived ${entityLabel}s`
: activeTab === "joined"
? `No joined ${entityLabel}s`
: `No ${entityLabel}s to browse`;
const emptyDescription =
deferredQuery.length > 0
? "Try a different name or keyword."
: activeTab === "archived"
? `Archived ${entityLabel}s you have joined will appear here.`
: activeTab === "joined"
? `${entityLabel[0].toUpperCase()}${entityLabel.slice(1)}s you join will appear here.`
: `All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`;
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="gap-0 overflow-hidden p-0"
aria-describedby={undefined}
className="gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
data-testid={
isForumMode ? "forum-browser-dialog" : "channel-browser-dialog"
}
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus({ preventScroll: true });
}}
showCloseButton={false}
>
<DialogHeader className="border-b border-border/80 px-6 py-5">
<DialogTitle className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-xs">
<Compass className="h-4 w-4" />
</span>
{browseTitle}
</DialogTitle>
<DialogDescription>{browseDescription}</DialogDescription>
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-xs">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>{browseTitle}</DialogTitle>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<label
className="mt-4 flex cursor-text items-center gap-3 rounded-xl border border-input bg-background px-3 py-2.5 shadow-xs transition-colors duration-150 ease-out hover:border-muted-foreground/40 hover:bg-muted/70 focus-within:border-muted-foreground/50 focus-within:bg-muted/70 group/search"
htmlFor="channel-browser-search"
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className="block h-6 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 text-muted-foreground/55 shadow-none caret-foreground outline-none transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 group-hover/search:text-muted-foreground group-hover/search:placeholder:text-muted-foreground group-focus-within/search:text-foreground group-focus-within/search:placeholder:text-foreground"
data-testid="channel-browser-search"
id="channel-browser-search"
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
setSelectedIndex(null);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && allItems.length > 0) {
if (
event.key === "ArrowDown" &&
orderedVisibleChannels.length > 0
) {
event.preventDefault();
setSelectedIndex((current) =>
Math.min(current + 1, allItems.length - 1),
current === null
? 0
: Math.min(
current + 1,
orderedVisibleChannels.length - 1,
),
);
return;
}
if (event.key === "ArrowUp" && allItems.length > 0) {
if (
event.key === "ArrowUp" &&
orderedVisibleChannels.length > 0
) {
event.preventDefault();
setSelectedIndex((current) => Math.max(current - 1, 0));
setSelectedIndex((current) =>
current === null
? orderedVisibleChannels.length - 1
: Math.max(current - 1, 0),
);
return;
}
if (
event.key === "Enter" &&
!event.nativeEvent.isComposing &&
selectedItem
orderedVisibleChannels.length > 0
) {
event.preventDefault();
handleSelect(selectedItem);
handleSelect(selectedItem ?? orderedVisibleChannels[0]);
}
}}
placeholder={searchPlaceholder}
ref={inputRef}
spellCheck={false}
type="text"
value={query}
/>
<span className="hidden shrink-0 text-xs text-muted-foreground/50 sm:block">
<span
className={`hidden shrink-0 text-xs text-muted-foreground/50 transition-opacity duration-150 ease-out group-focus-within/search:opacity-0 sm:block ${
query.length > 0 ? "opacity-0" : "opacity-100"
}`}
>
{BROWSE_CHANNELS_SHORTCUT_HINT}
</span>
</div>
</label>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{browsableChannels.length === 0 ? (
deferredQuery.length > 0 ? (
<BrowseState
description="Try a different name or keyword."
icon={Search}
title={`No ${entityLabel}s match your search`}
/>
) : (
<BrowseState
description={`All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`}
icon={Compass}
title={`No ${entityLabel}s to browse`}
/>
)
) : (
<div className="p-3">
{notJoined.length > 0 ? (
<>
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<span>
{notJoined.length} {entityLabel}
{notJoined.length !== 1 ? "s" : ""} to join
</span>
<span>Enter to view</span>
</div>
<div className="space-y-2">
{notJoined.map((channel) => {
const flatIndex = allItems.indexOf(channel);
return (
<ChannelCard
channel={channel}
isJoining={joiningChannelId === channel.id}
isSelected={flatIndex === selectedIndex}
key={channel.id}
onJoin={() => {
void handleJoin(channel.id);
}}
onMouseEnter={() => setSelectedIndex(flatIndex)}
onSelect={() => handleSelect(channel)}
/>
);
})}
</div>
</>
) : null}
<div className="h-[min(60vh,30rem)] overflow-hidden">
<div className="flex h-full flex-col">
<Tabs
className="shrink-0"
onValueChange={(value) => {
setActiveTab(value as BrowserTab);
setSelectedIndex(null);
}}
value={activeTab}
>
<TabsList
className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
ref={tabListRef}
>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-1px] left-0 h-0.5 w-px origin-left rounded-full bg-foreground opacity-0 transition-[transform,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
data-ready={tabIndicator.width > 0}
data-testid="channel-browser-tab-indicator"
style={{
transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
}}
/>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.all = element;
}}
value="all"
>
{allTabLabel}
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.joined = element;
}}
value="joined"
>
Joined
</TabsTrigger>
<TabsTrigger
className="rounded-none border-b-2 border-transparent bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
ref={(element) => {
tabTriggerRefs.current.archived = element;
}}
value="archived"
>
Archived
</TabsTrigger>
</TabsList>
</Tabs>
{joined.length > 0 ? (
<>
<div className="mb-3 mt-4 flex items-center px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<span>Joined</span>
</div>
<div className="space-y-2">
{joined.map((channel) => {
const flatIndex = allItems.indexOf(channel);
return (
<ChannelCard
channel={channel}
isJoining={false}
isSelected={flatIndex === selectedIndex}
key={channel.id}
onMouseEnter={() => setSelectedIndex(flatIndex)}
onSelect={() => handleSelect(channel)}
/>
);
})}
</div>
</>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
{orderedVisibleChannels.length === 0 ? (
<BrowseState
description={emptyDescription}
icon={deferredQuery.length > 0 ? Search : Compass}
title={emptyTitle}
/>
) : (
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
{orderedVisibleChannels.map((channel, index) => (
<ChannelCard
channel={channel}
isJoining={joiningChannelId === channel.id}
isSelected={index === selectedIndex}
key={channel.id}
onJoin={
!channel.isMember
? () => {
void handleJoin(channel.id);
}
: undefined
}
onSelect={() => handleSelect(channel)}
/>
))}
</div>
)}
</div>
)}
</div>
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
{hasArchivedJoinedChannels
? `Showing open ${entityLabel}s and your archived ${entityLabel}s. Private ${entityLabel}s require an invite.`
: `Showing open ${entityLabel}s. Private ${entityLabel}s require an invite.`}
</div>
</div>
</DialogContent>
</Dialog>
@@ -340,80 +434,80 @@ function ChannelCard({
isJoining,
isSelected,
onJoin,
onMouseEnter,
onSelect,
}: {
channel: Channel;
isJoining: boolean;
isSelected: boolean;
onJoin?: () => void;
onMouseEnter: () => void;
onSelect: () => void;
}) {
const memberLabel = `${channel.memberCount} ${
channel.memberCount === 1 ? "member" : "members"
}`;
return (
<button
<div
className={
isSelected
? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-xs outline-hidden transition-colors"
: "w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-xs outline-hidden transition-colors hover:border-primary/20 hover:bg-accent"
? "group/channel-row flex min-h-16 items-center gap-4 bg-muted/40 px-4 py-3 transition-colors duration-150 ease-out"
: "group/channel-row flex min-h-16 items-center gap-4 px-4 py-3 transition-colors duration-150 ease-out hover:bg-muted/40"
}
data-testid={`browse-channel-${channel.name}`}
onClick={onSelect}
onMouseEnter={onMouseEnter}
type="button"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
{channel.channelType === "forum" ? (
<FileText className="h-4 w-4" />
) : (
<Hash className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">
<button
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-left text-foreground outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
type="button"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 text-sm font-normal text-muted-foreground">
#
</span>
<p className="min-w-0 truncate text-base font-medium tracking-tight">
{channel.name}
</p>
<Badge variant="secondary">{channel.channelType}</Badge>
{channel.archivedAt ? (
<Badge variant="warning">archived</Badge>
<Badge className="ml-1 shrink-0" variant="warning">
archived
</Badge>
) : null}
<div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="h-3 w-3" />
{channel.memberCount}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(channel.lastMessageAt)}
</span>
</div>
</div>
{channel.description ? (
<p className="mt-1 text-sm leading-6 text-muted-foreground line-clamp-2">
{channel.description}
</p>
) : null}
<p className="mt-1 truncate text-sm text-muted-foreground">
<span>{memberLabel}</span>
{channel.description ? (
<>
<span className="px-1.5">·</span>
<span title={channel.description}>{channel.description}</span>
</>
) : null}
</p>
</div>
</button>
{!channel.isMember && onJoin ? (
<Button
className="shrink-0"
disabled={isJoining}
onClick={(event) => {
event.stopPropagation();
onJoin();
}}
size="sm"
type="button"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining..." : "Join"}
</Button>
) : null}
</div>
</button>
{!channel.isMember && onJoin ? (
<Button
className={
isJoining
? "shrink-0"
: "shrink-0 opacity-0 transition-opacity duration-150 ease-out group-hover/channel-row:opacity-100 group-focus-within/channel-row:opacity-100"
}
disabled={isJoining}
onClick={(event) => {
event.stopPropagation();
onJoin();
}}
size="sm"
type="button"
variant="default"
>
{isJoining ? "Joining..." : "Join"}
</Button>
) : null}
</div>
);
}

View File

@@ -321,9 +321,12 @@ export function useRichTextEditor({
],
editorProps: {
attributes: {
autocapitalize: "none",
autocorrect: "off",
class:
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
"data-testid": "message-input",
spellcheck: "false",
},
// ArrowUp in an empty composer → edit your last message (Slack
// parity). Handled here in ProseMirror's own DOM `keydown` hook —

View File

@@ -62,6 +62,8 @@ export function ChannelFindBar({
<div className="relative flex min-w-0 flex-1 items-center">
<input
ref={inputRef}
autoCapitalize="none"
autoCorrect="off"
className={cn(
"h-7 w-full rounded-md border border-input bg-transparent px-2 pr-20 text-sm",
"placeholder:text-muted-foreground",
@@ -70,6 +72,7 @@ export function ChannelFindBar({
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Find in channel"
spellCheck={false}
type="text"
value={query}
/>

View File

@@ -1,17 +1,22 @@
import { Lock, Zap } from "lucide-react";
import { ClockFading, Hash } from "lucide-react";
import * as React from "react";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Textarea } from "@/shared/ui/textarea";
/** Default TTL for ephemeral channels: 1 day of inactivity. */
const EPHEMERAL_TTL_SECONDS = 86400;
const CREATE_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-background shadow-xs transition-colors duration-150 ease-out hover:border-muted-foreground/40 hover:bg-muted/70 focus-within:border-muted-foreground/50 focus-within:bg-muted/70";
const CREATE_FIELD_CONTROL_CLASS =
"border-0 bg-transparent shadow-none outline-none ring-0 placeholder:text-muted-foreground/45 focus:bg-transparent focus:outline-hidden focus-visible:ring-0";
type ChannelKind = "stream" | "forum";
@@ -136,7 +141,10 @@ export function CreateChannelDialog({
>
<ChooserDialogContent
className="max-w-lg"
contentClassName="pt-3"
data-testid="create-channel-dialog"
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={`Create a new ${kindLabel}`}
description={
channelKind === "forum"
@@ -165,7 +173,7 @@ export function CreateChannelDialog({
}
>
<form
className="space-y-4"
className="space-y-5"
id="create-channel-form"
onSubmit={(event) => {
void handleSubmit(event);
@@ -179,24 +187,34 @@ export function CreateChannelDialog({
>
Name
</label>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
data-testid="create-channel-name"
disabled={isCreating}
id="create-channel-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder={
channelKind === "forum" ? "design-discussions" : "release-notes"
}
ref={nameInputRef}
spellCheck={false}
value={name}
/>
<div
className={cn(
"flex h-9 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn("h-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
data-testid="create-channel-name"
disabled={isCreating}
id="create-channel-name"
onChange={(event) => {
setName(event.target.value);
setErrorMessage(null);
}}
placeholder={
channelKind === "forum"
? "design-discussions"
: "release-notes"
}
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div>
{/* Description */}
@@ -210,91 +228,137 @@ export function CreateChannelDialog({
(optional)
</span>
</label>
<Textarea
className="min-h-16 resize-none"
data-testid="create-channel-description"
disabled={isCreating}
id="create-channel-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={description}
/>
<div className={CREATE_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-16 resize-none px-3 py-2",
CREATE_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
id="create-channel-description"
onChange={(event) => {
setDescription(event.target.value);
setErrorMessage(null);
}}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={description}
/>
</div>
</div>
{/* Options */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<label
className="flex items-center gap-1.5 text-sm text-muted-foreground"
htmlFor="create-channel-private"
>
<Lock className="h-3.5 w-3.5" />
Private — only visible to invited members
</label>
<Switch
checked={visibility === "private"}
data-testid="create-channel-visibility"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
setVisibility(checked ? "private" : "open")
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
className="flex items-center gap-1.5 text-sm text-muted-foreground"
htmlFor="create-channel-ephemeral"
>
<Zap className="h-3.5 w-3.5" />
Ephemeral — auto-archives after 1 day of inactivity
</label>
<Switch
checked={ephemeral}
disabled={isCreating}
id="create-channel-ephemeral"
onCheckedChange={setEphemeral}
/>
</div>
{/* Type */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Type</p>
<Tabs
className="w-full"
data-testid="create-channel-ephemeral"
onValueChange={(value) => setEphemeral(value === "temporary")}
value={ephemeral ? "temporary" : "ongoing"}
>
<TabsList className="relative grid h-auto w-full grid-cols-2 items-stretch rounded-xl bg-muted/45 p-1 text-muted-foreground">
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-1 left-1 top-1 z-0 w-[calc(50%-0.25rem)] rounded-lg bg-background/95 shadow-xs transition-transform duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
data-testid="create-channel-type-indicator"
style={{
transform: ephemeral
? "translate3d(100%, 0, 0)"
: "translate3d(0, 0, 0)",
}}
/>
<TabsTrigger
className="group/type-tab relative z-10 h-full min-h-24 flex-col items-start justify-start gap-1.5 whitespace-normal rounded-lg bg-transparent px-3 py-2.5 text-left text-muted-foreground/55 opacity-70 shadow-none transition-[color,opacity] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-muted-foreground hover:opacity-85 data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:opacity-100 data-[state=active]:shadow-none motion-reduce:transition-none"
disabled={isCreating}
value="ongoing"
>
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-current" />
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium">Ongoing</span>
<span className="block text-xs leading-4 text-muted-foreground/45 group-data-[state=active]/type-tab:text-muted-foreground/65">
For projects, teams, and recurring conversations.
</span>
</span>
</TabsTrigger>
<TabsTrigger
aria-label="Ephemeral — auto-archives after 1 day of inactivity"
className="group/type-tab relative z-10 h-full min-h-24 flex-col items-start justify-start gap-1.5 whitespace-normal rounded-lg bg-transparent px-3 py-2.5 text-left text-muted-foreground/55 opacity-70 shadow-none transition-[color,opacity] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-muted-foreground hover:opacity-85 data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:opacity-100 data-[state=active]:shadow-none motion-reduce:transition-none"
disabled={isCreating}
value="temporary"
>
<ClockFading className="mt-0.5 h-4 w-4 shrink-0 text-current" />
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium">Temporary</span>
<span className="block text-xs leading-4 text-muted-foreground/45 group-data-[state=active]/type-tab:text-muted-foreground/65">
For quick discussions that archive automatically when
inactive.
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Permissions */}
<fieldset
className="space-y-2"
data-testid="create-channel-visibility"
>
<legend className="text-sm font-medium text-foreground">
Permissions
</legend>
<div className="space-y-1">
<PermissionOption
description={`Anyone can see and join this ${kindLabel}.`}
disabled={isCreating}
name="create-channel-visibility"
onChange={() => setVisibility("open")}
selected={visibility === "open"}
title="Open"
value="open"
/>
<PermissionOption
description={`Only members can invite people to this ${kindLabel}.`}
disabled={isCreating}
name="create-channel-visibility"
onChange={() => setVisibility("private")}
selected={visibility === "private"}
title="Private"
value="private"
/>
</div>
</fieldset>
{/* Template Selector */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="create-channel-template"
disabled={isCreating || templates.length === 0}
id="create-channel-template"
onChange={(event) => handleTemplateChange(event.target.value)}
value={selectedTemplateId ?? ""}
>
<option value="">
{templatesQuery.isLoading
? "Loading..."
: templates.length === 0
? "No templates created yet"
: "No template"}
</option>
{templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
{templates.length > 0 ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="create-channel-template"
>
Template{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="create-channel-template"
disabled={isCreating}
id="create-channel-template"
onChange={(event) => handleTemplateChange(event.target.value)}
value={selectedTemplateId ?? ""}
>
<option value="">No template</option>
{templates.map((template: ChannelTemplate) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
) : null}
{/* Error */}
{errorMessage ? (
@@ -305,3 +369,66 @@ export function CreateChannelDialog({
</Dialog>
);
}
function PermissionOption({
description,
disabled,
name,
onChange,
selected,
title,
value,
}: {
description: string;
disabled: boolean;
name: string;
onChange: () => void;
selected: boolean;
title: string;
value: ChannelVisibility;
}) {
return (
<label
className={cn(
"group flex cursor-pointer items-start gap-3 rounded-lg px-1 py-1.5 transition-none",
"has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
selected
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
disabled && "pointer-events-none opacity-50",
)}
>
<input
checked={selected}
className="sr-only"
disabled={disabled}
name={name}
onChange={onChange}
type="radio"
value={value}
/>
<span
className={cn(
"mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-none",
selected
? "border-foreground"
: "border-muted-foreground/45 group-hover:border-muted-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
selected ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
</span>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium text-current">{title}</span>
<span className="block text-xs leading-4 text-muted-foreground/65">
{description}
</span>
</span>
</label>
);
}

View File

@@ -181,7 +181,7 @@ export function NewDirectMessageDialog({
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-5 w-5 text-[10px] shadow-none"
iconClassName="h-3 w-3"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<span className="font-medium">{formatUserName(user)}</span>
@@ -198,7 +198,7 @@ export function NewDirectMessageDialog({
}}
type="button"
>
<X className="h-3.5 w-3.5" />
<X className="h-4 w-4" />
</button>
</div>
))}

View File

@@ -15,7 +15,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/60 transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none",
className,
)}
ref={ref}
@@ -33,7 +33,7 @@ const AlertDialogContent = React.forwardRef<
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<AlertDialogPrimitive.Content
className={cn(
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
className,
)}
ref={ref}
@@ -74,7 +74,7 @@ const AlertDialogTitle = React.forwardRef<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
className={cn("text-lg font-semibold tracking-tight", className)}
className={cn("text-xl font-semibold tracking-tight", className)}
ref={ref}
{...props}
/>

View File

@@ -19,7 +19,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-3.5 w-3.5" />
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));

View File

@@ -2,12 +2,7 @@ import * as React from "react";
import { cn } from "@/shared/lib/cn";
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
import { DialogContent, DialogHeader, DialogTitle } from "./dialog";
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogContent
@@ -16,6 +11,7 @@ type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
footer?: React.ReactNode;
footerClassName?: string;
footerTestId?: string;
contentClassName?: string;
headerClassName?: string;
headerTestId?: string;
scrollAreaClassName?: string;
@@ -31,7 +27,8 @@ export const ChooserDialogContent = React.forwardRef<
{
children,
className,
description,
contentClassName,
description: _description,
footer,
footerClassName,
footerTestId,
@@ -40,11 +37,13 @@ export const ChooserDialogContent = React.forwardRef<
scrollAreaClassName,
scrollAreaTestId,
title,
"aria-describedby": ariaDescribedBy,
...props
},
ref,
) => (
<DialogContent
aria-describedby={ariaDescribedBy}
className={cn(
"flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0",
className,
@@ -53,16 +52,10 @@ export const ChooserDialogContent = React.forwardRef<
{...props}
>
<DialogHeader
className={cn(
"shrink-0 border-b border-border/60 px-6 py-5 pr-14",
headerClassName,
)}
className={cn("shrink-0 px-6 py-5 pr-14", headerClassName)}
data-testid={headerTestId}
>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
<div
@@ -72,7 +65,7 @@ export const ChooserDialogContent = React.forwardRef<
)}
data-testid={scrollAreaTestId}
>
<div className="py-5">{children}</div>
<div className={cn("py-5", contentClassName)}>{children}</div>
</div>
{footer ? (

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none",
isDark ? "bg-black/60" : "bg-black/10",
className,
)}
@@ -32,26 +32,34 @@ const DialogOverlay = React.forwardRef<
});
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> & {
showCloseButton?: boolean;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
DialogContentProps
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<DialogPrimitive.Content
className={cn(
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl transition-none duration-200 ease-out data-[state=closed]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
className,
)}
ref={ref}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</div>
</DialogPortal>
@@ -74,7 +82,7 @@ const DialogTitle = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
className={cn("text-lg font-semibold tracking-tight", className)}
className={cn("text-xl font-semibold tracking-tight", className)}
ref={ref}
{...props}
/>

View File

@@ -6,6 +6,9 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
type={type}
className={cn(
"flex h-9 w-full rounded-lg border border-input/40 bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",

View File

@@ -8,6 +8,9 @@ const Textarea = React.forwardRef<
>(({ className, ...props }, ref) => {
return (
<textarea
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
className={cn(
"flex min-h-20 w-full rounded-lg border border-input/40 bg-background px-3 py-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,