/** @jsxImportSource react */ import { useEffect, useId, useRef, useState, type CSSProperties } from "react"; import { Box, ScrollBox, Text, type ScrollBoxRenderable } from "../../../../ui"; import { TextAttributes } from "../../../../ui"; import type { ListRowState, ListViewItem, ListViewProps } from "../../../../components/ui/list-view"; import { blendHex, hoverBg, type ThemeColors } from "../../../../theme/colors"; import { useThemeColors } from "../../../../theme/theme-context"; import { isInsideDialogSurface } from "../host/focus-scope"; import { isEditableKeyboardTarget } from "../key-event"; import { CONTROL_RADIUS, panelBorder, selectedPanelFill, subtlePanelFill, } from "./control-styles"; function DefaultDesktopRow({ item, selected, }: { item: ListViewItem; selected: boolean; }) { const colors = useThemeColors(); return ( {item.label} {item.detail && ( {item.detail} )} ); } function listRowStyle(selected: boolean, disabled: boolean, colors: ThemeColors): CSSProperties { return { borderRadius: CONTROL_RADIUS, border: `1px solid ${selected ? colors.borderFocused : "transparent"}`, boxShadow: selected ? `inset 0 1px 0 ${blendHex(colors.bg, colors.textBright, 0.06)}` : undefined, boxSizing: "border-box", cursor: disabled ? "default" : "pointer", maxWidth: "100%", minWidth: 0, paddingInline: 10, }; } export function WebListView({ items, selectedIndex, scrollIndex, onSelect, onActivate, renderRow, getRowBackgroundColor, showSelectedDescription = false, emptyMessage = "Nothing to show.", bgColor, selectedBgColor, hoverBgColor, rowGap = 1, rowHeight = 1, surface, height, flexGrow, scrollable = false, selectOnHover = false, autoScrollToIndex = true, checkboxRows = false, onMouseScroll, remoteLabel, }: ListViewProps) { const colors = useThemeColors(); const [hoveredIndex, setHoveredIndex] = useState(null); const scrollRef = useRef(null); const listDomId = `gloom-list-${useId()}`; const baseBg = bgColor ?? colors.bg; const activeBg = selectedBgColor ?? selectedPanelFill(colors); const rowHoverBg = hoverBgColor ?? hoverBg(colors); const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : undefined; const activeScrollIndex = scrollIndex ?? selectedIndex; const rowStride = rowHeight + rowGap; const tabStopIndex = selectedIndex >= 0 && items[selectedIndex] && !items[selectedIndex].disabled ? selectedIndex : items.findIndex((item) => !item.disabled); const effectiveSurface = surface ?? (scrollable ? "framed" : "plain"); const frameStyle = effectiveSurface === "plain" ? { border: "none", borderRadius: 0, padding: 0, backgroundColor: "transparent", } : { border: `1px solid ${panelBorder(colors)}`, borderRadius: CONTROL_RADIUS, padding: 4, backgroundColor: subtlePanelFill(colors), }; useEffect(() => { if (!scrollable || !autoScrollToIndex || activeScrollIndex < 0) return; const scrollBox = scrollRef.current; if (!scrollBox) return; const safeIndex = Math.min(activeScrollIndex, items.length - 1); const viewportHeight = Math.max(scrollBox.viewport?.height ?? 0, 1); const rowTop = safeIndex * rowStride; const rowBottom = rowTop + rowHeight; if (rowTop < scrollBox.scrollTop) { scrollBox.scrollTo(rowTop); } else if (rowBottom > scrollBox.scrollTop + viewportHeight) { scrollBox.scrollTo(rowBottom - viewportHeight); } }, [activeScrollIndex, autoScrollToIndex, items.length, rowHeight, rowStride, scrollable]); useEffect(() => { if (!scrollable) return; const scrollBox = scrollRef.current; if (!scrollBox) return; if (scrollBox.verticalScrollBar) { scrollBox.verticalScrollBar.visible = items.length * rowStride - rowGap > (scrollBox.viewport?.height ?? 0); } }, [items.length, height, flexGrow, rowGap, rowStride, scrollable]); // The dialog or pane around the list moves its cursor (j/k, Home/End). A row // Tab or a press left focused follows it, or Space and Enter would act on the // row the focus was left on instead of the highlighted one. useEffect(() => { if (selectedIndex < 0 || typeof document === "undefined") return; const list = document.getElementById(listDomId); const active = document.activeElement; if (!list || !active || !list.contains(active) || isEditableKeyboardTarget(active)) return; const row = list.querySelectorAll('[role="option"]')[selectedIndex]; if (row && !row.contains(active)) row.focus({ preventScroll: scrollable }); }, [listDomId, scrollable, selectedIndex]); const rows = items.length === 0 ? ( {emptyMessage} ) : items.map((item, index) => { const selected = index === selectedIndex; const hovered = index === hoveredIndex && !selected; const disabled = item.disabled === true; const state: ListRowState = { selected, hovered, disabled }; const rowBg = getRowBackgroundColor?.(item, state, index) ?? (selected ? activeBg : hovered ? rowHoverBg : baseBg); return ( { if (!disabled) { setHoveredIndex((current) => (current === index ? current : index)); if (selectOnHover) onSelect?.(index); } }} onMouseDownCapture={(event: { target?: unknown; preventDefault?: () => void }) => { // Outside a dialog the keyboard belongs to the pane, not the DOM: a // pressed row keeps no focus that would take Enter and the arrows // from the pane's own cursor, the way a pressed button lets go. if (isEditableKeyboardTarget(event.target as EventTarget | null) || isInsideDialogSurface(event.target)) return; event.preventDefault?.(); }} onMouseDown={() => { if (disabled) return; onSelect?.(index); onActivate?.(item, index); }} data-gloom-role="desktop-list-row" role="option" aria-selected={selected} aria-disabled={disabled || undefined} tabIndex={index === tabStopIndex ? 0 : -1} onKeyDown={(event: React.KeyboardEvent) => { if (disabled) return; if (event.key === " " || (event.key === "Enter" && !checkboxRows)) { event.preventDefault(); event.stopPropagation(); onSelect?.(index); onActivate?.(item, index); return; } const direction = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; if (!direction) return; event.preventDefault(); event.stopPropagation(); let nextIndex = index + direction; while (items[nextIndex]?.disabled) nextIndex += direction; if (!items[nextIndex]) return; onSelect?.(nextIndex); const rows = event.currentTarget.parentElement?.querySelectorAll('[role="option"]'); rows?.[nextIndex]?.focus(); }} style={listRowStyle(selected, disabled, colors)} > {renderRow ? renderRow(item, state, index) : } ); }); return ( {scrollable ? ( {rows} ) : ( {rows} )} {showSelectedDescription && selectedItem?.description && ( {selectedItem.description} )} ); }