Files
tolaria/src/components/WorkspaceSelector.tsx
2026-05-14 12:14:16 +02:00

404 lines
14 KiB
TypeScript

import { CaretUpDown, Check, Cube } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import type { WorkspaceIdentity } from '../types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import {
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
const MIN_POPOVER_WIDTH = 220
const OPEN_COMBOBOX_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', ' '])
interface WorkspaceSelectorProps {
currentWorkspace?: WorkspaceIdentity | null
locale?: AppLocale
onChangeWorkspace?: (workspace: WorkspaceIdentity) => void | Promise<void>
workspaces: WorkspaceIdentity[]
}
function normalizeWorkspaceQuery(query: string): string {
return query.trim().toLowerCase()
}
function workspaceColor(workspace?: WorkspaceIdentity | null): string | undefined {
return workspace?.color ? `var(--accent-${workspace.color})` : undefined
}
function workspaceLightColor(workspace?: WorkspaceIdentity | null): string | undefined {
return workspace?.color ? `var(--accent-${workspace.color}-light)` : undefined
}
function uniqueWorkspaceOptions(
workspaces: WorkspaceIdentity[],
currentWorkspace?: WorkspaceIdentity | null,
): WorkspaceIdentity[] {
const byPath = new Map<string, WorkspaceIdentity>()
for (const workspace of workspaces) {
if (workspace.available === false || workspace.mounted === false) continue
byPath.set(workspace.path, workspace)
}
if (currentWorkspace) byPath.set(currentWorkspace.path, currentWorkspace)
return [...byPath.values()].sort((left, right) => left.label.localeCompare(right.label))
}
function buildWorkspaceOptions({
currentWorkspace,
query,
workspaces,
}: {
currentWorkspace?: WorkspaceIdentity | null
query: string
workspaces: WorkspaceIdentity[]
}): WorkspaceIdentity[] {
const normalizedQuery = normalizeWorkspaceQuery(query)
return uniqueWorkspaceOptions(workspaces, currentWorkspace).filter((workspace) => {
if (normalizedQuery === '') return true
return workspace.label.toLowerCase().includes(normalizedQuery)
|| workspace.alias.toLowerCase().includes(normalizedQuery)
})
}
function initialHighlightedIndex({
currentWorkspace,
options,
}: {
currentWorkspace?: WorkspaceIdentity | null
options: WorkspaceIdentity[]
}): number {
if (options.length === 0) return -1
const currentIndex = options.findIndex((workspace) => workspace.path === currentWorkspace?.path)
return currentIndex >= 0 ? currentIndex : 0
}
function stepHighlightedIndex(current: number, optionsLength: number, direction: 'next' | 'previous') {
if (optionsLength === 0) return -1
if (current < 0) return direction === 'next' ? 0 : optionsLength - 1
return direction === 'next'
? (current + 1) % optionsLength
: (current - 1 + optionsLength) % optionsLength
}
function shouldOpenCombobox(event: KeyboardEvent<HTMLButtonElement>) {
return OPEN_COMBOBOX_KEYS.has(event.key)
}
function reportWorkspaceChangeFailure(error: unknown) {
console.error('Failed to change workspace:', error)
}
function WorkspaceRowLabel({ locale }: { locale: AppLocale }) {
return (
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="workspace-row-icon-slot"
>
<Cube size={14} className="shrink-0" data-testid="workspace-row-icon" />
</span>
<span className="min-w-0 truncate">{translate(locale, 'inspector.properties.workspace')}</span>
</span>
)
}
function WorkspaceSelectorValue({
currentWorkspace,
locale,
}: {
currentWorkspace?: WorkspaceIdentity | null
locale: AppLocale
}) {
if (!currentWorkspace) {
return <span className="truncate text-muted-foreground">{translate(locale, 'inspector.properties.none')}</span>
}
return <span className="min-w-0 truncate">{currentWorkspace.label}</span>
}
function ReadOnlyWorkspaceSelector({
currentWorkspace,
locale,
}: {
currentWorkspace?: WorkspaceIdentity | null
locale: AppLocale
}) {
if (!currentWorkspace) return null
return (
<div className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 px-1.5" style={PROPERTY_PANEL_ROW_STYLE}>
<WorkspaceRowLabel locale={locale} />
<span
className="min-w-0 max-w-full truncate text-[12px] font-medium"
style={{
...PROPERTY_CHIP_STYLE,
background: workspaceLightColor(currentWorkspace) ?? 'var(--muted)',
color: workspaceColor(currentWorkspace) ?? 'var(--secondary-foreground)',
display: 'inline-flex',
alignItems: 'center',
}}
title={currentWorkspace.label}
>
{currentWorkspace.label}
</span>
</div>
)
}
export function WorkspaceSelector({
currentWorkspace,
locale = 'en',
onChangeWorkspace,
workspaces,
}: WorkspaceSelectorProps) {
const optionsForVisibility = useMemo(
() => uniqueWorkspaceOptions(workspaces, currentWorkspace),
[currentWorkspace, workspaces],
)
if (!currentWorkspace || optionsForVisibility.length <= 1) return null
if (!onChangeWorkspace) {
return <ReadOnlyWorkspaceSelector currentWorkspace={currentWorkspace} locale={locale} />
}
return (
<EditableWorkspaceSelector
currentWorkspace={currentWorkspace}
locale={locale}
onChangeWorkspace={onChangeWorkspace}
workspaces={optionsForVisibility}
/>
)
}
function EditableWorkspaceSelector({
currentWorkspace,
locale,
onChangeWorkspace,
workspaces,
}: {
currentWorkspace: WorkspaceIdentity
locale: AppLocale
onChangeWorkspace: (workspace: WorkspaceIdentity) => void | Promise<void>
workspaces: WorkspaceIdentity[]
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [contentWidth, setContentWidth] = useState(MIN_POPOVER_WIDTH)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const listboxId = useId()
const options = useMemo(
() => buildWorkspaceOptions({ currentWorkspace, query, workspaces }),
[currentWorkspace, query, workspaces],
)
useEffect(() => {
if (!open) return
const updateWidth = () => {
const nextWidth = rootRef.current?.getBoundingClientRect().width ?? MIN_POPOVER_WIDTH
setContentWidth(Math.max(nextWidth, MIN_POPOVER_WIDTH))
}
updateWidth()
const frame = requestAnimationFrame(() => inputRef.current?.focus())
window.addEventListener('resize', updateWidth)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener('resize', updateWidth)
}
}, [open])
const openCombobox = () => {
const nextOptions = buildWorkspaceOptions({ currentWorkspace, query: '', workspaces })
setQuery('')
setHighlightedIndex(initialHighlightedIndex({ currentWorkspace, options: nextOptions }))
setOpen(true)
}
const closeCombobox = () => {
setOpen(false)
setQuery('')
setHighlightedIndex(-1)
}
const selectWorkspace = (workspace: WorkspaceIdentity) => {
if (workspace.path !== currentWorkspace.path) {
void Promise.resolve(onChangeWorkspace(workspace)).catch(reportWorkspaceChangeFailure)
}
closeCombobox()
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
openCombobox()
return
}
closeCombobox()
}
const scrollHighlightedOptionIntoView = (index: number) => {
if (index < 0) return
listRef.current?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
}
const moveHighlight = (direction: 'next' | 'previous') => {
setHighlightedIndex((current) => {
const nextIndex = stepHighlightedIndex(current, options.length, direction)
scrollHighlightedOptionIntoView(nextIndex)
return nextIndex
})
}
const handleTriggerPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return
event.preventDefault()
openCombobox()
}
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!shouldOpenCombobox(event)) return
event.preventDefault()
openCombobox()
}
const handleSearchChange = (nextQuery: string) => {
const nextOptions = buildWorkspaceOptions({ currentWorkspace, query: nextQuery, workspaces })
setQuery(nextQuery)
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
}
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
moveHighlight('next')
return
case 'ArrowUp':
event.preventDefault()
moveHighlight('previous')
return
case 'Enter': {
const selected = options.at(highlightedIndex)
if (!selected) return
event.preventDefault()
selectWorkspace(selected)
return
}
case 'Escape':
event.preventDefault()
closeCombobox()
return
default:
return
}
}
return (
<div
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 px-1.5"
style={PROPERTY_PANEL_ROW_STYLE}
data-testid="workspace-selector"
>
<WorkspaceRowLabel locale={locale} />
<div className="flex min-w-0 items-center justify-start">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div ref={rootRef} className="min-w-0">
<Button
type="button"
variant="ghost"
size="sm"
role="combobox"
aria-label={currentWorkspace.label}
aria-controls={listboxId}
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
'h-auto max-w-full justify-between gap-1 border-none px-2 shadow-none ring-inset [&_svg]:text-current',
'hover:ring-1 hover:ring-current',
)}
style={{
...PROPERTY_CHIP_STYLE,
background: workspaceLightColor(currentWorkspace) ?? undefined,
color: workspaceColor(currentWorkspace) ?? undefined,
}}
onPointerDown={handleTriggerPointerDown}
onKeyDown={handleTriggerKeyDown}
>
<span className="flex min-w-0 items-center truncate">
<WorkspaceSelectorValue currentWorkspace={currentWorkspace} locale={locale} />
</span>
<CaretUpDown size={14} aria-hidden="true" />
</Button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
side="left"
sideOffset={4}
className="overflow-hidden p-1"
style={{ width: contentWidth }}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div className="border-b border-border p-1">
<Input
ref={inputRef}
value={query}
placeholder={translate(locale, 'inspector.properties.searchWorkspaces')}
autoComplete="off"
aria-label={translate(locale, 'inspector.properties.searchWorkspaces')}
className="h-8 text-sm"
data-testid="workspace-selector-search-input"
onChange={(event) => handleSearchChange(event.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</div>
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
{options.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
{translate(locale, 'inspector.properties.noMatchingWorkspaces')}
</div>
) : (
<div id={listboxId} role="listbox">
{options.map((workspace, index) => {
const selected = workspace.path === currentWorkspace.path
const highlighted = index === highlightedIndex
return (
<Button
key={workspace.path}
type="button"
variant="ghost"
size="sm"
role="option"
aria-selected={selected}
data-index={index}
className={cn(
'h-auto w-full justify-between px-2 py-1.5 text-left font-normal',
highlighted && 'bg-muted',
)}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectWorkspace(workspace)}
>
<span className="min-w-0 truncate">{workspace.label}</span>
{selected ? <Check size={14} aria-hidden="true" /> : null}
</Button>
)
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
</div>
)
}