/** @jsxImportSource react */
import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from "react";
import { Box, Input, Text, Textarea, editableTextContextMenuItems, useRendererHost, useUiCapabilities } from "../../../../ui";
import { TextAttributes, type InputRenderable } from "../../../../ui";
import { useShortcut } from "../../../../react/input";
import { blendHex, type ThemeColors } from "../../../../theme/colors";
import { contrastRatio } from "../../../../theme/color-utils";
import { useThemeColors } from "../../../../theme/theme-context";
import { isDetailBackNavigationKey } from "../../../../utils/back-navigation";
import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../../../../theme/font-scale";
import type { ButtonProps } from "../../../../components/ui/button";
import type { CheckboxProps } from "../../../../components/ui/checkbox";
import type { TextFieldProps } from "../../../../components/ui/fields";
import type { DialogFrameProps } from "../../../../components/ui/frame";
import type { MessageComposerProps } from "../../../../components/ui/message-composer";
import type { PageStackViewProps } from "../../../../components/ui/page-stack-view";
import { StackHeaderContext, type StackHeaderSlot } from "./stack-header";
import { releasePointerFocus } from "../host/focus-scope";
import { WebIcon, WebIconButton } from "./icons";
import { NestedPaneTabs } from "../../../../components/layout/pane/header-tabs";
import type { SegmentedControlProps } from "../../../../components/ui/toggle";
import {
CONTROL_RADIUS,
buttonPalette,
controlBorderColor,
controlShadow,
panelBorder,
panelFill,
} from "./control-styles";
export { WebListView } from "./list-view";
export function WebButton({
label,
displayLabel,
children,
expanded,
onPress,
variant = "secondary",
disabled = false,
active = false,
shortcut,
width,
height,
compact = false,
flush = false,
stopPropagation = false,
title,
}: ButtonProps) {
const colors = useThemeColors();
const palette = buttonPalette({ variant, active, disabled }, colors);
return (
);
}
function checkboxAccentColor(colors: ThemeColors): string {
const base = colors.borderFocused;
const nativeCheckmark = "#ffffff";
const candidates = [
blendHex(base, colors.selected, 0.55),
blendHex(base, colors.selected, 0.65),
blendHex(base, "#000000", 0.45),
blendHex(base, "#000000", 0.52),
base,
];
return candidates.find((candidate) => (
contrastRatio(candidate, nativeCheckmark) >= 4.5
&& contrastRatio(candidate, colors.panel) >= 2.2
)) ?? candidates[1]!;
}
function checkboxCheckImage(): string {
const svg = ``;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
/**
* The one checkbox square on the desktop: the kit Checkbox, query-bar toggles
* and multi-select menu items all draw it through this style.
*/
export function checkboxBoxStyle(
colors: ThemeColors,
{ checked, active = false, size = 14 }: { checked: boolean; active?: boolean; size?: number },
): CSSProperties {
const accentColor = checkboxAccentColor(colors);
return {
appearance: "none",
WebkitAppearance: "none",
display: "inline-block",
width: size,
height: size,
margin: 0,
backgroundColor: checked ? accentColor : panelFill(colors),
backgroundImage: checked ? checkboxCheckImage() : undefined,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: `${size - 2}px ${size - 2}px`,
border: `1px solid ${checked ? accentColor : controlBorderColor(active, false, colors)}`,
borderRadius: size >= 14 ? 4 : 3,
boxShadow: active
? `0 0 0 2px ${blendHex(colors.bg, colors.borderFocused, 0.28)}, inset 0 1px 0 rgba(255,255,255,0.16)`
: "inset 0 1px 0 rgba(255,255,255,0.10)",
boxSizing: "border-box",
flexShrink: 0,
verticalAlign: "middle",
};
}
/** A checkbox square for a control that owns its own click (a menu row, a bar toggle). */
export function CheckboxBox({ checked, size = 12 }: { checked: boolean; size?: number }) {
const colors = useThemeColors();
return ;
}
export function WebCheckbox({
label,
displayLabel,
checked,
onChange,
disabled = false,
active = false,
description,
width,
}: CheckboxProps) {
const colors = useThemeColors();
const textColor = disabled
? colors.textMuted
: active
? colors.textBright
: colors.text;
const visibleLabel = displayLabel ?? label;
return (
{description ? (
{description}
) : null}
);
}
/** Outer height of a `size="comfortable"` field, border included. */
const COMFORTABLE_FIELD_HEIGHT = 26;
export function WebTextField({
label,
value,
placeholder,
focused,
width,
inputRef,
onChange,
onSubmit,
onBlur,
hint,
type = "text",
autoComplete,
variant = "default",
size = "default",
backgroundColor,
textColor,
placeholderColor,
onMouseDown,
onKeyDown,
}: TextFieldProps) {
const colors = useThemeColors();
const resolvedBackgroundColor = backgroundColor ?? colors.bg;
const resolvedTextColor = textColor ?? colors.text;
const resolvedPlaceholderColor = placeholderColor ?? colors.textDim;
const localInputRef = useRef(null);
const resolvedInputRef = inputRef ?? localInputRef;
const renderer = useRendererHost();
const { nativeContextMenu } = useUiCapabilities();
const plain = variant === "plain";
const comfortable = size === "comfortable";
return (
{label && (
{label}
)}
{
onMouseDown?.();
resolvedInputRef.current?.focus?.();
}}
onContextMenu={(event: any) => {
if (!nativeContextMenu || !renderer.showContextMenu) return;
event.preventDefault?.();
event.stopPropagation?.();
resolvedInputRef.current?.focus?.();
void renderer.showContextMenu(editableTextContextMenuItems());
}}
data-gloom-role="desktop-text-field"
style={{
height: comfortable ? COMFORTABLE_FIELD_HEIGHT : undefined,
border: plain ? "none" : `1px solid ${controlBorderColor(focused, false, colors)}`,
borderRadius: plain ? 0 : CONTROL_RADIUS,
boxShadow: plain ? undefined : controlShadow(focused, colors),
overflow: "hidden",
}}
>
}
width="100%"
value={value}
type={type}
autoComplete={autoComplete}
placeholder={placeholder}
focused={focused}
textColor={resolvedTextColor}
focusedTextColor={resolvedTextColor}
placeholderColor={resolvedPlaceholderColor}
backgroundColor={plain ? "transparent" : resolvedBackgroundColor}
focusedBackgroundColor={plain ? "transparent" : resolvedBackgroundColor}
cursorColor={colors.textBright}
style={{
paddingLeft: plain ? 0 : comfortable ? 8 : 10,
paddingRight: plain ? 0 : comfortable ? 8 : 10,
borderRadius: plain ? 0 : CONTROL_RADIUS,
// The field is taller than a cell, so the input takes the inner
// height and centers its text on the box's own line.
...(comfortable ? { height: COMFORTABLE_FIELD_HEIGHT - 2, lineHeight: `${COMFORTABLE_FIELD_HEIGHT - 2}px` } : {}),
}}
onInput={onChange}
onKeyDown={onKeyDown}
onChange={onChange}
onSubmit={(nextValue?: string) => onSubmit?.(
typeof nextValue === "string" ? nextValue : resolvedInputRef.current?.editBuffer.getText() ?? value ?? "",
)}
onBlur={(nextValue?: string) => onBlur?.(
typeof nextValue === "string" ? nextValue : resolvedInputRef.current?.editBuffer.getText() ?? value ?? "",
)}
/>
{hint && (
{hint}
)}
);
}
export function WebMessageComposer({
inputRef,
initialValue = "",
focused = false,
placeholder = "",
width,
height = 2,
onFocusRequest,
onInput,
onCursorChange,
onSubmit,
keyBindings,
wrapText = false,
}: MessageComposerProps) {
const colors = useThemeColors();
const borderColor = focused
? blendHex(colors.borderFocused, colors.textBright, 0.24)
: colors.border;
const requestFocus = () => {
onFocusRequest?.();
inputRef?.current?.focus?.();
};
const handleInput = (value: string) => {
if (!focused) onFocusRequest?.();
onInput?.(value);
};
return (
);
}
export function WebSegmentedControl({
options,
value,
onChange,
focused,
width,
wrap = false,
}: SegmentedControlProps) {
const colors = useThemeColors();
const enabled = options.filter((option) => !option.disabled);
const selectAdjacent = (direction: -1 | 1) => {
const index = enabled.findIndex((option) => option.value === value);
const next = enabled[index < 0 ? 0 : (index + direction + enabled.length) % enabled.length];
if (next) onChange?.(next.value);
};
// Callers place the control on a single grid row, so its border and padding
// have to live inside one cell: sized any taller it bled over the pane title
// and the table header below it.
return (
{options.map((option) => {
const active = option.value === value;
return (
{
if (!option.disabled) onChange?.(option.value);
}}
// A clicked option would otherwise keep taking the arrows and Enter
// after the keyboard has moved on to another pane.
onClick={(event: { currentTarget: unknown; detail: number }) => releasePointerFocus(event.currentTarget, event.detail)}
data-gloom-interactive={option.disabled ? undefined : "true"}
role="radio"
aria-checked={active}
aria-disabled={option.disabled || undefined}
tabIndex={option.disabled ? -1 : 0}
onKeyDown={(event: KeyboardEvent) => {
if (option.disabled) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
selectAdjacent(event.key === "ArrowLeft" ? -1 : 1);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onChange?.(option.value);
}
}}
style={{
alignSelf: wrap ? undefined : "stretch",
borderRadius: CONTROL_RADIUS - 2,
paddingInline: 8,
cursor: option.disabled ? "default" : "pointer",
}}
>
{option.label}
);
})}
);
}
export function WebDialogFrame({
title,
subtitle,
children,
footer,
showTitleDivider = false,
onClose,
}: DialogFrameProps) {
const colors = useThemeColors();
return (
{title}
{subtitle ? (
{subtitle}
) : null}
{onClose ? (
) : null}
{children}
{footer && (
{footer}
)}
);
}
export function WebPageStackView({
focused,
detailOpen,
onBack,
rootContent,
detailContent,
detailTitle,
backLabel = "Back",
backHint,
}: PageStackViewProps) {
useShortcut((event) => {
if (!focused || !detailOpen || !isDetailBackNavigationKey(event)) return;
event.stopPropagation?.();
event.preventDefault?.();
onBack();
});
const containerRef = useRef(null);
const attachedRef = useRef(0);
const [headerAttached, setHeaderAttached] = useState(false);
const onBackRef = useRef(onBack);
onBackRef.current = onBack;
// A hint has no place in a query bar, so a detail with one keeps its band.
const slot = useMemo(() => backHint ? null : {
backLabel,
title: detailTitle,
onBack: () => onBackRef.current(),
containerRef,
attach: () => {
attachedRef.current += 1;
setHeaderAttached(true);
return () => {
attachedRef.current -= 1;
if (attachedRef.current === 0) setHeaderAttached(false);
};
},
}, [backHint, backLabel, detailTitle]);
if (!detailOpen) {
return (
{rootContent}
);
}
return (
{!headerAttached && (
{detailTitle ?
{detailTitle}
:
}
{backHint ?
{backHint} : null}
)}
{detailContent}
);
}