/** @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}
)}
);
}