import type { ReactElement, ReactNode, Ref } from "react"; import { Children, cloneElement } from "react"; import { Pressable, type PressableProps, View as RNView, type ViewProps as RNViewProps, } from "react-native"; import type { VariantProps } from "tailwind-variants"; import type { AccentOrNeutral } from "../../core/AlouetteConfig"; import { tv } from "../../core/tv"; import { twMerge } from "../../core/twMerge"; import { useSafeAreaInsets } from "../../core/useSafeAreaInsets"; import { AccentScope } from "./AccentScope"; // Allow Box to shrink inside a flex row or column (react-native-web's View // defaults to flex-shrink: 0). overflow is intentionally left off so multi-layer // box-shadows are not clipped. export const boxBaseClasses = "shrink"; export interface BoxProps extends RNViewProps { accent?: AccentOrNeutral; ref?: Ref; } export function Box({ className, accent, ...props }: BoxProps): ReactNode { return ( ); } export const interactiveBoxVariants = tv({ base: [ boxBaseClasses, "cursor-pointer", // `translate` is deliberately outside the transition: the press is one // whole pixel, so it lands instantly while the ground fades, and it never // animates its way onto a compositing layer — which is what would redraw // the label (grayscale antialiasing, baseline re-snapped) and jump it. "transition-[background-color,border-color] duration-fast ease-in", "disabled:cursor-not-allowed disabled:opacity-70 aria-disabled:cursor-not-allowed aria-disabled:opacity-70", ].join(" "), variants: { // A constant displacement, not a proportional one: a scale moves every // point in proportion to its distance from the centre, so it would grow from // a press on a button (~1.6px per edge) into a squeeze on a full-width row // (~14px). Rigid, so a row's icon and its label keep their positions, and a // whole pixel, so the label is re-hinted on the same subpixel phase. // Off for a bare label row (Radio, Checkbox): its indicator takes the press // through `group-active:` instead, so the text never moves. withPressEffect: { true: "active:translate-y-px", false: "", }, // The keyboard focus ring (`focus-ring`, in the accent ink), on // `focus-visible` only so a mouse click leaves no residue. `inset` draws it inside the edge: for a raised control // (PressableBox's tonal material picks it) and for a pressable whose // parent clips (`surface` is overflow-hidden). withFocusVisibleOutline: { true: "focus-visible:focus-ring", inset: "focus-visible:focus-ring-inset", // `outline-none` cannot express this: react-native-css keeps solid, // dotted and dashed outline styles only and drops `none`, so the // browser's own focus ring is overridden with a zero-width one instead. false: "outline-solid outline-0", }, }, defaultVariants: { withPressEffect: true }, }); export interface InteractiveBoxProps extends VariantProps, PressableProps { ref?: Ref; } export function InteractiveBox({ withFocusVisibleOutline, withPressEffect, className, ...rest }: InteractiveBoxProps): ReactNode { return ( ); } // The child wears the material, but the pressable holds the focus: the ring is // driven from the pressable's `group`, and the pressable's own UA ring is // zeroed (`outline-solid outline-0`, see `withFocusVisibleOutline: false`). const hitSlopChildVariants = tv({ extend: interactiveBoxVariants, variants: { withFocusVisibleOutline: { true: "group-focus-visible:focus-ring", inset: "group-focus-visible:focus-ring-inset", false: "", }, }, }); export function InteractiveBoxHitSlop({ withFocusVisibleOutline, withPressEffect, children, className, ...rest }: InteractiveBoxProps): ReactNode { const child = Children.only(children) as ReactElement; return ( {cloneElement(child, { className: hitSlopChildVariants({ withFocusVisibleOutline, withPressEffect: rest.disabled ? false : withPressEffect, className: child.props.className, }), })} ); } export type SafeAreaBoxProps = Omit; export function SafeAreaBox(props: SafeAreaBoxProps): ReactNode { const insets = useSafeAreaInsets(); return ( ); }