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 * as React from "react";
import { import { Compass, Search, X, type LucideIcon } from "lucide-react";
Compass,
FileText,
Hash,
LogIn,
Search,
Users,
type LucideIcon,
} from "lucide-react";
import type { Channel } from "@/shared/api/types"; import type { Channel } from "@/shared/api/types";
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O"; const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O";
type BrowserTab = "all" | "joined" | "archived";
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));
}
function BrowseState({ function BrowseState({
icon: Icon, icon: Icon,
@@ -60,7 +28,7 @@ function BrowseState({
return ( return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center"> <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"> <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> </div>
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p> <p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
<p className="mt-2 max-w-md text-sm text-muted-foreground"> <p className="mt-2 max-w-md text-sm text-muted-foreground">
@@ -88,24 +56,34 @@ export function ChannelBrowserDialog({
onSelectChannel, onSelectChannel,
}: ChannelBrowserDialogProps) { }: ChannelBrowserDialogProps) {
const [query, setQuery] = React.useState(""); 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>( const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
null, null,
); );
const inputRef = React.useRef<HTMLInputElement>(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 deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
const isForumMode = channelTypeFilter === "forum"; const isForumMode = channelTypeFilter === "forum";
const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels"; const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels";
const browseDescription = isForumMode
? "Discover and join open forums."
: "Discover and join open channels.";
const searchPlaceholder = isForumMode const searchPlaceholder = isForumMode
? "Search forums by name or description" ? "Search forums by name or description"
: "Search channels by name or description"; : "Search channels by name or description";
const entityLabel = isForumMode ? "forum" : "channel"; const entityLabel = isForumMode ? "forum" : "channel";
const browsableChannels = React.useMemo(() => { const matchingChannels = React.useMemo(() => {
const filtered = channels.filter( const filtered = channels.filter(
(channel) => (channel) =>
channel.channelType !== "dm" && channel.channelType !== "dm" &&
@@ -126,53 +104,114 @@ export function ChannelBrowserDialog({
); );
}, [channels, channelTypeFilter, deferredQuery]); }, [channels, channelTypeFilter, deferredQuery]);
const notJoined = React.useMemo( const currentChannels = React.useMemo(
() => browsableChannels.filter((channel) => !channel.isMember), () => matchingChannels.filter((channel) => channel.archivedAt === null),
[browsableChannels], [matchingChannels],
); );
const joined = React.useMemo( const joinedChannels = React.useMemo(
() => browsableChannels.filter((channel) => channel.isMember), () => currentChannels.filter((channel) => channel.isMember),
[browsableChannels], [currentChannels],
);
const hasArchivedJoinedChannels = React.useMemo(
() => joined.some((channel) => channel.archivedAt !== null),
[joined],
); );
// Flat list for keyboard navigation: not-joined first, then joined const archivedChannels = React.useMemo(
const allItems = React.useMemo( () => matchingChannels.filter((channel) => channel.archivedAt !== null),
() => [...notJoined, ...joined], [matchingChannels],
[notJoined, joined],
); );
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(() => { React.useEffect(() => {
if (!open) { if (!open) {
setQuery(""); setQuery("");
setSelectedIndex(0); setActiveTab("all");
setSelectedIndex(null);
setJoiningChannelId(null); setJoiningChannelId(null);
return; return;
} }
const timeout = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(timeout);
};
}, [open]); }, [open]);
React.useEffect(() => { React.useEffect(() => {
setSelectedIndex((current) => { setSelectedIndex((current) => {
if (allItems.length === 0) { if (current === null || orderedVisibleChannels.length === 0) {
return 0; return null;
} }
return Math.min(current, allItems.length - 1); return Math.min(current, orderedVisibleChannels.length - 1);
}); });
}, [allItems]); }, [orderedVisibleChannels]);
async function handleJoin(channelId: string) { async function handleJoin(channelId: string) {
setJoiningChannelId(channelId); setJoiningChannelId(channelId);
@@ -191,144 +230,199 @@ export function ChannelBrowserDialog({
onSelectChannel(channel.id); 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 ( return (
<Dialog onOpenChange={onOpenChange} open={open}> <Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent <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={ data-testid={
isForumMode ? "forum-browser-dialog" : "channel-browser-dialog" 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"> <DialogHeader className="space-y-0 pb-5">
<DialogTitle className="flex items-center gap-3"> <div className="flex items-center justify-between gap-4">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-xs"> <DialogTitle>{browseTitle}</DialogTitle>
<Compass className="h-4 w-4" /> <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">
</span> <X className="h-4 w-4" />
{browseTitle} <span className="sr-only">Close</span>
</DialogTitle> </DialogClose>
<DialogDescription>{browseDescription}</DialogDescription> </div>
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-xs"> <label
<Search className="h-4 w-4 text-muted-foreground" /> 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"
<Input htmlFor="channel-browser-search"
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0" >
<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" data-testid="channel-browser-search"
id="channel-browser-search"
onChange={(event) => { onChange={(event) => {
setQuery(event.target.value); setQuery(event.target.value);
setSelectedIndex(0); setSelectedIndex(null);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "ArrowDown" && allItems.length > 0) { if (
event.key === "ArrowDown" &&
orderedVisibleChannels.length > 0
) {
event.preventDefault(); event.preventDefault();
setSelectedIndex((current) => setSelectedIndex((current) =>
Math.min(current + 1, allItems.length - 1), current === null
? 0
: Math.min(
current + 1,
orderedVisibleChannels.length - 1,
),
); );
return; return;
} }
if (event.key === "ArrowUp" && allItems.length > 0) { if (
event.key === "ArrowUp" &&
orderedVisibleChannels.length > 0
) {
event.preventDefault(); event.preventDefault();
setSelectedIndex((current) => Math.max(current - 1, 0)); setSelectedIndex((current) =>
current === null
? orderedVisibleChannels.length - 1
: Math.max(current - 1, 0),
);
return; return;
} }
if ( if (
event.key === "Enter" && event.key === "Enter" &&
!event.nativeEvent.isComposing && !event.nativeEvent.isComposing &&
selectedItem orderedVisibleChannels.length > 0
) { ) {
event.preventDefault(); event.preventDefault();
handleSelect(selectedItem); handleSelect(selectedItem ?? orderedVisibleChannels[0]);
} }
}} }}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
ref={inputRef} ref={inputRef}
spellCheck={false}
type="text"
value={query} 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} {BROWSE_CHANNELS_SHORTCUT_HINT}
</span> </span>
</div> </label>
</DialogHeader> </DialogHeader>
<div className="max-h-[60vh] overflow-y-auto"> <div className="h-[min(60vh,30rem)] overflow-hidden">
{browsableChannels.length === 0 ? ( <div className="flex h-full flex-col">
deferredQuery.length > 0 ? ( <Tabs
<BrowseState className="shrink-0"
description="Try a different name or keyword." onValueChange={(value) => {
icon={Search} setActiveTab(value as BrowserTab);
title={`No ${entityLabel}s match your search`} setSelectedIndex(null);
/> }}
) : ( value={activeTab}
<BrowseState >
description={`All open ${entityLabel}s are available in the sidebar. Create a new ${entityLabel} to get started.`} <TabsList
icon={Compass} className="relative h-auto w-full justify-start gap-6 rounded-none border-b border-border/70 bg-transparent p-0 text-muted-foreground"
title={`No ${entityLabel}s to browse`} ref={tabListRef}
/> >
) <span
) : ( aria-hidden="true"
<div className="p-3"> 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"
{notJoined.length > 0 ? ( data-ready={tabIndicator.width > 0}
<> data-testid="channel-browser-tab-indicator"
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground"> style={{
<span> transform: `translate3d(${tabIndicator.left}px, 0, 0) scaleX(${tabIndicator.width})`,
{notJoined.length} {entityLabel} }}
{notJoined.length !== 1 ? "s" : ""} to join />
</span> <TabsTrigger
<span>Enter to view</span> 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"
</div> ref={(element) => {
<div className="space-y-2"> tabTriggerRefs.current.all = element;
{notJoined.map((channel) => { }}
const flatIndex = allItems.indexOf(channel); value="all"
return ( >
<ChannelCard {allTabLabel}
channel={channel} </TabsTrigger>
isJoining={joiningChannelId === channel.id} <TabsTrigger
isSelected={flatIndex === selectedIndex} 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"
key={channel.id} ref={(element) => {
onJoin={() => { tabTriggerRefs.current.joined = element;
void handleJoin(channel.id); }}
}} value="joined"
onMouseEnter={() => setSelectedIndex(flatIndex)} >
onSelect={() => handleSelect(channel)} 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"
</div> ref={(element) => {
</> tabTriggerRefs.current.archived = element;
) : null} }}
value="archived"
>
Archived
</TabsTrigger>
</TabsList>
</Tabs>
{joined.length > 0 ? ( <div className="min-h-0 flex-1 overflow-y-auto pb-6 pt-4">
<> {orderedVisibleChannels.length === 0 ? (
<div className="mb-3 mt-4 flex items-center px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground"> <BrowseState
<span>Joined</span> description={emptyDescription}
</div> icon={deferredQuery.length > 0 ? Search : Compass}
<div className="space-y-2"> title={emptyTitle}
{joined.map((channel) => { />
const flatIndex = allItems.indexOf(channel); ) : (
return ( <div className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55">
<ChannelCard {orderedVisibleChannels.map((channel, index) => (
channel={channel} <ChannelCard
isJoining={false} channel={channel}
isSelected={flatIndex === selectedIndex} isJoining={joiningChannelId === channel.id}
key={channel.id} isSelected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(flatIndex)} key={channel.id}
onSelect={() => handleSelect(channel)} onJoin={
/> !channel.isMember
); ? () => {
})} void handleJoin(channel.id);
</div> }
</> : undefined
) : null} }
onSelect={() => handleSelect(channel)}
/>
))}
</div>
)}
</div> </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> </DialogContent>
</Dialog> </Dialog>
@@ -340,80 +434,80 @@ function ChannelCard({
isJoining, isJoining,
isSelected, isSelected,
onJoin, onJoin,
onMouseEnter,
onSelect, onSelect,
}: { }: {
channel: Channel; channel: Channel;
isJoining: boolean; isJoining: boolean;
isSelected: boolean; isSelected: boolean;
onJoin?: () => void; onJoin?: () => void;
onMouseEnter: () => void;
onSelect: () => void; onSelect: () => void;
}) { }) {
const memberLabel = `${channel.memberCount} ${
channel.memberCount === 1 ? "member" : "members"
}`;
return ( return (
<button <div
className={ className={
isSelected isSelected
? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-xs outline-hidden transition-colors" ? "group/channel-row flex min-h-16 items-center gap-4 bg-muted/40 px-4 py-3 transition-colors duration-150 ease-out"
: "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 px-4 py-3 transition-colors duration-150 ease-out hover:bg-muted/40"
} }
data-testid={`browse-channel-${channel.name}`} data-testid={`browse-channel-${channel.name}`}
onClick={onSelect}
onMouseEnter={onMouseEnter}
type="button"
> >
<div className="flex items-start gap-3"> <button
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground"> 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"
{channel.channelType === "forum" ? ( onClick={(event) => {
<FileText className="h-4 w-4" /> event.stopPropagation();
) : ( onSelect();
<Hash className="h-4 w-4" /> }}
)} type="button"
</div> >
<div className="min-w-0">
<div className="min-w-0 flex-1"> <div className="flex min-w-0 items-center gap-1.5">
<div className="flex flex-wrap items-center gap-2"> <span className="shrink-0 text-sm font-normal text-muted-foreground">
<p className="text-sm font-semibold tracking-tight"> #
</span>
<p className="min-w-0 truncate text-base font-medium tracking-tight">
{channel.name} {channel.name}
</p> </p>
<Badge variant="secondary">{channel.channelType}</Badge>
{channel.archivedAt ? ( {channel.archivedAt ? (
<Badge variant="warning">archived</Badge> <Badge className="ml-1 shrink-0" variant="warning">
archived
</Badge>
) : null} ) : 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> </div>
{channel.description ? ( <p className="mt-1 truncate text-sm text-muted-foreground">
<p className="mt-1 text-sm leading-6 text-muted-foreground line-clamp-2"> <span>{memberLabel}</span>
{channel.description} {channel.description ? (
</p> <>
) : null} <span className="px-1.5">·</span>
<span title={channel.description}>{channel.description}</span>
</>
) : null}
</p>
</div> </div>
</button>
{!channel.isMember && onJoin ? ( {!channel.isMember && onJoin ? (
<Button <Button
className="shrink-0" className={
disabled={isJoining} isJoining
onClick={(event) => { ? "shrink-0"
event.stopPropagation(); : "shrink-0 opacity-0 transition-opacity duration-150 ease-out group-hover/channel-row:opacity-100 group-focus-within/channel-row:opacity-100"
onJoin(); }
}} disabled={isJoining}
size="sm" onClick={(event) => {
type="button" event.stopPropagation();
variant="default" onJoin();
> }}
<LogIn className="mr-1.5 h-3.5 w-3.5" /> size="sm"
{isJoining ? "Joining..." : "Join"} type="button"
</Button> variant="default"
) : null} >
</div> {isJoining ? "Joining..." : "Join"}
</button> </Button>
) : null}
</div>
); );
} }

View File

@@ -321,9 +321,12 @@ export function useRichTextEditor({
], ],
editorProps: { editorProps: {
attributes: { attributes: {
autocapitalize: "none",
autocorrect: "off",
class: 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", "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", "data-testid": "message-input",
spellcheck: "false",
}, },
// ArrowUp in an empty composer → edit your last message (Slack // ArrowUp in an empty composer → edit your last message (Slack
// parity). Handled here in ProseMirror's own DOM `keydown` hook — // 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"> <div className="relative flex min-w-0 flex-1 items-center">
<input <input
ref={inputRef} ref={inputRef}
autoCapitalize="none"
autoCorrect="off"
className={cn( className={cn(
"h-7 w-full rounded-md border border-input bg-transparent px-2 pr-20 text-sm", "h-7 w-full rounded-md border border-input bg-transparent px-2 pr-20 text-sm",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground",
@@ -70,6 +72,7 @@ export function ChannelFindBar({
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Find in channel" placeholder="Find in channel"
spellCheck={false}
type="text" type="text"
value={query} 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 * as React from "react";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types"; import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog"; import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input"; 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"; import { Textarea } from "@/shared/ui/textarea";
/** Default TTL for ephemeral channels: 1 day of inactivity. */ /** Default TTL for ephemeral channels: 1 day of inactivity. */
const EPHEMERAL_TTL_SECONDS = 86400; 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"; type ChannelKind = "stream" | "forum";
@@ -136,7 +141,10 @@ export function CreateChannelDialog({
> >
<ChooserDialogContent <ChooserDialogContent
className="max-w-lg" className="max-w-lg"
contentClassName="pt-3"
data-testid="create-channel-dialog" data-testid="create-channel-dialog"
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={`Create a new ${kindLabel}`} title={`Create a new ${kindLabel}`}
description={ description={
channelKind === "forum" channelKind === "forum"
@@ -165,7 +173,7 @@ export function CreateChannelDialog({
} }
> >
<form <form
className="space-y-4" className="space-y-5"
id="create-channel-form" id="create-channel-form"
onSubmit={(event) => { onSubmit={(event) => {
void handleSubmit(event); void handleSubmit(event);
@@ -179,24 +187,34 @@ export function CreateChannelDialog({
> >
Name Name
</label> </label>
<Input <div
autoCapitalize="none" className={cn(
autoComplete="off" "flex h-9 items-center px-3",
autoCorrect="off" CREATE_FIELD_SHELL_CLASS,
data-testid="create-channel-name" )}
disabled={isCreating} >
id="create-channel-name" <Input
onChange={(event) => { autoCapitalize="none"
setName(event.target.value); autoComplete="off"
setErrorMessage(null); autoCorrect="off"
}} className={cn("h-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
placeholder={ data-testid="create-channel-name"
channelKind === "forum" ? "design-discussions" : "release-notes" disabled={isCreating}
} id="create-channel-name"
ref={nameInputRef} onChange={(event) => {
spellCheck={false} setName(event.target.value);
value={name} setErrorMessage(null);
/> }}
placeholder={
channelKind === "forum"
? "design-discussions"
: "release-notes"
}
ref={nameInputRef}
spellCheck={false}
value={name}
/>
</div>
</div> </div>
{/* Description */} {/* Description */}
@@ -210,91 +228,137 @@ export function CreateChannelDialog({
(optional) (optional)
</span> </span>
</label> </label>
<Textarea <div className={CREATE_FIELD_SHELL_CLASS}>
className="min-h-16 resize-none" <Textarea
data-testid="create-channel-description" className={cn(
disabled={isCreating} "min-h-16 resize-none px-3 py-2",
id="create-channel-description" CREATE_FIELD_CONTROL_CLASS,
onChange={(event) => { )}
setDescription(event.target.value); data-testid="create-channel-description"
setErrorMessage(null); disabled={isCreating}
}} id="create-channel-description"
placeholder={`What this ${kindLabel} is for`} onChange={(event) => {
rows={2} setDescription(event.target.value);
value={description} setErrorMessage(null);
/> }}
placeholder={`What this ${kindLabel} is for`}
rows={2}
value={description}
/>
</div>
</div> </div>
{/* Options */} {/* Type */}
<div className="space-y-3"> <div className="space-y-2">
<div className="flex items-center justify-between gap-3"> <p className="text-sm font-medium text-foreground">Type</p>
<label <Tabs
className="flex items-center gap-1.5 text-sm text-muted-foreground" className="w-full"
htmlFor="create-channel-private" data-testid="create-channel-ephemeral"
> onValueChange={(value) => setEphemeral(value === "temporary")}
<Lock className="h-3.5 w-3.5" /> value={ephemeral ? "temporary" : "ongoing"}
Private — only visible to invited members >
</label> <TabsList className="relative grid h-auto w-full grid-cols-2 items-stretch rounded-xl bg-muted/45 p-1 text-muted-foreground">
<Switch <span
checked={visibility === "private"} aria-hidden="true"
data-testid="create-channel-visibility" 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"
disabled={isCreating} data-testid="create-channel-type-indicator"
id="create-channel-private" style={{
onCheckedChange={(checked) => transform: ephemeral
setVisibility(checked ? "private" : "open") ? "translate3d(100%, 0, 0)"
} : "translate3d(0, 0, 0)",
/> }}
</div> />
<div className="flex items-center justify-between gap-3"> <TabsTrigger
<label 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"
className="flex items-center gap-1.5 text-sm text-muted-foreground" disabled={isCreating}
htmlFor="create-channel-ephemeral" value="ongoing"
> >
<Zap className="h-3.5 w-3.5" /> <Hash className="mt-0.5 h-4 w-4 shrink-0 text-current" />
Ephemeral — auto-archives after 1 day of inactivity <span className="min-w-0 space-y-0.5">
</label> <span className="block text-sm font-medium">Ongoing</span>
<Switch <span className="block text-xs leading-4 text-muted-foreground/45 group-data-[state=active]/type-tab:text-muted-foreground/65">
checked={ephemeral} For projects, teams, and recurring conversations.
disabled={isCreating} </span>
id="create-channel-ephemeral" </span>
onCheckedChange={setEphemeral} </TabsTrigger>
/> <TabsTrigger
</div> 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> </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 */} {/* Template Selector */}
<div className="space-y-1.5"> {templates.length > 0 ? (
<label <div className="space-y-1.5">
className="text-sm font-medium text-foreground" <label
htmlFor="create-channel-template" className="text-sm font-medium text-foreground"
> htmlFor="create-channel-template"
Template{" "} >
<span className="font-normal text-muted-foreground"> Template{" "}
(optional) <span className="font-normal text-muted-foreground">
</span> (optional)
</label> </span>
<select </label>
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" <select
data-testid="create-channel-template" 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"
disabled={isCreating || templates.length === 0} data-testid="create-channel-template"
id="create-channel-template" disabled={isCreating}
onChange={(event) => handleTemplateChange(event.target.value)} id="create-channel-template"
value={selectedTemplateId ?? ""} onChange={(event) => handleTemplateChange(event.target.value)}
> value={selectedTemplateId ?? ""}
<option value=""> >
{templatesQuery.isLoading <option value="">No template</option>
? "Loading..." {templates.map((template: ChannelTemplate) => (
: templates.length === 0 <option key={template.id} value={template.id}>
? "No templates created yet" {template.name}
: "No template"} </option>
</option> ))}
{templates.map((template: ChannelTemplate) => ( </select>
<option key={template.id} value={template.id}> </div>
{template.name} ) : null}
</option>
))}
</select>
</div>
{/* Error */} {/* Error */}
{errorMessage ? ( {errorMessage ? (
@@ -305,3 +369,66 @@ export function CreateChannelDialog({
</Dialog> </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 <ProfileAvatar
avatarUrl={user.avatarUrl} avatarUrl={user.avatarUrl}
className="h-5 w-5 text-[10px] shadow-none" className="h-5 w-5 text-[10px] shadow-none"
iconClassName="h-3 w-3" iconClassName="h-4 w-4"
label={formatUserName(user)} label={formatUserName(user)}
/> />
<span className="font-medium">{formatUserName(user)}</span> <span className="font-medium">{formatUserName(user)}</span>
@@ -198,7 +198,7 @@ export function NewDirectMessageDialog({
}} }}
type="button" type="button"
> >
<X className="h-3.5 w-3.5" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
))} ))}

View File

@@ -15,7 +15,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( 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, className,
)} )}
ref={ref} 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"> <div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
@@ -74,7 +74,7 @@ const AlertDialogTitle = React.forwardRef<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
className={cn("text-lg font-semibold tracking-tight", className)} className={cn("text-xl font-semibold tracking-tight", className)}
ref={ref} ref={ref}
{...props} {...props}
/> />

View File

@@ -19,7 +19,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")} 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.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)); ));

View File

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

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
className={cn( 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", isDark ? "bg-black/60" : "bg-black/10",
className, className,
)} )}
@@ -32,26 +32,34 @@ const DialogOverlay = React.forwardRef<
}); });
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> & {
showCloseButton?: boolean;
};
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none"> <div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<DialogPrimitive.Content <DialogPrimitive.Content
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
> >
{children} {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"> {showCloseButton ? (
<X className="h-4 w-4" /> <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">
<span className="sr-only">Close</span> <X className="h-4 w-4" />
</DialogPrimitive.Close> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</div> </div>
</DialogPortal> </DialogPortal>
@@ -74,7 +82,7 @@ const DialogTitle = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
className={cn("text-lg font-semibold tracking-tight", className)} className={cn("text-xl font-semibold tracking-tight", className)}
ref={ref} ref={ref}
{...props} {...props}
/> />

View File

@@ -6,6 +6,9 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
type={type} type={type}
className={cn( 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", "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) => { >(({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
className={cn( 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", "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, className,