mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
Polish welcome channel search picker
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user