Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 99 additions & 57 deletions src/components/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,59 @@
'use client';

import {useState} from 'react';
import {useId, useState} from 'react';

import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {cn} from '@/lib/utils';

function Autocomplete({
emptyMessage,
id,
label,
onChange,
onSelect,
options = [],
value = '',
...props
}: React.ComponentProps<'input'> & {isLoading?: boolean; options: string[]}) {
const [query, setQuery] = useState(String(value));
}: Omit<React.ComponentProps<'input'>, 'onSelect'> & {
emptyMessage?: string;
isLoading?: boolean;
label: string;
onSelect?: (value: string) => void;
options: string[];
}) {
const generatedId = useId();
const inputId = id ?? generatedId;
const listboxId = `${inputId}-listbox`;
const optionId = (index: number) => `${listboxId}-option-${index}`;

const [selectedIndex, setSelectedIndex] = useState(-1);
const [isFocused, setIsFocused] = useState(false);

// keep list open on no matches so it can say so..
const showEmpty =
emptyMessage !== undefined &&
options.length === 0 &&
String(value).trim().length > 0;
const isOpen = isFocused && (options.length > 0 || showEmpty);

let announcement = '';
if (isOpen) {
announcement = showEmpty
? (emptyMessage ?? '')
: `${options.length} ${options.length === 1 ? 'suggestion' : 'suggestions'} available.`;
}

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
onChange?.(e);
setSelectedIndex(-1);
};

const selectOption = (suggestion: string) => {
setSelectedIndex(-1);
onSelect?.(suggestion);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
Expand All @@ -29,10 +62,9 @@ function Autocomplete({
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Enter' && selectedIndex >= 0) {
handleInputChange({
target: {name: props.name || '', value: options[selectedIndex]},
} as React.ChangeEvent<HTMLInputElement>);
setSelectedIndex(-1);
// otherwise form submits term the suggestion replaces
e.preventDefault();
selectOption(options[selectedIndex]);
} else if (e.key === 'Escape') {
setSelectedIndex(-1);
}
Expand All @@ -43,58 +75,68 @@ function Autocomplete({
};

const handleBlur = () => {
setTimeout(() => {
setIsFocused(false);
setSelectedIndex(-1);
}, 200);
setIsFocused(false);
setSelectedIndex(-1);
};

return (
<div className="relative">
<Input
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-expanded={options.length > 0}
onBlur={handleBlur}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
value={query}
{...props}
/>
{options.length > 0 && isFocused && (
<ul
aria-live="polite"
className="top-10 absolute bg-popover border min-w-32 overflow-x-hidden overflow-y-auto p-1 rounded-md shadow-md text-popover-foreground text-sm z-50"
id="suggestions-list"
>
{options.map((suggestion, index) => (
// biome-ignore lint/a11y/useKeyWithClickEvents: handled by parent input's keydown
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: listbox role inferred by aria-live
<li
aria-selected={index === selectedIndex}
className={cn(
'px-4 py-1.5 cursor-pointer hover:bg-accent rounded-md',
{
'bg-accent': index === selectedIndex,
}
)}
key={suggestion}
onClick={() =>
handleInputChange({
target: {
name: props.name || '',
type: 'click',
value: suggestion,
},
} as React.ChangeEvent<HTMLInputElement>)
}
>
{suggestion}
</li>
))}
</ul>
)}
<div className="space-y-2">
<Label htmlFor={inputId}>{label}</Label>
<div className="relative">
<Input
aria-activedescendant={
selectedIndex >= 0 ? optionId(selectedIndex) : undefined
}
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={isOpen}
id={inputId}
onBlur={handleBlur}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
role="combobox"
value={value}
{...props}
/>
{isOpen && (
<ul
className="top-10 absolute bg-popover border min-w-32 overflow-x-hidden overflow-y-auto p-1 rounded-md shadow-md text-popover-foreground text-sm z-50"
id={listboxId}
role="listbox"
>
{showEmpty && (
<li className="px-4 py-1.5 text-muted-foreground">
{emptyMessage}
</li>
)}
{options.map((suggestion, index) => (
<li
aria-selected={index === selectedIndex}
className={cn(
'px-4 py-1.5 cursor-pointer hover:bg-accent rounded-md',
{
'bg-accent': index === selectedIndex,
}
)}
id={optionId(index)}
key={suggestion}
// keeps focus on the input
onMouseDown={e => {
e.preventDefault();
selectOption(suggestion);
}}
role="option"
>
{suggestion}
</li>
))}
</ul>
)}
<span aria-live="polite" className="sr-only" role="status">
{announcement}
</span>
</div>
</div>
);
}
Expand Down
57 changes: 35 additions & 22 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type CopyButtonProps = Omit<

type CopyState = 'error' | 'idle' | 'success';

const COPY_STATUS_MESSAGES: Record<CopyState, string> = {
error: 'Copy failed. Select the text and copy it manually.',
idle: '',
success: 'Copied to clipboard',
};

export function CopyButton(props: CopyButtonProps) {
const {className, text, ...rest} = props;
const [, copy] = useCopyToClipboard();
Expand All @@ -24,27 +30,34 @@ export function CopyButton(props: CopyButtonProps) {
useTimeout(() => setCopyState('idle'), copyState !== 'idle' ? 2000 : null);

return (
<Button
{...rest}
className={cn(className)}
onClick={() => {
copy(text)
.then(() => {
setCopyState('success');
})
.catch(error => {
setCopyState('error');
console.error('Failed to copy', error);
alert(`Failed to copy to clipboard: ${error}`);
});
}}
size="icon"
variant="ghost"
>
{copyState === 'idle' && <Clipboard />}
{copyState === 'success' && <ClipboardCheck className="text-green-600" />}
{copyState === 'error' && <ClipboardX className="text-red-600" />}
<span className="sr-only">Copy to clipboard</span>
</Button>
<>
<Button
{...rest}
className={cn(className)}
onClick={() => {
copy(text)
.then(() => {
setCopyState('success');
})
.catch(error => {
setCopyState('error');
console.error('Failed to copy', error);
});
}}
size="icon"
type="button"
variant="ghost"
>
{copyState === 'idle' && <Clipboard />}
{copyState === 'success' && (
<ClipboardCheck className="text-green-600" />
)}
{copyState === 'error' && <ClipboardX className="text-red-600" />}
<span className="sr-only">Copy to clipboard</span>
</Button>
<span aria-live="polite" className="sr-only" role="status">
{COPY_STATUS_MESSAGES[copyState]}
</span>
</>
);
}
29 changes: 29 additions & 0 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {Button} from '@/components/ui/button';

export function EmptyState({
action,
description,
onAction,
title,
}: {
action: string;
description: string;
onAction: () => void;
title: string;
}) {
return (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="font-medium">{title}</p>
<p className="mt-1 text-muted-foreground text-sm">{description}</p>
<Button
className="mt-4"
onClick={onAction}
size="sm"
type="button"
variant="outline"
>
{action}
</Button>
</div>
);
}
83 changes: 83 additions & 0 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
type ErrorComponentProps,
Link,
useRouter,
} from '@tanstack/react-router';
import {Loader2, SearchX} from 'lucide-react';

import {ErrorState} from '@/components/ErrorState';
import {PageMain} from '@/components/PageMain';
import {Button} from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';

export function RouteError({error, reset}: ErrorComponentProps) {
const router = useRouter();

const retry = () => {
reset();
router.invalidate();
};

return (
<PageMain>
<Card>
<CardContent className="space-y-4">
<ErrorState
message={error.message || 'This page could not be loaded.'}
title="Something went wrong"
/>
<div className="flex flex-wrap gap-2">
<Button onClick={retry} type="button">
Try again
</Button>
<Button asChild variant="ghost">
<Link to="/">Back to package search</Link>
</Button>
</div>
</CardContent>
</Card>
</PageMain>
);
}

export function RouteNotFound() {
return (
<PageMain>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<SearchX className="size-5 text-muted-foreground" />
Page not found
</CardTitle>
<CardDescription>
This address does not match any page. The package may have been
dropped from the repositories, or the link may be mistyped.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/">Back to package search</Link>
</Button>
</CardContent>
</Card>
</PageMain>
);
}

export function RoutePending() {
return (
<PageMain
className="flex items-center justify-center gap-3 text-muted-foreground"
role="status"
>
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
<span>Loading…</span>
</PageMain>
);
}
Loading
Loading