mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { LineItemButton } from "@opal/components/buttons/line-item-button/components";
|
|
import { InputSwitch } from "@opal/components/inputs/booleans/input-switch/components";
|
|
import { rowElementId } from "@opal/components/dropdown/model";
|
|
import type {
|
|
DropdownMode,
|
|
DropdownRow,
|
|
DropdownRowProps,
|
|
DropdownSizePreset,
|
|
} from "@opal/components/dropdown/types";
|
|
|
|
/**
|
|
* Whether a mousedown must keep its default: the target is a control that
|
|
* needs focus to work (an input inside a custom row). Anything else keeps
|
|
* focus on the trigger, so a pick never blurs it.
|
|
*/
|
|
export function targetTakesFocus(event: React.MouseEvent): boolean {
|
|
return (
|
|
event.target instanceof Element &&
|
|
event.target.closest("input, textarea, select, [contenteditable]") !== null
|
|
);
|
|
}
|
|
|
|
interface RowProps {
|
|
listId: string;
|
|
mode: DropdownMode;
|
|
row: DropdownRow;
|
|
sizePreset: DropdownSizePreset;
|
|
/** The row's keyboard stop, or -1 while it is withheld (folded). */
|
|
index: number;
|
|
isHighlighted: boolean;
|
|
/** An option that is picked, or an option the trigger text matches exactly. */
|
|
isSelected: boolean;
|
|
onActivate: (row: DropdownRow) => void;
|
|
}
|
|
|
|
/**
|
|
* One row of the list, rendered by kind. Rows are presentational
|
|
* `LineItemButton`s, since the list owns focus and the keyboard and
|
|
* addresses the row through `aria-activedescendant`: the selection reads
|
|
* as the selected state and the keyboard stop as hover. A link action is
|
|
* the one native control, an anchor. Memoized: the list re-renders on every
|
|
* highlight move.
|
|
*/
|
|
export const Row = React.memo(function Row({
|
|
listId,
|
|
mode,
|
|
row,
|
|
sizePreset,
|
|
index,
|
|
isHighlighted,
|
|
isSelected,
|
|
onActivate,
|
|
}: RowProps) {
|
|
const id = rowElementId(listId, row);
|
|
const interaction = isHighlighted ? "hover" : "rest";
|
|
const onClick = (event: React.MouseEvent) => {
|
|
event.stopPropagation();
|
|
if (!row.disabled) onActivate(row);
|
|
};
|
|
// Keep focus on the trigger: the pick must not blur it. A control inside
|
|
// a custom row that needs focus keeps the default.
|
|
const onMouseDown = (event: React.MouseEvent) => {
|
|
if (!targetTakesFocus(event)) event.preventDefault();
|
|
};
|
|
|
|
if (row.kind === "custom") {
|
|
const props: DropdownRowProps = {
|
|
id,
|
|
role: mode === "picker" ? "option" : "menuitem",
|
|
...(mode === "picker" && { "aria-selected": false }),
|
|
...(row.disabled && { "aria-disabled": true }),
|
|
"data-index": index,
|
|
tabIndex: -1,
|
|
onClick,
|
|
onMouseDown,
|
|
};
|
|
return <>{row.render({ highlighted: isHighlighted, props })}</>;
|
|
}
|
|
|
|
if (row.kind === "toggle") {
|
|
return (
|
|
<LineItemButton
|
|
presentational
|
|
selectVariant="select-heavy"
|
|
interaction={interaction}
|
|
disabled={row.disabled}
|
|
rounding={2}
|
|
icon={row.icon}
|
|
title={row.title}
|
|
description={row.description}
|
|
sizePreset={sizePreset}
|
|
padding={sizePreset === "secondary" ? 0 : 0.5}
|
|
variant={row.description ? "heading" : "body"}
|
|
// The switch only shows the state: the row is the control, so the
|
|
// switch takes no pointer or focus of its own.
|
|
rightChildren={
|
|
<div inert className="opal-dropdown-toggle">
|
|
<InputSwitch checked={row.checked} />
|
|
</div>
|
|
}
|
|
id={id}
|
|
data-index={index}
|
|
role={mode === "picker" ? "option" : "menuitemcheckbox"}
|
|
{...(mode === "picker"
|
|
? { "aria-selected": row.checked }
|
|
: { "aria-checked": row.checked })}
|
|
tabIndex={-1}
|
|
onClick={onClick}
|
|
onMouseDown={onMouseDown}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const content = {
|
|
selectVariant: "select-heavy",
|
|
state: isSelected ? "selected" : "empty",
|
|
interaction,
|
|
disabled: row.disabled,
|
|
rounding: 2,
|
|
icon: row.icon,
|
|
title: row.title,
|
|
description: row.description,
|
|
suffix: row.kind === "option" ? row.suffix : undefined,
|
|
tag: row.kind === "option" ? row.tag : undefined,
|
|
descriptionMaxLines:
|
|
row.kind === "option" ? row.descriptionMaxLines : undefined,
|
|
color: row.kind === "action" && row.danger ? "danger" : undefined,
|
|
tooltip:
|
|
row.kind === "action" || row.kind === "option" ? row.tooltip : undefined,
|
|
// Beside the list, so it never covers the row above.
|
|
tooltipSide: "end",
|
|
sizePreset,
|
|
padding: sizePreset === "secondary" ? 0 : 0.5,
|
|
// `body` resolves to `ContentSm`, which has no description, suffix or
|
|
// tag slot; a row with any of them takes the `heading` layout.
|
|
variant:
|
|
row.description || (row.kind === "option" && (row.suffix || row.tag))
|
|
? "heading"
|
|
: "body",
|
|
id,
|
|
"data-index": index,
|
|
role: mode === "picker" ? "option" : "menuitem",
|
|
...(mode === "picker" && { "aria-selected": isSelected }),
|
|
tabIndex: -1,
|
|
onClick,
|
|
onMouseDown,
|
|
} as const;
|
|
|
|
if (row.kind === "action" && row.href !== undefined) {
|
|
// A real link: the click navigates natively, and Enter clicks it, so
|
|
// middle-click, modifier-click and copy-link work.
|
|
return <LineItemButton {...content} href={row.href} target={row.target} />;
|
|
}
|
|
return <LineItemButton presentational {...content} />;
|
|
});
|