Polish welcome channel search picker

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-28 09:20:22 +01:00
parent b65fdc9245
commit 846eed7271
3 changed files with 100 additions and 56 deletions

View File

@@ -6,11 +6,15 @@ import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { ACTION_TRAY_SURFACE_CLASS } from "@/shared/ui/actionTray";
import { Button } from "@/shared/ui/button";
import {
MODAL_SEARCH_INPUT_CLASS,
MODAL_SEARCH_SHELL_CLASS,
} from "@/shared/ui/modalSearchStyles";
import { POPOVER_CUSTOM_ENTER_MOTION_CLASS } from "@/shared/ui/popoverSurface";
type WelcomeChannelChipPickerProps = {
channels: Channel[];
insert: { id: string; url: string };
insert: { id: string; title: string; url: string };
onClose: () => void;
onRemove: () => void;
onSelect: (channel: Channel) => void;
@@ -25,7 +29,8 @@ export function WelcomeChannelChipPicker({
onSelect,
position,
}: WelcomeChannelChipPickerProps) {
const [query, setQuery] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const [query, setQuery] = React.useState(insert.url ? insert.title : "");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const matchingChannels = React.useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
@@ -43,6 +48,11 @@ export function WelcomeChannelChipPicker({
.sort((a, b) => a.name.localeCompare(b.name));
}, [channels, query]);
const highlightedChannel = matchingChannels[highlightedIndex];
const isSearching = query.trim().length > 0;
React.useEffect(() => {
inputRef.current?.focus({ preventScroll: true });
}, []);
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (matchingChannels.length === 0) return;
@@ -64,7 +74,7 @@ export function WelcomeChannelChipPicker({
<div
aria-label="Edit channel"
className={cn(
"absolute z-30 w-80 space-y-3 rounded-xl p-4",
"absolute z-30 w-80 space-y-3 overflow-visible rounded-xl p-4",
ACTION_TRAY_SURFACE_CLASS,
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
)}
@@ -90,67 +100,84 @@ export function WelcomeChannelChipPicker({
<X className="h-4 w-4" />
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/50">
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="relative">
<label
className={cn(MODAL_SEARCH_SHELL_CLASS, "mt-0")}
htmlFor={`welcome-channel-search-${insert.id}`}
>
<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
aria-controls={`welcome-channel-results-${insert.id}`}
aria-controls={
isSearching ? `welcome-channel-results-${insert.id}` : undefined
}
aria-activedescendant={
highlightedChannel
isSearching && highlightedChannel
? `welcome-channel-result-${highlightedChannel.id}`
: undefined
}
aria-expanded="true"
aria-expanded={isSearching}
aria-label="Search channels"
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
className={MODAL_SEARCH_INPUT_CLASS}
id={`welcome-channel-search-${insert.id}`}
onChange={(event) => {
setQuery(event.target.value);
setHighlightedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Search channels…"
ref={inputRef}
role="combobox"
spellCheck={false}
value={query}
/>
</div>
<div
aria-label="Channel results"
className="max-h-52 overflow-y-auto p-1"
id={`welcome-channel-results-${insert.id}`}
role="listbox"
>
{matchingChannels.length > 0 ? (
matchingChannels.map((channel, index) => (
<button
aria-selected={insert.url === buildChannelLink(channel.id)}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none",
index === highlightedIndex &&
"bg-accent text-accent-foreground",
)}
data-testid={`welcome-channel-result-${channel.id}`}
id={`welcome-channel-result-${channel.id}`}
key={channel.id}
onClick={() => onSelect(channel)}
onMouseMove={() => setHighlightedIndex(index)}
role="option"
type="button"
>
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{channel.name}</span>
</button>
))
) : (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
No channels found.
</p>
)}
</div>
</label>
{isSearching ? (
<div
aria-label="Channel results"
className="absolute left-0 right-0 top-full z-40 mt-2 max-h-64 overflow-y-auto rounded-xl border border-border/70 bg-background shadow-lg divide-y divide-border/55"
id={`welcome-channel-results-${insert.id}`}
role="listbox"
>
{matchingChannels.length > 0 ? (
matchingChannels.map((channel, index) => (
<button
aria-selected={insert.url === buildChannelLink(channel.id)}
className={cn(
"flex min-h-14 w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none",
index === highlightedIndex && "bg-muted/40",
)}
data-testid={`welcome-channel-result-${channel.id}`}
id={`welcome-channel-result-${channel.id}`}
key={channel.id}
onClick={() => onSelect(channel)}
onMouseMove={() => setHighlightedIndex(index)}
role="option"
type="button"
>
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{channel.name}
</span>
{channel.description ? (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{channel.description}
</span>
) : null}
</span>
</button>
))
) : (
<p className="px-4 py-5 text-center text-sm text-muted-foreground">
No channels found.
</p>
)}
</div>
) : null}
</div>
<Button
className="text-destructive hover:bg-destructive/10 hover:text-destructive"

View File

@@ -16,6 +16,10 @@ import * as React from "react";
import { toast } from "sonner";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "@/features/channels/ui/channelFormStyles";
import { buildChannelLink } from "@/features/messages/lib/channelLink";
import { uploadMediaFile } from "@/shared/api/tauriMedia";
import type { Channel } from "@/shared/api/types";
@@ -350,14 +354,20 @@ function InlineChipEditor({
<X className="h-4 w-4" />
</Button>
</div>
<Input
aria-label={insert.type === "link" ? "Link title" : "Image alt text"}
className="bg-muted/50"
onChange={(event) => onChange({ ...insert, title: event.target.value })}
placeholder={insert.type === "link" ? "Community guide" : "Team photo"}
value={insert.title}
/>
<div className="relative">
<div className={CHANNEL_FORM_FIELD_SHELL_CLASS}>
<Input
aria-label={insert.type === "link" ? "Link title" : "Image alt text"}
className={cn("h-10", CHANNEL_FORM_FIELD_CONTROL_CLASS)}
onChange={(event) =>
onChange({ ...insert, title: event.target.value })
}
placeholder={
insert.type === "link" ? "Community guide" : "Team photo"
}
value={insert.title}
/>
</div>
<div className={cn("relative", CHANNEL_FORM_FIELD_SHELL_CLASS)}>
<span className="pointer-events-none absolute inset-y-0 left-2.5 z-10 flex items-center text-muted-foreground">
{insert.type === "link" ? (
<Link2 className="h-4 w-4" />
@@ -370,7 +380,7 @@ function InlineChipEditor({
insert.type === "link" ? "Link destination" : "Image source"
}
autoCapitalize="off"
className="bg-muted/50 pl-8"
className={cn("h-10 pl-8", CHANNEL_FORM_FIELD_CONTROL_CLASS)}
onChange={(event) => onChange({ ...insert, url: event.target.value })}
placeholder={
insert.type === "link"

View File

@@ -407,11 +407,18 @@ test("channel inserts search the community channel list", async ({ page }) => {
await expect(channelSearch).toBeVisible();
await expect(page.getByLabel("Channel name")).toHaveCount(0);
await expect(page.getByLabel("Channel destination")).toHaveCount(0);
await expect(channelEditor.getByRole("listbox")).toHaveCount(0);
const pickerBeforeSearch = await channelEditor.boundingBox();
await channelSearch.fill("rand");
const pickerWithResults = await channelEditor.boundingBox();
expect(pickerWithResults?.height).toBeCloseTo(
pickerBeforeSearch?.height ?? 0,
0,
);
const randomResult = channelEditor.getByRole("option", {
name: "random",
exact: true,
name: /^random/,
});
await expect(randomResult).toBeVisible();
await waitForAnimations(page);
@@ -426,7 +433,7 @@ test("channel inserts search the community channel list", async ({ page }) => {
await expect(
page
.getByRole("dialog", { name: "Edit channel" })
.getByRole("option", { name: "random", exact: true }),
.getByRole("option", { name: /^random/ }),
).toHaveAttribute("aria-selected", "true");
});