/** * Feedback — a dialog whose body is something to write in, and whose * actions sit in a band around it. * * ```tsx * * * * What should we fix first? * * * * * * * * * * ``` * * ## Why the panel is a panel and not the dialog * * A dialog that asks for a sentence has two jobs on screen at once: hold what * is being written, and offer the two things to do with it. `Dialog` puts both * on one surface, which is right when the body is a line of prose — nothing * about it says "this is where you type". * * Here the writing surface is a well set into the dialog, and the buttons sit * in the band around it. The recess is the whole affordance: it says the field * is the page and everything else is the frame around it, before a word has * been read. It is also why the field has no border of its own — an outline * inside a well is two edges saying the same thing. * * ## The actions are equal, and one of them is not * * Cancel and Submit take the same width, because they are the same size of * decision — this is a sentence somebody wrote, not a deletion. What separates * them is weight: Submit is filled in the foreground colour and Cancel is a * tint of it, so the difference is legible at a glance without either being * hidden. * * ## Nothing is submitted empty * * `Submit` disables itself while the field is empty. A dialog that accepts an * empty answer produces empty feedback, and the person who sent it believes * they said something. * * Disabled, it drops the fill instead of dimming it. A tinted accent pill * still reads as the thing to press, whatever its opacity. */ import { createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, TextInput, View, type TextInputProps, type ViewProps, } from 'react-native'; import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { XIcon } from '../../icons'; import { AnimatedPressable } from '../../primitives/animated-pressable'; import { KeyboardAvoider } from '../../primitives/keyboard-avoider'; import { ModalPortal } from '../../primitives/portal'; import { Scrim } from '../../primitives/scrim'; import { Text, textChildren, type TextProps } from '../../primitives/text'; import { useBackHandler } from '../../hooks/use-back-handler'; import { cn } from '../../utils/cn'; /** * The shell's padding, in points, and the corner it draws. * * The panel's corner is the shell's less that padding, which is what keeps the * two curves concentric — a well whose radius does not account for the band * around it leaves a crescent of shell at each corner, thicker there than it is * along the sides. */ const SHELL_PADDING = 8; const SHELL_RADIUS = 38; const PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING; /** * How far the action row is held in from the shell's own edge, beyond the * padding the panel gets. * * The buttons are narrower than the well above them on purpose: a row that ran * the full width would read as a third edge of the dialog rather than as two * things to press. */ const FOOTER_INSET = 26; /** Room to write in before the field starts growing. */ const FIELD_MIN_HEIGHT = 200; /** * The ✕'s drawn size, and the slop that takes its touch box to 48. * * Made up with slop rather than with size, because the glyph is set against * the title's cap height and a circle large enough to press comfortably would * be taller than the line it sits on. */ const CLOSE_SIZE = 22; const CLOSE_HIT_SLOP = 13; const feedbackVariants = tv({ slots: { /* * The shell is `bg-popover` with an `inset` band laid over it rather than * a colour of its own. `--color-inset` is a translucent black in every * theme, so the shell always comes out darker than the panel it holds — * which is the one thing this design cannot get from the surface ladder, * because that ladder runs darker in a light theme and lighter in a dark * one, and the recess has to read the same way in both. */ shell: 'w-full max-w-sm overflow-hidden bg-popover shadow-xl', recess: 'absolute inset-0 bg-inset', panel: 'overflow-hidden bg-popover', title: 'pe-9 text-[22px] font-semibold leading-[28px] text-popover-foreground', close: 'absolute items-center justify-center rounded-full bg-foreground/10', field: 'p-0 text-[17px] leading-[24px] text-muted-foreground', footer: 'flex-row items-center gap-3.5', action: 'h-11 flex-1 items-center justify-center rounded-full', actionLabel: 'text-[16px]', }, variants: { tone: { /** The one that discards. A tint of the foreground, not a fill. */ cancel: { action: 'bg-foreground/10', actionLabel: 'font-medium text-muted-foreground', }, /** The one that sends. Filled, and the only filled thing on the dialog. */ submit: { action: 'bg-primary', actionLabel: 'font-semibold text-primary-foreground', }, }, disabled: { true: {}, }, }, compoundVariants: [ /* * A disabled Submit loses its fill rather than dimming it. * * A tinted accent pill is still an accent pill: at any opacity it reads as * the thing on the dialog to press, so an inert one gets pressed and then * gets reported as broken. Dropping the fill is what makes the state * legible before the press. * * The tint is fainter than Cancel's own on purpose. Matched fills would * read as two Cancels rather than as one live action and one dead one. */ { tone: 'submit', disabled: true, class: { action: 'bg-foreground/[0.04]', actionLabel: 'font-medium text-muted-foreground/50', }, }, { tone: 'cancel', disabled: true, class: { action: 'opacity-[0.45]' } }, ], }); interface FeedbackContextValue { open: boolean; setOpen: (open: boolean) => void; /** What is in the field, so Submit can refuse to send nothing. */ value: string; setValue: (value: string) => void; } const FeedbackContext = createContext(null); function useFeedback(component: string): FeedbackContextValue { const context = useContext(FeedbackContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface FeedbackProps { children: ReactNode; /** Controlled open state. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** Initial state when uncontrolled. */ defaultOpen?: boolean; /** The message, when the caller holds it. Leave unset to let the field keep it. */ value?: string; /** Starting message for an uncontrolled field. Ignored once `value` is passed. */ defaultValue?: string; onValueChange?: (value: string) => void; } function FeedbackRoot({ children, open, onOpenChange, defaultOpen = false, value, defaultValue = '', onValueChange, }: FeedbackProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const openControlled = open !== undefined; const resolvedOpen = openControlled ? open : internalOpen; const [internalValue, setInternalValue] = useState(defaultValue); const valueControlled = value !== undefined; const resolvedValue = valueControlled ? value : internalValue; const setOpen = useCallback( (next: boolean) => { if (!openControlled) setInternalOpen(next); onOpenChange?.(next); }, [openControlled, onOpenChange] ); const setValue = useCallback( (next: string) => { if (!valueControlled) setInternalValue(next); onValueChange?.(next); }, [valueControlled, onValueChange] ); const context = useMemo( () => ({ open: resolvedOpen, setOpen, value: resolvedValue, setValue }), [resolvedOpen, setOpen, resolvedValue, setValue] ); return ( {children} ); } FeedbackRoot.displayName = 'Feedback'; interface FeedbackTriggerProps { children: ReactElement<{ onPress?: (...args: unknown[]) => void }>; } /** Wraps its child and opens the dialog on press. */ function FeedbackTrigger({ children }: FeedbackTriggerProps) { const { setOpen } = useFeedback('Feedback.Trigger'); if (!isValidElement(children)) return children; return ( setOpen(true)}> {children} ); } FeedbackTrigger.displayName = 'Feedback.Trigger'; export interface FeedbackContentProps extends ViewProps { className?: string; /** Whether tapping outside or pressing back closes it. */ dismissible?: boolean; /** Frost the screen behind instead of dimming it. Needs `expo-blur`. */ blur?: boolean; children?: ReactNode; } /** The shell: the recessed band, and everything laid in it. */ function FeedbackContent({ className, dismissible = true, blur = false, children, ...props }: FeedbackContentProps) { const context = useFeedback('Feedback.Content'); const { open, setOpen } = context; const slots = feedbackVariants(); useBackHandler(open && dismissible, () => setOpen(false)); if (!open) return null; return ( {/* Portal content mounts under PortalHost, outside this provider's subtree — re-provide the context so Close and Submit keep working. */} setOpen(false) : undefined} /> {/* The body is a field, so the dialog gets out of the keyboard's way rather than sitting behind it. `lift` and not `dock`: it is centred on the screen rather than pinned to an edge, so it only has to move by however much the keyboard actually overlaps it. */} {textChildren(children)} ); } FeedbackContent.displayName = 'Feedback.Content'; export interface FeedbackPanelProps extends ViewProps { className?: string; children?: ReactNode; } /** The well set into the shell: the title, the ✕ and the field. */ const FeedbackPanel = forwardRef( ({ className, children, ...props }, ref) => { const slots = feedbackVariants(); return ( {textChildren(children)} ); } ); FeedbackPanel.displayName = 'Feedback.Panel'; const FeedbackTitle = forwardRef, TextProps>( ({ className, ...props }, ref) => { const slots = feedbackVariants(); return ; } ); FeedbackTitle.displayName = 'Feedback.Title'; export interface FeedbackCloseProps extends ViewProps { className?: string; /** How the ✕ announces itself. */ label?: string; /** Runs instead of closing. Call `onOpenChange` yourself if you pass this. */ onPress?: () => void; } /** The ✕ in the panel's corner. */ function FeedbackClose({ className, label = 'Close', onPress, ...props }: FeedbackCloseProps) { const { setOpen } = useFeedback('Feedback.Close'); const slots = feedbackVariants(); return ( setOpen(false))} className={slots.close({ className })} style={{ top: 20, right: 20, width: CLOSE_SIZE, height: CLOSE_SIZE }} {...props} > ); } FeedbackClose.displayName = 'Feedback.Close'; export interface FeedbackFieldProps extends Omit { className?: string; /** The message. Leave unset to let the dialog hold it. */ value?: string; onChangeText?: (value: string) => void; /** Room to write in before the field starts growing. */ minHeight?: number; } /** * What is being written. * * No border and no background of its own: it is already inside a well, and an * outline drawn inside one is two edges making the same point. * * The caret is deliberately left to the platform. Every system draws its own * accent there, and a field that overrides it is a field that looks like it * belongs to a different phone. */ const FeedbackField = forwardRef( ( { className, value, onChangeText, minHeight = FIELD_MIN_HEIGHT, placeholder = 'Tell us what you think', style, ...props }, ref ) => { const dialog = useFeedback('Feedback.Field'); const slots = feedbackVariants(); const text = value ?? dialog.value; return ( { dialog.setValue(next); onChangeText?.(next); }} className={slots.field({ className })} style={[{ minHeight }, style]} {...props} /> ); } ); FeedbackField.displayName = 'Feedback.Field'; export interface FeedbackFooterProps extends ViewProps { className?: string; children?: ReactNode; } /** The action row, in the band under the panel. */ const FeedbackFooter = forwardRef( ({ className, children, style, ...props }, ref) => { const slots = feedbackVariants(); return ( {children} ); } ); FeedbackFooter.displayName = 'Feedback.Footer'; export interface FeedbackActionProps extends ViewProps { className?: string; labelClassName?: string; disabled?: boolean; onPress?: () => void; children?: ReactNode; } /** Shared body for the two buttons, so they can only differ in the ways they should. */ function Action({ tone, className, labelClassName, disabled, onPress, children, ...props }: FeedbackActionProps & { tone: 'cancel' | 'submit' }) { const slots = feedbackVariants({ tone, disabled: !!disabled }); return ( {textChildren(children, (label) => ( {label} ))} ); } /** Discards and closes. Give it `onPress` to do something else first. */ function FeedbackCancel({ children = 'Cancel', onPress, ...props }: FeedbackActionProps) { const { setOpen } = useFeedback('Feedback.Cancel'); return ( setOpen(false))} {...props}> {children} ); } FeedbackCancel.displayName = 'Feedback.Cancel'; export interface FeedbackSubmitProps extends FeedbackActionProps { /** * Hand the message to the caller. The dialog does not close itself here — * sending usually has to finish first, and a dialog that closed on the press * would take its own error message with it. */ onSubmit?: (value: string) => void; } /** Sends. Inert while the field is empty. */ function FeedbackSubmit({ children = 'Submit', disabled, onPress, onSubmit, ...props }: FeedbackSubmitProps) { const { value } = useFeedback('Feedback.Submit'); // Empty feedback is worse than none: it is sent by somebody who believes // they said something. const empty = value.trim().length === 0; return ( { onPress?.(); onSubmit?.(value); }} {...props} > {children} ); } FeedbackSubmit.displayName = 'Feedback.Submit'; export const Feedback = Object.assign(FeedbackRoot, { Trigger: FeedbackTrigger, Content: FeedbackContent, Panel: FeedbackPanel, Title: FeedbackTitle, Close: FeedbackClose, Field: FeedbackField, Footer: FeedbackFooter, Cancel: FeedbackCancel, Submit: FeedbackSubmit, });