mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Polish channel modal forms (#1000)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user