| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149 |
- /**
- * NeoButton — SwiftUI-faithful Button. Props: neo-button.types.ts.
- *
- * The API mirrors SwiftUI's Button axes:
- *
- * <Button("Save", systemImage: "tray") { action() }>
- * .buttonStyle(.borderedProminent)
- * .controlSize(.large)
- * .buttonBorderShape(.capsule)
- * .tint(.blue)
- *
- * is rendered here as:
- *
- * <NeoButton
- * label="Save"
- * image="Save"
- * style="borderedProminent"
- * controlSize="large"
- * borderShape="capsule"
- * tint="#3b82f6"
- * onPress={save}
- * />
- *
- * Anything more complex than label + single image goes via children
- * (a custom HStack-equivalent). There are no startDecorator /
- * endDecorator / leadingDecorator / trailingDecorator props anymore;
- * use `image` + `imagePlacement` for the common case, children for the
- * rest. This keeps the API mappable 1:1 to `@expo/ui/swift-ui` Button.
- *
- * Native: `native.expo_ui_buttons` (configs) routes eligible buttons through
- * Expo UI (`neo-button-expoui.{ios,android}.tsx`). iOS draws Lucide or SF
- * glyphs natively and hosts icon-like custom children (logo) in the SwiftUI
- * label. Long-press, labelled custom children, glyphs with no native asset,
- * and `selected` on non-glass styles stay on the JS surface (Android: also
- * unmapped icons and all custom children). Glass keeps Expo UI for selected
- * (tinted liquid glass) and for `addon` counters (SwiftUI child in the label).
- */
- import React, { createContext, memo, useContext, type ReactNode } from 'react';
- import type { ViewStyle } from 'react-native';
- import { Pressable, View, MotionView, Row } from 'app/design/view';
- import { Text } from 'app/design/typography';
- import { Icon } from 'app/ui/atoms/icon';
- import Link from 'app/ui/atoms/link';
- import Tooltip from 'app/ui/atoms/tooltip';
- import Loading from 'app/ui/atoms/loading';
- import { useIconClassColor } from 'app/ui/atoms/icon-class-color';
- import { useWindowWidth } from 'app/context/measure';
- import {
- cn,
- isWeb,
- isEmoji,
- appSetting,
- getBreakpoint,
- FeedbackHaptics,
- sanitazeUrl,
- isExternalUrl,
- openExternalLink,
- decodeTextNode,
- } from 'app/lib/util';
- import {
- useResolvedNeoButton,
- parseNeoButtonAddon,
- useNeoButtonExpoUI,
- NeoButtonStyleProvider,
- NeoControlSizeProvider,
- useNeoButtonStyle,
- useNeoControlSize,
- usePointerCapability,
- useNeoEnv,
- resolveScoped,
- } from 'app/design/controls/neo-button/neo-button-resolver';
- import { NeoButtonExpoUI, getNativeIcon, hostsCustomContent } from 'app/design/controls/neo-button/neo-button-expoui';
- import { selectedColorScheme, useNeoButtonColorScheme } from 'app/design/controls/neo-button/color-scheme-context';
- import { useNeoButtonGroupItem } from 'app/design/controls/neo-button/neo-button-group';
- import type { NeoButtonAddon, NeoButtonClassNames, NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
- import { cueAttributes, usePressCueOverride, type PressCue } from 'app/lib/sound';
- export type { NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
- // `width="fill"` takes leftover space in a flex parent (`flex-auto`), not
- // 100% of the parent (`w-full`) — that would shove siblings out of the row.
- // `min-w-0` lets the label ellipsis instead of overflowing. Inner surface
- // still uses `w-full` so the chrome fills this flex item.
- const FILL_WIDTH_CLASS = 'flex-auto min-w-0';
- // `showTitleFromSize`: the label shows from this breakpoint up (icon + label
- // buttons only). Static literals for Tailwind/Uniwind scanning. Web uses the
- // classes; native compares the window breakpoint (`useTitleHidden`).
- const BP_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'];
- const TITLE_FROM_SIZE_CLASS: Record<string, string> = {
- sm: 'hidden sm:block',
- md: 'hidden md:block',
- lg: 'hidden lg:block',
- xl: 'hidden xl:block',
- '2xl': 'hidden 2xl:block',
- };
- /** True below the `showTitleFromSize` breakpoint. Only native acts on it (web uses classes). */
- function useTitleHidden(showTitleFromSize: unknown): boolean {
- const width = useWindowWidth();
- if (typeof showTitleFromSize !== 'string' || !TITLE_FROM_SIZE_CLASS[showTitleFromSize]) return false;
- return BP_ORDER.indexOf(getBreakpoint(width) || '') < BP_ORDER.indexOf(showTitleFromSize);
- }
- const hasImageValue = (image: unknown) =>
- (typeof image === 'string' && image !== '') || React.isValidElement(image);
- /**
- * Resolved label classes + icon size of the enclosing JS NeoButton, for element
- * images that should follow the button's state colours (`LegacyDecoratorStack`).
- */
- type NeoButtonContentValue = { textCls: string; iconSize: number };
- const NeoButtonContentContext = createContext<NeoButtonContentValue | null>(null);
- export const useNeoButtonContent = () => useContext(NeoButtonContentContext);
- const ICON_ACCESSIBLE_MAP: Record<string, string> = {
- X: 'Close', ChevronLeft: 'Previous', ChevronRight: 'Next',
- ChevronUp: 'Up', ChevronDown: 'Down', Plus: 'Add', Minus: 'Remove',
- Trash: 'Delete', Trash2: 'Delete', Edit: 'Edit', Edit2: 'Edit', Edit3: 'Edit',
- Search: 'Search', Menu: 'Menu', MoreVertical: 'More options',
- MoreHorizontal: 'More options', Heart: 'Like', Star: 'Favorite',
- Share: 'Share', Share2: 'Share', MessageCircle: 'Comment',
- MessageSquare: 'Messages', Send: 'Send', Bell: 'Notifications',
- Settings: 'Settings', Home: 'Home', User: 'Profile', UserRound: 'Login',
- LogIn: 'Login', LogOut: 'Logout',
- };
- const getAccessibleName = (accessibilityLabel: string | undefined, tooltip: unknown, label: unknown, image: unknown): string | undefined => {
- if (accessibilityLabel) return accessibilityLabel;
- if (tooltip && typeof tooltip === 'string') return tooltip;
- if (label && typeof label === 'string') return label;
- if (typeof image === 'string') {
- if (ICON_ACCESSIBLE_MAP[image]) return ICON_ACCESSIBLE_MAP[image];
- return image.replace(/([A-Z])/g, ' $1').trim();
- }
- return undefined;
- };
- const getAddon = (addon: NeoButtonAddon, hasLabel: boolean) => {
- const parsed = parseNeoButtonAddon(addon);
- if (!parsed) return null;
- const isPrimary = parsed.variant === 'primary';
- const bg = isPrimary ? 'bg-destructive' : 'bg-accent';
- const textColor = isPrimary ? 'text-destructive-foreground' : 'text-accent-foreground';
- const pos = (typeof addon === 'object' ? addon?.position : undefined) === 'bottom' ? 'bottom-0 -end-1' : '-top-2 -end-2';
- const text = parsed.text;
- if (!hasLabel) {
- return (
- <View className={`absolute ${bg} border-2 border-card rounded-full px-1 min-w-6 min-h-6 items-center justify-center ${pos}`}>
- <Text className={`${textColor} text-xs font-semibold`}>{text}</Text>
- </View>
- );
- }
- return (
- <View className="flex-1 items-center flex-row">
- <View className={`${bg} rounded-full min-w-5 min-h-5 px-1.5 py-0.5 border border-card/80 items-center`}>
- <Text className={`${textColor} text-xs font-semibold`}>{text}</Text>
- </View>
- </View>
- );
- };
- /* --------------------------- image / label render -------------------------- */
- /**
- * Loading spinner in the label colour of the current style/state, so it stays
- * visible on prominent fills. Web: `currentColor` from the wrapper's `text-*`
- * class. Native: the class resolved to a colour (Uniwind).
- */
- function NeoSpinner({ className, color }: { className?: string; color?: string }) {
- const classColor = useIconClassColor(className);
- if (isWeb) {
- return (
- <View className={cn(className, 'overflow-visible')}>
- <Loading size="small" color={color ?? 'currentColor'} />
- </View>
- );
- }
- return <Loading size="small" color={color ?? classColor} />;
- }
- const NeoImage = memo(function NeoImage({ source, size, color, className }: {
- source?: unknown;
- size?: number;
- color?: string;
- className?: string;
- }) {
- if (source === undefined || source === null || source === '' || source === false) {
- return null;
- }
- if (source === '_loading') return <NeoSpinner className={className} color={color} />;
- if (React.isValidElement(source)) return source;
- if (typeof source !== 'string') return null;
- if (isEmoji(source)) {
- return <Text className={className} style={color ? { color } : undefined}>{source}</Text>;
- }
- return (
- <Icon
- size={size}
- className={cn(className, 'pointer-events-none')}
- icon={source}
- {...(color ? { color } : {})}
- />
- );
- });
- /**
- * SwiftUI Label(title:image:) equivalent. Wins over `image`/`label` props
- * when `children` is provided (custom HStack content).
- */
- function renderLabel({
- label, image, imagePlacement,
- iconSize, labelGap, labelGapCls, fontCls, textCls, tintColor, classNames, loading,
- spreadContent, hideLabel,
- }: {
- label: ReactNode;
- image: unknown;
- imagePlacement: string;
- iconSize: number;
- labelGap: number;
- labelGapCls?: string;
- fontCls?: string;
- textCls?: string;
- tintColor?: string;
- classNames?: NeoButtonClassNames;
- loading?: boolean;
- spreadContent?: boolean;
- /** Native `showTitleFromSize` below its breakpoint: keep the padding, drop the Text. */
- hideLabel?: boolean;
- }) {
- // While loading, the label and image keep their place, invisible, under the
- // centred spinner, so the button keeps its size. Only a button with neither
- // draws the spinner here, in the image slot.
- const effectiveImage = (loading && !label && !image) ? '_loading' : image;
- if (!label && !effectiveImage) return null;
- const spreadTrailing =
- spreadContent && imagePlacement === 'trailing' && !!effectiveImage && !!label;
- // Prefer the gap utility class; fall back to inline columnGap if the
- // resolved value isn't mapped. `spreadTrailing` uses justify-between (no gap).
- const useGapClass = !spreadTrailing && !!labelGapCls;
- return (
- <Row
- className={cn(
- 'flex-row items-center min-w-0 max-w-full',
- spreadTrailing && 'flex-1 w-full justify-between',
- useGapClass && labelGapCls,
- loading && effectiveImage !== '_loading' && 'opacity-0',
- )}
- style={(spreadTrailing || useGapClass) ? undefined : { columnGap: labelGap }}
- >
- {imagePlacement === 'leading' && effectiveImage ? (
- <View className="shrink-0">
- <NeoImage
- source={effectiveImage}
- size={iconSize}
- color={tintColor}
- className={cn(textCls, classNames?.image)}
- />
- </View>
- ) : null}
- {label && !hideLabel ? (
- <Text
- className={cn('min-w-0 shrink', fontCls, textCls, classNames?.text)}
- {...(isWeb ? {} : { numberOfLines: 1 })}
- style={tintColor ? { color: tintColor } : undefined}
- >
- {label}
- </Text>
- ) : null}
- {imagePlacement === 'trailing' && effectiveImage ? (
- <View className="shrink-0">
- <NeoImage
- source={effectiveImage}
- size={iconSize}
- color={tintColor}
- className={cn(textCls, classNames?.image)}
- />
- </View>
- ) : null}
- </Row>
- );
- }
- /* -------------------- transition wrapper (per style) ---------------------- */
- const getWebTransition = (press: any, property: string) => {
- const duration = press?.duration ?? (press?.spring ? 140 : 120);
- return {
- transitionProperty: property,
- transitionDuration: `${duration}ms`,
- transitionTimingFunction: 'cubic-bezier(0.2, 0, 0, 1)',
- willChange: property,
- };
- };
- function PressTransition({ active, transition, children, className }: {
- active: boolean;
- transition: any;
- children?: ReactNode;
- className?: string;
- }) {
- const press = transition?.press;
- if (!press || press === false || press.type === 'shadow') {
- // Style-driven press feedback via class swap only. No wrapper, so no
- // inline `transform: scale(1)` is left behind on every button.
- return <View className={className}>{children}</View>;
- }
- if (press.type === 'opacity') {
- if (isWeb) {
- return (
- <View
- className={className}
- style={{
- opacity: active ? (press.to ?? 0.85) : (press.from ?? 1),
- ...getWebTransition(press, 'opacity'),
- }}
- >
- {children}
- </View>
- );
- }
- return (
- <MotionView
- className={className}
- animate={{ opacity: active ? (press.to ?? 0.85) : (press.from ?? 1) }}
- transition={press.spring ? { type: 'spring', ...press.spring } : { duration: (press.duration ?? 100) / 1000 }}
- >
- {children}
- </MotionView>
- );
- }
- // Default: 'scale'.
- if (isWeb) {
- const scale = active ? (press.to ?? 0.97) : (press.from ?? 1);
- return (
- <View
- className={className}
- style={{
- transform: `scale(${scale})`,
- ...getWebTransition(press, 'transform'),
- }}
- >
- {children}
- </View>
- );
- }
- return (
- <MotionView
- className={className}
- animate={{ scale: active ? (press.to ?? 0.97) : (press.from ?? 1) }}
- transition={press.spring ? { type: 'spring', ...press.spring } : undefined}
- >
- {children}
- </MotionView>
- );
- }
- function PressHighlight({ active, rounded, color }: { active: boolean; rounded?: string; color?: string }) {
- const className = cn('absolute inset-0 pointer-events-none z-10', rounded);
- if (isWeb) {
- return (
- <View
- className={className}
- style={{
- backgroundColor: color,
- opacity: active ? 0.18 : 0,
- transitionProperty: 'opacity',
- transitionDuration: '120ms',
- transitionTimingFunction: 'ease-out',
- } as ViewStyle}
- />
- );
- }
- return (
- <MotionView
- className={className}
- animate={{ opacity: active ? 0.18 : 0 }}
- transition={{ duration: 0.12 }}
- style={{ backgroundColor: color }}
- />
- );
- }
- /* --------------------------- native mode gating ---------------------------- */
- function isNativeGlassStyle(style: unknown) {
- return style === 'glass' || style === 'glassProminent';
- }
- function canRenderExpoUIButton(props: NeoButtonProps, native: { config: Record<string, any> }) {
- if (props.expoUI === false) return false;
- const hasChildren = props.children !== undefined && props.children !== null && props.children !== false;
- if (props.onLongPress) return false;
- const style = props.buttonStyle ?? props.style;
- const glass = isNativeGlassStyle(style);
- // Passive buttons (chips, drag handles, children-triggers) stay JS so a
- // native button never swallows the parent's touches. Passive glass keeps
- // Expo UI (liquid glass menu triggers in headers).
- if (!props.onPress && !glass) return false;
- // iOS Expo UI would render String(label) = "[object Object]".
- if (React.isValidElement(props.label ?? props.title)) return false;
- // Expo UI has no spinner.
- if (props.loading !== undefined && !glass) return false;
- // Glass keeps Expo UI for selected + addon. Other styles have no native
- // selected/counter mapping and stay on the JS `selectedState` wash.
- if (parseNeoButtonAddon(props.addon) && !glass) return false;
- if (props.selected !== undefined && !glass) return false;
- const label = props.label ?? props.title;
- const hasLabel = (typeof label === 'string' && label !== '') || typeof label === 'number';
- // Icon-like custom content (header logo, avatar): iOS hosts it in the
- // SwiftUI label so it keeps native glass. Labelled custom rows stay JS.
- if (hasChildren) return hostsCustomContent && !hasLabel && React.isValidElement(props.children);
- const rawImage = props.systemImage ?? props.image;
- const hasImage = (typeof rawImage === 'string' && rawImage !== '') || React.isValidElement(rawImage);
- if (!hasLabel && !hasImage) return false;
- if (!hasImage) return true;
- if (React.isValidElement(rawImage)) return hostsCustomContent;
- return !!getNativeIcon(rawImage, native.config);
- }
- /* -------------------------- main Button component ------------------------- */
- const TEXT_PROPS = ['label', 'title', 'loadingLabel', 'accessibilityLabel', 'alt', 'tooltip'] as const;
- /**
- * UNA sends titles HTML-escaped (`&`). `Text` decodes the JS label, but the
- * same strings also become accessible names, tooltips and SwiftUI / Compose
- * labels, so decode them once here. Props without an entity come back as is.
- */
- const decodeTextProps = (props: NeoButtonProps): NeoButtonProps => {
- const hasEntity = TEXT_PROPS.some((key) => {
- const value = props[key];
- return typeof value === 'string' && value.includes('&');
- });
- if (!hasEntity) return props;
- return {
- ...props,
- label: decodeTextNode(props.label),
- title: decodeTextNode(props.title),
- loadingLabel: decodeTextNode(props.loadingLabel),
- accessibilityLabel: decodeTextNode(props.accessibilityLabel),
- alt: decodeTextNode(props.alt),
- tooltip: decodeTextNode(props.tooltip),
- };
- };
- export const NeoButton = (rawProps: NeoButtonProps) => {
- const props = decodeTextProps(rawProps);
- const native = useNeoButtonExpoUI(props);
- const titleHidden = useTitleHidden(props.showTitleFromSize);
- // A group segment draws on the group's surface, so it stays JS.
- const segment = useNeoButtonGroupItem();
- if (native && NeoButtonExpoUI && !segment) {
- // `showTitleFromSize` below its breakpoint: the native button turns
- // icon-only and keeps the label as its accessible name.
- const label = props.label ?? props.title;
- const dropTitle = titleHidden && !!label && hasImageValue(props.systemImage ?? props.image);
- const expoProps: NeoButtonProps = dropTitle
- ? {
- ...props,
- label: undefined,
- title: undefined,
- accessibilityLabel: props.accessibilityLabel
- ?? (typeof label === 'string' || typeof label === 'number' ? String(label) : undefined),
- }
- : props;
- if (canRenderExpoUIButton(expoProps, native)) {
- return <NeoButtonExpoUI {...expoProps} nativeConfig={native.config} />;
- }
- }
- return <NeoButtonStyled {...props} titleHidden={titleHidden} />;
- };
- /**
- * A button's press cue when it sets none (Cuelume's "Which cue?"): a menu
- * trigger opens or closes, a close / cancel button closes, a prominent or
- * confirming button taps, other buttons tap subtly. Buttons without haptics
- * (editor toolbars, object actions that play their own cue) stay quiet.
- */
- function defaultPressCue({ role, style, haptics, menuTrigger, expanded }: {
- role: string;
- style: string;
- haptics: boolean;
- menuTrigger: boolean;
- expanded: boolean;
- }): PressCue {
- if (menuTrigger) return { cue: expanded ? 'close' : 'open', emphasis: 'subtle' };
- if (!haptics) return false;
- if (role === 'close') return 'close';
- if (role === 'cancel') return { cue: 'close', emphasis: 'subtle' };
- if (role === 'confirm' || style === 'borderedProminent' || style === 'glassProminent') return 'tap';
- return { cue: 'tap', emphasis: 'subtle' };
- }
- /* ----------------------- JS-styled implementation ------------------------- */
- const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
- const {
- // SwiftUI core (`buttonStyle` avoids RN `Link` clobbering `style` on native)
- role, style, buttonStyle, controlSize, borderShape, tint,
- // Content
- label, loadingLabel, title, image, systemImage, imagePlacement,
- // Behaviour
- disabled = false,
- loading = false,
- selected = false,
- selectedState = 'pressedToggle',
- addon = '',
- interactive = false,
- haptics,
- sound,
- onPress,
- onPressIn,
- onPressOut,
- onLongPress,
- // Layout
- width, align, contentInsets,
- showTitleFromSize = '',
- // Accessibility / web
- accessibilityLabel, alt, tooltip = false, tooltipSide,
- hitarea = true, hitSlop, focusRing, accessibilityRole,
- // Native renderer gating (handled in `NeoButton`).
- expoUI: _expoUI, titleHidden = false,
- // Animation override
- transition,
- pressAnimation,
- glassEffect,
- // Style escape hatches
- className = '', textClassName = '', classNames = {},
- // Children = custom HStack-equivalent label content
- children,
- forwardedRef,
- // Link-related: forwarded by NeoButtonLink, ignored here.
- href, target, asExternal, emulate, variant, size, mode, noprefetch,
- // Anything else (e.g. data-* attrs) is forwarded to the surface.
- ...rest
- } = props;
- // Inside a NeoButtonGroup: the group's role and tint unless the button sets
- // its own (style and size come through the group's providers). No press
- // scale, which would push the segment over its neighbours, and no web glass
- // layers: the group draws the surface.
- const segment = useNeoButtonGroupItem();
- const resolved = useResolvedNeoButton({
- role: role ?? segment?.role,
- style, buttonStyle, controlSize, borderShape,
- tint: tint ?? segment?.tint,
- transition: segment ? false : transition,
- focusRing,
- pressAnimation: segment ? false : pressAnimation,
- glassEffect: segment ? 'standard' : glassEffect,
- image: image ?? systemImage,
- systemImage,
- imagePlacement, align, width, disabled, haptics,
- });
- // Effective content props.
- // While `loading=true` the content stays as it is (hidden under a centred
- // spinner, so the button keeps its size), and `loadingLabel`, when given,
- // becomes the accessible name instead of swapping the visible text.
- const effectiveLabel = label ?? title ?? '';
- const loadingName = loading && loadingLabel ? loadingLabel : undefined;
- const effectiveImage = image ?? systemImage ?? resolved.defaultImage;
- const effectivePlacement = imagePlacement ?? resolved.imagePlacement;
- const isCustomChildren = children !== undefined && children !== null && children !== false;
- const isTitle = !!effectiveLabel;
- const hasImage = !!effectiveImage || loading;
- const isIconOnly = hasImage && !isTitle && !isCustomChildren;
- const isPressable = !!(onPress || onLongPress || href);
- const isInteractive = !disabled && (isPressable || interactive);
- const isActive = isPressable && !disabled;
- const isPressedToggle = !!selected;
- const soundOverride = usePressCueOverride();
- const accessibleName =
- loadingName ?? accessibilityLabel ?? alt ?? getAccessibleName(undefined, tooltip, effectiveLabel, effectiveImage);
- /* ----------------------- interaction state -------------------------- */
- const [isPressed, setIsPressed] = React.useState(false);
- const [isHovered, setIsHovered] = React.useState(false);
- const [isFocused, setIsFocused] = React.useState(false);
- const stateKey =
- disabled ? 'disabled' :
- isPressedToggle ? selectedState :
- isPressed ? 'pressed' :
- isHovered ? 'hovered' :
- isFocused ? 'focused' :
- 'default';
- /* ------------------------------ handlers ---------------------------- */
- // Haptics fire on press-in (finger down), not onPress (finger up): JS
- // Pressable has no built-in feedback, so this is what makes styled
- // buttons answer the touch when `native.expo_ui_buttons` is off.
- const fireHaptics = () => {
- if (resolved.haptics) FeedbackHaptics(resolved.haptics);
- };
- const handlePress = (event: any) => {
- // `loading` blocks presses (the button keeps focus and stays in the tab order).
- if (loading) return;
- // Blur only after a real pointer click; keyboard activation keeps focus (and the ring).
- if (isWeb && event?.type === 'click' && event?.detail > 0) event?.currentTarget?.blur?.();
- onPress?.(event);
- };
- const handlePressIn = (event: any) => {
- if (!loading) fireHaptics();
- setIsPressed(true);
- onPressIn?.(event);
- };
- const handleLongPress = (event: any) => {
- onLongPress?.(event);
- };
- const refProps = forwardedRef ? { ref: forwardedRef } : {};
- // A menu trigger reports its state with `aria-expanded` (DropdownPopup);
- // `aria-pressed` next to it would announce a toggle instead.
- const isMenuTrigger = !!rest['aria-haspopup'];
- // Web press sound (`bind()` plays it from the attributes). Native buttons
- // answer with haptics only.
- const pressCue: PressCue = sound !== undefined
- ? sound
- : soundOverride !== undefined
- ? soundOverride
- : defaultPressCue({
- role: role ?? 'default',
- style: resolved.style,
- haptics: !!resolved.haptics,
- menuTrigger: isMenuTrigger,
- expanded: !!rest['aria-expanded'],
- });
- const soundAttributes = isWeb && isActive && !loading ? cueAttributes(pressCue) : {};
- const buttonAttributes = isPressable
- ? {
- ...(accessibleName ? { 'aria-label': accessibleName, alt: accessibleName } : {}),
- role: accessibilityRole || 'button',
- ...(isPressedToggle && !isMenuTrigger ? { 'aria-pressed': true } : {}),
- ...(disabled ? { 'aria-disabled': true } : {}),
- ...(loading ? { 'aria-busy': true } : {}),
- }
- : {};
- // hitSlop (theme `neo_button.controlSizes`) is the single source of truth.
- // Native applies it to the RN Pressable via this prop; web mirrors it with
- // the `hit-area-*` utility class (see `hitAreaClass` below) — RN has no
- // `::before`, so the two platforms diverge in mechanism but not in value.
- // A segment's hit area extends only past the group's outer edges, so no
- // segment covers its neighbour.
- const effectiveHitSlop = hitSlop ?? (segment && hitarea !== false ? {
- top: resolved.hitSlop,
- bottom: resolved.hitSlop,
- left: segment.outerStart ? resolved.hitSlop : 0,
- right: segment.outerEnd ? resolved.hitSlop : 0,
- } : undefined);
- const resolvedHitSlop = effectiveHitSlop !== undefined
- ? effectiveHitSlop
- : (hitarea === false ? undefined : {
- top: resolved.hitSlop, right: resolved.hitSlop,
- bottom: resolved.hitSlop, left: resolved.hitSlop,
- });
- /* --------------------------- sizing / padding ----------------------- */
- // Sizing comes from utility classes (theme numbers → classes in the
- // resolver). When a value isn't mapped we fall back to inline styles so
- // nothing silently breaks.
- const sizeClass = resolved.heightCls ?? '';
- const needsSizeFallback = !resolved.heightCls;
- const resolvedContentInsets = typeof contentInsets === 'string'
- ? resolved.contentInsets?.[contentInsets]
- : contentInsets;
- // contentInsets (when present) override padding per-side via inline style so
- // asymmetric media insets still work; the common case uses the px-* class.
- const insetLeft = resolvedContentInsets?.left ?? resolvedContentInsets?.start ?? resolvedContentInsets?.x;
- const insetRight = resolvedContentInsets?.right ?? resolvedContentInsets?.end ?? resolvedContentInsets?.x;
- // Icon-only normally drops to `px-0` so the box is square. Shapes flagged
- // `iconOnlyPadded` (capsule) keep the labelled button's padding instead, so
- // they stay visibly wider than a circle.
- const keepsPaddingWhenIconOnly = !isIconOnly || resolved.iconOnlyPadded;
- const pinIconOnlySquare = isIconOnly && !resolved.iconOnlyPadded;
- const paddingClass = keepsPaddingWhenIconOnly ? (resolved.paddingXCls ?? '') : 'px-0';
- const needsPaddingFallback = keepsPaddingWhenIconOnly && !resolved.paddingXCls;
- // Web-only hit-area utility (RN uses the hitSlop prop instead). See the
- // `hit-area-*` @utility in global.css. Only active buttons get one, so a
- // disabled button does not capture its neighbours' clicks. An explicit
- // `hitSlop` prop (number or per-side insets) uses `hit-area-sides` with
- // `--neo-hit-*` variables.
- const webHitArea = hitarea !== false && isActive && isWeb;
- const customHit = webHitArea && effectiveHitSlop !== undefined;
- const hitInsets = typeof effectiveHitSlop === 'number'
- ? { top: effectiveHitSlop, right: effectiveHitSlop, bottom: effectiveHitSlop, left: effectiveHitSlop }
- : (effectiveHitSlop ?? {});
- const hitAreaClass = !webHitArea ? '' : (customHit ? 'hit-area-sides' : (resolved.hitAreaCls ?? ''));
- /* ------------------- container / text classes ----------------------- */
- // A segment's corners: the group's radius on its outer edges, square inside.
- const roundedCls = segment ? segment.roundedCls : resolved.rounded;
- const containerCls = cn(
- 'flex-row items-center web:transition-[background-color,box-shadow,opacity] web:duration-200 web:ease-out',
- isInteractive && 'web:cursor-pointer',
- roundedCls,
- (resolved.aspectSquare || pinIconOnlySquare) ? 'aspect-square' : '',
- sizeClass,
- paddingClass,
- `justify-${resolved.align}`,
- segment ? resolved.segmentCls(stateKey) : resolved.containerCls(stateKey),
- // `min-w-0` is for labelled ellipsis. On icon-only it tw-merges away
- // `min-w-*` from the size class and the box collapses to the glyph.
- resolved.width === 'fill'
- ? 'w-full'
- : (pinIconOnlySquare ? '' : 'min-w-0 max-w-full'),
- className,
- classNames?.container,
- classNames?.surface,
- );
- const textCls = cn(
- 'whitespace-nowrap text-ellipsis overflow-hidden',
- resolved.textCls(stateKey),
- textClassName,
- );
- // Styles where `tint` repaints the surface as a fully opaque fill
- // (and the text inherits the prominent foreground colour, not the tint).
- // Mirrors SwiftUI's simple split: prominent styles use tint as fill;
- // non-prominent styles use tint for the label/image accent.
- const isProminent = resolved.style === 'borderedProminent' || resolved.style === 'glassProminent';
- // A segment's fill is the group's (it paints the tint itself).
- const tintFillsSurface = !segment && resolved.tint && isProminent;
- // Only the values that have no utility-class equivalent remain inline:
- // unmapped size/padding fallbacks, asymmetric contentInsets overrides, and
- // the arbitrary `tint` colour.
- const containerStyle = {
- ...(needsSizeFallback
- ? (resolved.aspectSquare
- ? { height: resolved.height, width: resolved.height }
- : { minHeight: resolved.height, minWidth: resolved.height })
- : null),
- ...(needsPaddingFallback ? { paddingLeft: resolved.paddingX, paddingRight: resolved.paddingX } : null),
- ...(insetLeft != null ? { paddingLeft: insetLeft } : null),
- ...(insetRight != null ? { paddingRight: insetRight } : null),
- ...(tintFillsSurface ? { backgroundColor: resolved.tint } : null),
- ...(customHit ? {
- '--neo-hit-t': hitInsets.top ?? 0,
- '--neo-hit-r': hitInsets.right ?? 0,
- '--neo-hit-b': hitInsets.bottom ?? 0,
- '--neo-hit-l': hitInsets.left ?? 0,
- } : null),
- };
- /* --------------------------- web ergonomics ------------------------- */
- const ringClass = !resolved.behaviors.focusRing
- ? 'u-neo-btn-ring-never'
- : (isInteractive ? 'u-neo-btn-ring' : '');
- /* ----------------------------- surface ------------------------------ */
- // Web Pressable does not surface onPressIn/Out to the DOM (see view.web.tsx).
- // Use View + pointer handlers for press visuals; onPress maps to onClick there.
- // Native keeps RN Pressable for press-in/out, hitSlop, and long-press.
- const useNativePressable = isPressable && !isWeb;
- const Cnt = useNativePressable ? Pressable : View;
- const hoverHandlers = resolved.behaviors.hover && isInteractive ? {
- onMouseEnter: () => setIsHovered(true),
- onMouseLeave: () => {
- setIsHovered(false);
- setIsPressed(false);
- },
- } : {};
- const pointerPressHandlers = isInteractive && isWeb ? {
- onPointerDown: (event: any) => {
- if (disabled) return;
- if (event.pointerType === 'mouse' && event.button !== 0) return;
- if (!loading) fireHaptics();
- setIsPressed(true);
- },
- onPointerUp: () => setIsPressed(false),
- onPointerCancel: () => setIsPressed(false),
- onPointerLeave: () => setIsPressed(false),
- } : {};
- const cntProps: Record<string, any> = {
- ...rest,
- ...soundAttributes,
- className: cn(
- `neo-btn neo-btn-${resolved.style} neo-btn-${resolved.controlSize}`,
- resolved.glassFx && cn('neo-glass', resolved.style === 'glassProminent' && 'neo-glass-prominent'),
- hitAreaClass,
- ringClass,
- classNames?.ring,
- ),
- style: containerStyle,
- ...refProps,
- ...buttonAttributes,
- ...(useNativePressable ? {
- disabled: !isActive,
- hitSlop: resolvedHitSlop,
- onPressIn: handlePressIn,
- onPressOut: (event: any) => {
- setIsPressed(false);
- onPressOut?.(event);
- },
- onFocus: () => setIsFocused(true),
- onBlur: () => setIsFocused(false),
- ...hoverHandlers,
- ...(onPress ? { onPress: handlePress } : {}),
- ...(onLongPress && resolved.behaviors.longPress ? { onLongPress: handleLongPress } : {}),
- } : {
- ...hoverHandlers,
- ...pointerPressHandlers,
- ...(isPressable ? {
- disabled: !isActive,
- onFocus: () => setIsFocused(true),
- onBlur: () => setIsFocused(false),
- ...(onPress ? { onPress: handlePress } : {}),
- } : {}),
- }),
- };
- /* ------------------------------ content ----------------------------- */
- const tintColor = (() => {
- if (!resolved.tint) return undefined;
- // When tint repaints the surface (borderedProminent / glassProminent),
- // the label/image keep the prominent foreground colour from the theme
- // so contrast stays correct on the freshly-tinted background.
- if (tintFillsSurface || (segment && isProminent)) return undefined;
- return resolved.tint;
- })();
- // `showTitleFromSize` (icon + label only): web hides the label with
- // responsive classes; native drops the Text below the breakpoint. Either
- // way the button stays labelled (padding, accessible name).
- const titleFromSizeCls = (typeof showTitleFromSize === 'string' && isTitle && !!effectiveImage && !isCustomChildren)
- ? TITLE_FROM_SIZE_CLASS[showTitleFromSize]
- : undefined;
- const labelClassNames = titleFromSizeCls && isWeb
- ? { ...classNames, text: cn(classNames?.text, titleFromSizeCls) }
- : classNames;
- // The spinner sits over the content, which stays in place invisible (see
- // `renderLabel`). A button with no content draws it inline instead.
- const spinnerOverlay = loading && (isTitle || !!effectiveImage || isCustomChildren);
- // Custom children get a wrapper whenever the button takes `loading` at all,
- // so toggling it only fades them: a new parent would remount them and reset
- // their state. Buttons without `loading` render them as before.
- const childrenContent = props.loading === undefined
- ? children
- : <Row className={cn('flex-row items-center', loading && 'opacity-0')}>{children}</Row>;
- const labelContent = isCustomChildren
- ? childrenContent
- : renderLabel({
- label: effectiveLabel,
- image: effectiveImage,
- imagePlacement: effectivePlacement,
- iconSize: resolved.iconSize,
- labelGap: resolved.labelGap,
- labelGapCls: resolved.labelGapCls,
- fontCls: resolved.fontCls,
- textCls,
- tintColor,
- classNames: labelClassNames,
- loading,
- spreadContent: resolved.align === 'between',
- hideLabel: !isWeb && !!titleFromSizeCls && titleHidden,
- });
- const addonContent = getAddon(addon, isTitle);
- const glassFx = resolved.glassFx;
- // Consumers are this button's own label content, which re-renders with it.
- const contentValue = { textCls, iconSize: resolved.iconSize };
- const buttonElement = (
- <Cnt {...cntProps} className={cn(cntProps.className, containerCls)}>
- {glassFx ? <View className="neo-glass-rim" aria-hidden /> : null}
- <NeoButtonContentContext.Provider value={contentValue}>
- {labelContent}
- </NeoButtonContentContext.Provider>
- {spinnerOverlay ? (
- <View className="absolute inset-0 items-center justify-center pointer-events-none">
- <NeoSpinner className={cn(textCls, classNames?.image)} color={tintColor} />
- </View>
- ) : null}
- {addonContent && (
- isTitle ? <View className="z-10 ml-1">{addonContent}</View> : <View className="absolute top-0 right-0 w-full h-full z-20 pointer-events-none">{addonContent}</View>
- )}
- {/* press highlight overlay (visual feedback, runs in addition to
- the per-style transition). Glass effect draws its own. */}
- {glassFx ? (
- <View className="neo-glass-shine" aria-hidden />
- ) : (resolved.style !== 'plain' && resolved.style !== 'link') ? (
- <PressHighlight
- active={isPressed}
- rounded={roundedCls}
- color={resolved.highlightBg}
- />
- ) : null}
- </Cnt>
- );
- // `u-neo-btn-root` lifts a focused button above later siblings so they
- // cannot paint over its ring (global.web.css). The layout classes go on
- // the outermost box: the Tooltip wrapper when there is one.
- const { isDesktop } = useNeoEnv();
- // A surface with its own brightness (header on a coloured band): on web the
- // root becomes a `.light` / `.dark` colour-scheme scope, so the button draws
- // with that scheme's tokens (global.css), the opposite one when selected.
- // iOS: see color-scheme-context.
- const colorScheme = selectedColorScheme(useNeoButtonColorScheme(), !!selected);
- const schemeClass = isWeb && colorScheme ? colorScheme : '';
- // Web only: native Tooltip renders no wrapper and drops `className`, so a
- // wide native screen (isDesktop) would lose the root layout classes.
- const tooltipWraps = isWeb && !!tooltip && isDesktop && !!appSetting('layout', 'tooltips');
- const rootLayoutClass = cn(
- // Segments of a `width="fill"` group share its width.
- resolved.width === 'fill' || segment?.fill
- ? FILL_WIDTH_CLASS
- : (pinIconOnlySquare ? 'self-start' : 'self-start max-w-full min-w-0'),
- classNames?.root,
- );
- const wrapperClass = cn('u-neo-btn-root', schemeClass, tooltipWraps ? '' : rootLayoutClass);
- // Glass effect: press/hover motion is CSS keyed off `data-neo-glass`,
- // so the JS PressTransition scale is skipped.
- const wrapped = glassFx ? (
- <View
- className={cn(
- 'neo-glass-wrap',
- `neo-glass-${resolved.controlSize}`,
- !resolved.behaviors.pressAnimation && 'neo-glass-still',
- wrapperClass,
- )}
- data-neo-glass={stateKey}
- >
- <View className={cn('neo-glass-shadow', resolved.rounded)} aria-hidden />
- {buttonElement}
- </View>
- ) : (
- <PressTransition
- active={isPressed}
- transition={resolved.behaviors.pressAnimation ? resolved.transition : false}
- className={wrapperClass}
- >
- {buttonElement}
- </PressTransition>
- );
- /* ---------------------------- tooltip wrap -------------------------- */
- if (tooltipWraps) {
- return (
- <Tooltip content={tooltip} side={tooltipSide} enabled className={cn('u-neo-btn-root', schemeClass, rootLayoutClass)}>
- {wrapped}
- </Tooltip>
- );
- }
- return wrapped;
- };
- /* ------------------------------- ref wrapper ------------------------------ */
- export const NeoButtonRef = React.forwardRef<any, NeoButtonProps>((props, forwardedRef) => (
- <NeoButton {...props} forwardedRef={forwardedRef} />
- ));
- /* ------------------------------ link wrapper ------------------------------ */
- export const NeoButtonLink = ({
- href = '',
- // `''` keeps legacy ButtonLink parity: external links open in the same
- // tab unless a `target` is passed.
- target = '',
- asExternal = false,
- style,
- emulate,
- onPress,
- onLongPress: _onLongPress,
- ...props
- }: NeoButtonProps) => {
- const finalHref = sanitazeUrl(href);
- const isExternal = isExternalUrl(finalHref) || asExternal === true;
- // Web: the <a> is the click target, so the hit area lives on it. Resolve
- // it from the same control size the button uses (hitSlop depends only on
- // controlSize + env scope).
- const { hitAreaCls } = useResolvedNeoButton({ controlSize: props.controlSize });
- // Disabled: no anchor, so it is neither navigable nor a tab stop.
- if (props.disabled) {
- return <NeoButton {...props} buttonStyle={style} />;
- }
- // Native external URL: the button itself opens the in-app browser.
- if (isExternal && !isWeb) {
- return (
- <NeoButton
- {...props}
- buttonStyle={style}
- accessibilityRole="link"
- onPress={(event?: any) => {
- onPress?.(event);
- openExternalLink(finalHref);
- }}
- />
- );
- }
- // Web: `classNames.root` lands on the <a>, the outermost box, so layout
- // classes reach the parent's flex layout. Link renders no anchor for an
- // empty href (unless emulated): the classes then stay on the button.
- const webAnchor = isWeb && (!!finalHref || emulate === true);
- const rootOnAnchor = webAnchor ? props.classNames?.root : undefined;
- // Web hit area on the anchor: the size's `hit-area-*`, or `hit-area-sides`
- // with `--neo-hit-*` for an explicit `hitSlop` (number or per-side insets).
- const anchorHit = webAnchor && props.hitarea !== false;
- const customHit = anchorHit && props.hitSlop !== undefined;
- const hitInsets = typeof props.hitSlop === 'number'
- ? { top: props.hitSlop, right: props.hitSlop, bottom: props.hitSlop, left: props.hitSlop }
- : (props.hitSlop ?? {});
- const anchorStyle = customHit
- ? {
- '--neo-hit-t': hitInsets.top ?? 0,
- '--neo-hit-r': hitInsets.right ?? 0,
- '--neo-hit-b': hitInsets.bottom ?? 0,
- '--neo-hit-l': hitInsets.left ?? 0,
- }
- : undefined;
- const linkClassName = cn(
- 'u-neo-btn-link',
- anchorHit ? (customHit ? 'hit-area-sides' : hitAreaCls) : '',
- props.width === 'fill'
- ? cn('block', FILL_WIDTH_CLASS)
- : 'inline-flex items-center max-w-full min-w-0 shrink',
- rootOnAnchor,
- );
- const neoProps: NeoButtonProps = {
- ...props,
- classNames: rootOnAnchor ? { ...props.classNames, root: undefined } : props.classNames,
- buttonStyle: style,
- // Web: the <a> owns the hit area. Native: `Link asChild` makes the
- // NeoButton the pressable, so it keeps its own hitSlop.
- hitarea: webAnchor ? false : props.hitarea,
- interactive: true,
- // Native: announced as a link (web: the <a> is the link; the inner
- // button is not pressable, so it is neither a tab stop nor a button).
- // `role` keeps `Link asChild`'s injected `role="link"` out of the
- // SwiftUI role axis.
- ...(isWeb ? {} : { accessibilityRole: 'link', role: props.role ?? 'default' }),
- };
- const label = props.label ?? props.title;
- const linkAccessibleName =
- props.accessibilityLabel ||
- props.alt ||
- (typeof label === 'string' && label !== '' ? label : undefined) ||
- (typeof props.tooltip === 'string' && props.tooltip !== '' ? props.tooltip : undefined);
- // Runs before navigation. Web `<a>`: onClick (`preventDefault()` cancels
- // navigation). Emulated web links and native: Link's own onPress handler.
- const pressProps = onPress
- ? (isWeb && emulate !== true ? { onClick: onPress } : { onPress })
- : {};
- return (
- <Link
- href={href}
- target={target}
- asExternal={asExternal}
- emulate={emulate}
- mode="plain"
- className={linkClassName}
- alt={linkAccessibleName}
- {...(anchorStyle ? { style: anchorStyle } : null)}
- {...pressProps}
- >
- <NeoButton {...neoProps} />
- </Link>
- );
- };
- /* ----------------------- providers + resolver re-exports ------------------ */
- export {
- NeoButtonStyleProvider,
- NeoControlSizeProvider,
- useNeoButtonStyle,
- useNeoControlSize,
- useResolvedNeoButton,
- useNeoButtonExpoUI,
- usePointerCapability,
- resolveScoped,
- };
- export {
- isExpoUI,
- isNativeTabsEnabled,
- isTabBarLabelsEnabled,
- } from 'app/lib/util';
- /* ----------------------------- internals ---------------------------------- */
- export const __neoButtonInternals = {
- renderLabel,
- PressTransition,
- NeoImage,
- getAccessibleName,
- };
|