diff --git a/src/components/LoginPanel.tsx b/src/components/LoginPanel.tsx index 7630f6e..29aa646 100644 --- a/src/components/LoginPanel.tsx +++ b/src/components/LoginPanel.tsx @@ -1,5 +1,7 @@ import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; -import type { LoginSuggestion } from "$/lib/types"; +import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; +import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; +import type { ActorSuggestion } from "$/lib/types"; import { createEffect, Show } from "solid-js"; import { Motion } from "solid-motionone"; import { Icon } from "./shared/Icon"; @@ -40,6 +42,11 @@ export function LoginPanel(props: LoginPanelProps) { input: () => input, value: () => props.value, }); + const combobox = useActorTypeaheadCombobox({ + ariaControls: "login-suggestions", + onSelect: applySuggestion, + typeahead, + }); createEffect(() => { if (props.shakeCount > 0) { @@ -48,36 +55,12 @@ export function LoginPanel(props: LoginPanelProps) { } }); - function applySuggestion(suggestion: LoginSuggestion) { + function applySuggestion(suggestion: ActorSuggestion) { props.onInput(suggestion.handle); typeahead.close(); input?.focus(); } - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - typeahead.moveActiveIndex(1); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - typeahead.moveActiveIndex(-1); - return; - } - - if (event.key === "Escape") { - typeahead.close(); - return; - } - - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { - event.preventDefault(); - applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); - } - } - return (
= 0 - ? `login-suggestions-option-${typeahead.activeIndex()}` - : undefined} - aria-expanded={typeahead.open()} + aria-controls={combobox.a11y.controls} + aria-activedescendant={combobox.a11y.activeDescendant()} + aria-expanded={combobox.a11y.expanded()} autocomplete="username" spellcheck={false} value={props.value} placeholder="alice.bsky.social" onFocus={() => typeahead.focus()} onInput={(event) => props.onInput(event.currentTarget.value)} - onKeyDown={(event) => handleKeyDown(event)} /> - + onKeyDown={(event) => combobox.handleKeyDown(event)} /> + ); } - -function LoginLoadingIndicator(props: { visible: boolean }) { - return ( - - - - - ); -} diff --git a/src/components/actors/ActorSearch.tsx b/src/components/actors/ActorSearch.tsx index 7bcaadc..b9fe320 100644 --- a/src/components/actors/ActorSearch.tsx +++ b/src/components/actors/ActorSearch.tsx @@ -1,5 +1,5 @@ import { AvatarBadge } from "$/components/AvatarBadge"; -import { searchActorSuggestions } from "$/lib/api/actors"; +import { TypeaheadController } from "$/lib/api/typeahead"; import type { ActorSuggestion } from "$/lib/types"; import { type Accessor, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; @@ -13,15 +13,6 @@ type UseActorSuggestionsOptions = { value: Accessor; }; -function normalizeActorSuggestionQuery(value: string) { - const trimmed = value.trim(); - if (trimmed.length < 2 || trimmed.startsWith("did:") || /^https?:\/\//i.test(trimmed)) { - return ""; - } - - return trimmed.replace(/^@/, ""); -} - export function getActorSuggestionHeadline(suggestion: ActorSuggestion) { const displayName = suggestion.displayName?.trim(); return displayName && displayName !== suggestion.handle ? displayName : suggestion.handle.replace(/^@/, ""); @@ -36,7 +27,7 @@ export function useActorSuggestions(options: UseActorSuggestionsOptions) { const [suggestions, setSuggestions] = createSignal([]); createEffect(() => { - const query = normalizeActorSuggestionQuery(options.value()); + const query = TypeaheadController.normalizeQuery(options.value()); const disabled = options.disabled?.() ?? false; const nextRequestId = requestId + 1; requestId = nextRequestId; @@ -52,7 +43,7 @@ export function useActorSuggestions(options: UseActorSuggestionsOptions) { setLoading(true); const timeout = globalThis.setTimeout(() => { - void searchActorSuggestions(query).then((results) => { + void TypeaheadController.searchActor(query).then((results) => { if (requestId !== nextRequestId) { return; } diff --git a/src/components/actors/ActorTypeaheadLoading.tsx b/src/components/actors/ActorTypeaheadLoading.tsx new file mode 100644 index 0000000..254536d --- /dev/null +++ b/src/components/actors/ActorTypeaheadLoading.tsx @@ -0,0 +1,19 @@ +import { Icon } from "$/components/shared/Icon"; +import { Show } from "solid-js"; + +type ActorTypeaheadLoadingProps = { class?: string; iconClass?: string; inline?: boolean; visible: boolean }; + +export function ActorTypeaheadLoading(props: ActorTypeaheadLoadingProps) { + const defaultClass = () => + props.inline + ? "flex items-center text-on-surface-variant" + : "pointer-events-none absolute top-1/2 -translate-y-1/2 text-on-surface-variant right-3"; + + return ( + + + + + ); +} diff --git a/src/components/actors/hooks/useActorTypeaheadCombobox.ts b/src/components/actors/hooks/useActorTypeaheadCombobox.ts new file mode 100644 index 0000000..3d45ed1 --- /dev/null +++ b/src/components/actors/hooks/useActorTypeaheadCombobox.ts @@ -0,0 +1,69 @@ +import type { ActorSuggestion } from "$/lib/types"; +import { type Accessor, createMemo } from "solid-js"; + +type ActorTypeaheadController = { + activeIndex: Accessor; + activeSuggestion: () => ActorSuggestion | null; + close: () => void; + moveActiveIndex: (direction: 1 | -1) => void; + open: Accessor; +}; + +type HandleActorTypeaheadKeyDownOptions = { + onEscape?: () => void; + onSelect: (suggestion: ActorSuggestion) => void; + typeahead: ActorTypeaheadController; +}; + +type UseActorTypeaheadComboboxOptions = HandleActorTypeaheadKeyDownOptions & { ariaControls: string }; + +export function handleActorTypeaheadKeyDown(event: KeyboardEvent, options: HandleActorTypeaheadKeyDownOptions) { + if (event.key === "ArrowDown") { + event.preventDefault(); + options.typeahead.moveActiveIndex(1); + return true; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + options.typeahead.moveActiveIndex(-1); + return true; + } + + if (event.key === "Escape") { + if (options.onEscape) { + options.onEscape(); + } else { + options.typeahead.close(); + } + return true; + } + + if (event.key !== "Enter" || !options.typeahead.open()) { + return false; + } + + const suggestion = options.typeahead.activeSuggestion(); + if (!suggestion) { + return false; + } + + event.preventDefault(); + options.onSelect(suggestion); + return true; +} + +export function useActorTypeaheadCombobox(options: UseActorTypeaheadComboboxOptions) { + const activeDescendant = createMemo(() => + options.typeahead.activeIndex() >= 0 + ? `${options.ariaControls}-option-${options.typeahead.activeIndex()}` + : undefined + ); + const expanded = createMemo(() => options.typeahead.open()); + + function handleKeyDown(event: KeyboardEvent) { + handleActorTypeaheadKeyDown(event, options); + } + + return { a11y: { activeDescendant, controls: options.ariaControls, expanded }, handleKeyDown }; +} diff --git a/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx b/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx index f82a745..c67e3e5 100644 --- a/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx +++ b/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx @@ -1,20 +1,11 @@ import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; -import { Icon } from "$/components/shared/Icon"; -import type { LoginSuggestion } from "$/lib/types"; +import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; +import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; +import type { ActorSuggestion } from "$/lib/types"; import * as logger from "@tauri-apps/plugin-log"; -import { createSignal, Show } from "solid-js"; +import { createSignal } from "solid-js"; import type { ProfileSelection } from "../types"; -function TypeaheadLoading(props: { visible: boolean }) { - return ( - - - - - - ); -} - export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) => void }) { let container: HTMLDivElement | undefined; let input: HTMLInputElement | undefined; @@ -25,6 +16,11 @@ export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) = onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), value, }); + const combobox = useActorTypeaheadCombobox({ + ariaControls: "profile-suggestions", + onSelect: submitSuggestion, + typeahead, + }); function submitManualActor() { const actor = value().trim(); @@ -36,7 +32,7 @@ export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) = props.onSubmit({ actor }); } - function submitSuggestion(suggestion: LoginSuggestion) { + function submitSuggestion(suggestion: ActorSuggestion) { typeahead.close(); props.onSubmit({ actor: suggestion.handle, @@ -46,30 +42,6 @@ export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) = }); } - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - typeahead.moveActiveIndex(1); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - typeahead.moveActiveIndex(-1); - return; - } - - if (event.key === "Escape") { - typeahead.close(); - return; - } - - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { - event.preventDefault(); - submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); - } - } - return (
= 0 - ? `profile-suggestions-option-${typeahead.activeIndex()}` - : undefined} - aria-expanded={typeahead.open()} + aria-controls={combobox.a11y.controls} + aria-activedescendant={combobox.a11y.activeDescendant()} + aria-expanded={combobox.a11y.expanded()} class="ui-input ui-input-strong w-full rounded-xl px-4 py-2.5 pr-10" placeholder="alice.bsky.social" spellcheck={false} value={value()} onFocus={() => typeahead.focus()} onInput={(event) => setValue(event.currentTarget.value)} - onKeyDown={(event) => handleKeyDown(event)} /> + onKeyDown={(event) => combobox.handleKeyDown(event)} /> - + null), + ExplorerController.describeRepo(resolved.did), + ProfileController.getProfile(resolved.did).catch(() => null), ]); const profileData = profile?.status === "available" ? profile.profile : null; const collections = extractCollections(repoData); @@ -204,7 +194,7 @@ export function ExplorerPanel() { } case "collection": { if (resolved.did && resolved.collection) { - const listData = await listRecords(resolved.did, resolved.collection); + const listData = await ExplorerController.listRecords(resolved.did, resolved.collection); finalViewState = { ...viewState, loading: false, @@ -222,8 +212,10 @@ export function ExplorerPanel() { case "record": { if (resolved.did && resolved.collection && resolved.rkey) { const [recordData, labels] = await Promise.all([ - getRecord(resolved.did, resolved.collection, resolved.rkey), - resolved.uri ? queryLabels(resolved.uri).catch(() => ({ labels: [] })) : Promise.resolve({ labels: [] }), + ExplorerController.getRecord(resolved.did, resolved.collection, resolved.rkey), + resolved.uri + ? ExplorerController.queryLabels(resolved.uri).catch(() => ({ labels: [] })) + : Promise.resolve({ labels: [] }), ]); finalViewState = { ...viewState, @@ -335,7 +327,11 @@ export function ExplorerPanel() { }); try { - const nextPage = await listRecords(collectionData.did, collectionData.collection, collectionData.cursor); + const nextPage = await ExplorerController.listRecords( + collectionData.did, + collectionData.collection, + collectionData.cursor, + ); const nextRecords = (nextPage.records as Array>) || []; const nextCursor = (nextPage.cursor as string) || null; @@ -360,7 +356,7 @@ export function ExplorerPanel() { if (!did) return; try { - const result = await exportRepoCar(did); + const result = await ExplorerController.exportRepoCar(did); setStatusMessage({ kind: "success", text: `Saved CAR export to ${result.path}` }); } catch (error) { setStatusMessage({ kind: "error", text: String(error) }); @@ -376,7 +372,7 @@ export function ExplorerPanel() { setStatusMessage(null); try { - await clearLexiconFaviconCache(); + await ExplorerController.clearLexiconFaviconCache(); explorer.resetLexiconIcons(); setStatusMessage({ kind: "success", text: "Cleared explorer icon cache." }); diff --git a/src/components/explorer/ExplorerUrlBar.tsx b/src/components/explorer/ExplorerUrlBar.tsx index 9dd0bb8..63b167f 100644 --- a/src/components/explorer/ExplorerUrlBar.tsx +++ b/src/components/explorer/ExplorerUrlBar.tsx @@ -1,7 +1,9 @@ import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; +import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; +import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; import { ArrowIcon, Icon } from "$/components/shared/Icon"; -import type { LoginSuggestion } from "$/lib/types"; -import { createEffect, createSignal, Show } from "solid-js"; +import type { ActorSuggestion } from "$/lib/types"; +import { createEffect, createSignal } from "solid-js"; type ExplorerUrlBarProps = { value: string; @@ -40,6 +42,11 @@ function UrlInputForm(props: { value: string; onInput: (value: string) => void; input: () => input, value: () => props.value, }); + const combobox = useActorTypeaheadCombobox({ + ariaControls: "explorer-suggestions", + onSelect: applySuggestion, + typeahead, + }); createEffect(() => { if (focused() && typeahead.suggestions().length > 0 && props.value.trim().startsWith("@")) { @@ -52,7 +59,7 @@ function UrlInputForm(props: { value: string; onInput: (value: string) => void; props.onSubmit(props.value); } - function applySuggestion(suggestion: LoginSuggestion) { + function applySuggestion(suggestion: ActorSuggestion) { const nextValue = suggestion.handle.startsWith("@") ? suggestion.handle : `@${suggestion.handle}`; props.onInput(nextValue); typeahead.close(); @@ -60,30 +67,6 @@ function UrlInputForm(props: { value: string; onInput: (value: string) => void; input?.focus(); } - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - typeahead.moveActiveIndex(1); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - typeahead.moveActiveIndex(-1); - return; - } - - if (event.key === "Escape") { - typeahead.close(); - return; - } - - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { - event.preventDefault(); - applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); - } - } - return ( { @@ -106,11 +89,9 @@ function UrlInputForm(props: { value: string; onInput: (value: string) => void; type="text" role="combobox" aria-autocomplete="list" - aria-controls="explorer-suggestions" - aria-activedescendant={typeahead.activeIndex() >= 0 - ? `explorer-suggestions-option-${typeahead.activeIndex()}` - : undefined} - aria-expanded={typeahead.open()} + aria-controls={combobox.a11y.controls} + aria-activedescendant={combobox.a11y.activeDescendant()} + aria-expanded={combobox.a11y.expanded()} value={props.value} spellcheck={false} onInput={(event) => props.onInput(event.currentTarget.value)} @@ -122,14 +103,10 @@ function UrlInputForm(props: { value: string; onInput: (value: string) => void; setFocused(false); typeahead.close(); }} - onKeyDown={(event) => handleKeyDown(event)} + onKeyDown={(event) => combobox.handleKeyDown(event)} class="flex-1 bg-transparent text-sm font-mono outline-none text-on-surface placeholder:text-on-surface-variant/50" placeholder="at://did:... or @handle or https://pds..." /> - - - - +