Files
onyx/web/lib/opal/src/components/dropdown/rows.tsx
T

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