// ~/src/siteplane/components/list-box/list-box.tsx "use client"; import * as React from "react"; import { ListBox as ListBoxPrimitive, ListBoxItem as ListBoxItemPrimitive, type Selection, } from "react-aria-components"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { cn } from "../../system/utils/cn"; import { Button, type ButtonBaseProps } from "../button"; import "./list-box.css"; export type ListBoxSelectionAppearance = "check" | "row"; type ListBoxVisualContextValue = { selectionAppearance: ListBoxSelectionAppearance; size: SiteplaneControlSize; }; const ListBoxVisualContext = React.createContext(null); function getSelectionValue(selection: Selection): string | undefined { if (selection === "all") return undefined; const value = selection.values().next().value; return value === undefined ? undefined : String(value); } function getTextValue(children: React.ReactNode): string { return React.Children.toArray(children) .map((child) => typeof child === "string" || typeof child === "number" ? String(child) : React.isValidElement<{ children?: React.ReactNode }>(child) ? getTextValue(child.props.children) : "", ) .join(" ") .trim(); } export interface ListBoxProps extends Omit< React.ComponentProps<"div">, "aria-orientation" | "defaultValue" | "role" > { defaultValue?: string | undefined; onValueChange?: ((value: string) => void) | undefined; selectionAppearance?: ListBoxSelectionAppearance | undefined; size?: SiteplaneControlSize | undefined; value?: string | undefined; } export function ListBox({ children, className, defaultValue, onValueChange, selectionAppearance = "check", size, value, ...props }: ListBoxProps): React.ReactElement { const controlled = value !== undefined; const resolvedSize = useResolvedControlSize(size); const visualContext = React.useMemo( () => ({ selectionAppearance, size: resolvedSize }), [resolvedSize, selectionAppearance], ); return ( )} className={cn("sp-list-box sp-base-list-box", className)} data-foundation="list-box" data-selection-appearance={selectionAppearance} data-size={resolvedSize} data-slot="list-box" {...(controlled || defaultValue === undefined ? {} : { defaultSelectedKeys: [defaultValue] })} disallowEmptySelection onSelectionChange={(selection) => { const nextValue = getSelectionValue(selection); if (nextValue !== undefined) onValueChange?.(nextValue); }} orientation="vertical" {...(controlled ? { selectedKeys: [value] } : {})} selectionBehavior="replace" selectionMode="single" shouldFocusWrap > {children} ); } export interface ListBoxItemProps extends Omit< ButtonBaseProps, | "align" | "aria-disabled" | "aria-selected" | "onClick" | "onKeyDown" | "role" | "render" | "size" | "tabIndex" | "value" | "variant" | "width" > { "data-slot"?: string; textValue?: string; value: string; } export function ListBoxItem({ "data-slot": dataSlot = "list-box-item", children, className, disabled = false, multiline = true, textValue, value, ...props }: ListBoxItemProps): React.ReactElement { const context = React.useContext(ListBoxVisualContext); if (!context) { throw new Error("ListBoxItem must be rendered inside ListBox."); } return ( ); } // ~/src/siteplane/components/list-box/list-box.css @layer theme, base, components, siteplane, utilities; @layer siteplane { .sp-list-box-item[data-chassis][data-size="sm"] { min-height: var(--sp-rw-control-sm); } .sp-list-box-item[data-chassis][data-size="md"] { min-height: var(--sp-rw-control-md); } .sp-list-box-item[data-chassis][data-size="lg"] { min-height: var(--sp-rw-control-lg); } .sp-list-box-item[data-chassis][data-size="xl"] { min-height: var(--sp-rw-control-xl); } /* The selected option: the row's own fill and text, set through the * Button's fill and text, so a className on the item still restyles it. */ :root .sp-list-box-item[data-chassis][data-selected] { --sp-rw-button-fill: var(--sp-semantic-color-secondary); --sp-rw-button-text: var(--sp-semantic-color-secondary-foreground); } } // ~/src/siteplane/components/list-box/index.ts export * from "./list-box";