import type { ReactNode, Ref } from "react"; import type { PressableProps as RNPressableProps, View as RNView, } from "react-native"; import type { VariantProps } from "tailwind-variants"; import type { AccentOrNeutral } from "../../core/AlouetteConfig"; import { tv } from "../../core/tv"; import { AccentScope } from "../containers/AccentScope"; import { InteractiveBox, type InteractiveBoxProps, interactiveBoxVariants, } from "../containers/Box"; import { useTonalGroundWarningRef } from "./useTonalGroundWarningRef"; // No `focus:` ground in any variant: a mouse click leaves the focus behind, so // a focus ground would keep the button lit after the pointer has left. The // keyboard focus is the ring `InteractiveBox` draws on `focus-visible`. const pressableBoxVariants = tv({ extend: interactiveBoxVariants, // `group`: a child styles itself from the pressable's state — the icon // swapped by InteractiveIcon, and anything an app composes on top. base: "group overflow-hidden", variants: { variant: { // Lifted off the page it sits on: the ground is a tone of the theme — // the lightest step when neutral, a pale tone of the accent when // accented — and the accent is carried by the `text-on-tonal` ink. tonal: [ "rounded-sm", process.env.EXPO_PUBLIC_STORYBOOK_ENABLED ? "" : "bg-interactive-tonal-pressable", "hover:bg-interactive-tonal-hover", "active:shadow-lowered active:bg-interactive-tonal-active", "disabled:bg-interactive-tonal-disabled disabled:shadow-none", "aria-disabled:bg-interactive-tonal-disabled aria-disabled:shadow-none", ].join(" "), // The accent's own fill, flat, under `text-on-accent` ink — the neutral // one is the grayscale accent, a dark ground carrying white ink. filled: [ "rounded-sm", process.env.EXPO_PUBLIC_STORYBOOK_ENABLED ? "" : "bg-interactive-filled-pressable", "hover:bg-interactive-filled-hover", "active:shadow-lowered active:bg-interactive-filled-active", "disabled:bg-interactive-filled-disabled", "aria-disabled:bg-interactive-filled-disabled", ].join(" "), outlined: [ "border bg-highlight", process.env.EXPO_PUBLIC_STORYBOOK_ENABLED ? "" : "border-interactive-outlined-pressable", "hover:border-interactive-outlined-hover", "active:border-interactive-outlined-active", "disabled:border-interactive-outlined-disabled", "aria-disabled:border-interactive-outlined-disabled", ].join(" "), // No ground and no border at rest: the affordance is the fill arriving // on hover, like a listbox row (ListboxOption). The fill is a tone of // the surrounding surface, not the accent, so the label keeps its own // color. No radius either (twMerge is off here, so a variant radius // would collide with the caller's own). soft: [ process.env.EXPO_PUBLIC_STORYBOOK_ENABLED ? "" : "bg-transparent", "hover:bg-interactive-soft-hover", "active:bg-interactive-soft-active", "disabled:bg-transparent", "aria-disabled:bg-transparent", ].join(" "), }, forceStyle: { hover: "", focus: "", press: "translate-y-px", }, raised: { true: "", false: "" }, }, // A forced `focus` is the keyboard focus: the rest ground under the ring, // which `focus-visible` never fires for in a story. compoundVariants: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED ? [ /* tonal */ { variant: "tonal", raised: true, forceStyle: undefined, className: "shadow-s", }, { variant: "tonal", raised: true, forceStyle: ["hover", "focus"], className: "shadow-s", }, { variant: "tonal", forceStyle: undefined, className: "bg-interactive-tonal-pressable", }, { variant: "tonal", forceStyle: "hover", className: "bg-interactive-tonal-hover", }, { variant: "tonal", forceStyle: "focus", className: "bg-interactive-tonal-pressable focus-ring-inset", }, { variant: "tonal", forceStyle: "press", className: "shadow-lowered bg-interactive-tonal-active", }, /* filled */ { variant: "filled", forceStyle: undefined, className: "bg-interactive-filled-pressable", }, { variant: "filled", forceStyle: "hover", className: "bg-interactive-filled-hover", }, { variant: "filled", forceStyle: "focus", className: "bg-interactive-filled-pressable focus-ring", }, { variant: "filled", forceStyle: "press", className: "shadow-lowered bg-interactive-filled-active", }, /* outlined */ { variant: "outlined", forceStyle: undefined, className: "border-interactive-outlined-pressable", }, { variant: "outlined", forceStyle: "hover", className: "border-interactive-outlined-hover", }, { variant: "outlined", forceStyle: "focus", className: "border-interactive-outlined-pressable focus-ring", }, { variant: "outlined", forceStyle: "press", className: "border-interactive-outlined-active", }, /* soft */ { variant: "soft", forceStyle: undefined, className: "bg-transparent", }, { variant: "soft", forceStyle: "hover", className: "bg-interactive-soft-hover", }, { variant: "soft", forceStyle: "focus", className: "bg-transparent focus-ring-inset", }, { variant: "soft", forceStyle: "press", className: "bg-interactive-soft-active", }, ] : [{ variant: "tonal", raised: true, className: "shadow-s" }], defaultVariants: { variant: "tonal", raised: true, }, }); type PressableBoxVariantProps = VariantProps; export type PressableBoxVariant = NonNullable< PressableBoxVariantProps["variant"] >; export interface PressableBoxProps extends RNPressableProps, PressableBoxVariantProps { /** `"neutral"` drops an accent inherited from an ancestor and renders the * neutral interactive tokens — the secondary action beside an accented one. */ accent?: AccentOrNeutral; className?: string; /** * Destination. react-native-web renders a real `` for it; native ignores * it, so a native app routes from `onPress` — expo Router's `` * injects both. Giving one turns the default `role` into `"link"`; a * component that needs another one (a `menuitem`) still passes its own. */ href?: string; /** * Defaults to `"link"` when `href` is set and `"button"` otherwise. */ role?: RNPressableProps["role"]; forceStyle?: "focus" | "hover" | "press"; /** * The keyboard focus ring. Unset, a `tonal` pressable rings inside its edge * (`"inset"`: the material is raised, and its shadow is where an outer ring * would sit), and so does a `soft` one, to match it; `filled` and `outlined` * ring 2px outside it. Pass `"inset"` to a * pressable whose parent clips (a `surface` is `overflow-hidden`), and * `false` on a row of a list that already paints its cursor (a menu item, a * listbox option): the focus moves with the pointer there, so the outline * would ring whatever the mouse is over. */ withFocusVisibleOutline?: InteractiveBoxProps["withFocusVisibleOutline"]; /** * `false` drops the `tonal` shadow at rest, for a segment of a group that * casts one shadow for all of them (`SplitButton`): side by side, each * segment's shadow would fall on its neighbor. */ raised?: boolean; /** Untyped by react-native; web only in effect. */ "aria-describedby"?: string; ref?: Ref; } /** * `InteractiveBox` with a material: the `variant`'s `interactive-*` states, an * accent scope, and a `link` role when it has an `href`. */ export function PressableBox({ ref, className, variant = "tonal", forceStyle, raised, accent, href, role, withFocusVisibleOutline = variant === "tonal" || variant === "soft" ? "inset" : true, ...props }: PressableBoxProps): ReactNode { const warningRef = useTonalGroundWarningRef(ref, { variant, disabled: props.disabled === true, forced: forceStyle !== undefined, }); return ( ); }