neo-button.tsx 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149
  1. /**
  2. * NeoButton — SwiftUI-faithful Button. Props: neo-button.types.ts.
  3. *
  4. * The API mirrors SwiftUI's Button axes:
  5. *
  6. * <Button("Save", systemImage: "tray") { action() }>
  7. * .buttonStyle(.borderedProminent)
  8. * .controlSize(.large)
  9. * .buttonBorderShape(.capsule)
  10. * .tint(.blue)
  11. *
  12. * is rendered here as:
  13. *
  14. * <NeoButton
  15. * label="Save"
  16. * image="Save"
  17. * style="borderedProminent"
  18. * controlSize="large"
  19. * borderShape="capsule"
  20. * tint="#3b82f6"
  21. * onPress={save}
  22. * />
  23. *
  24. * Anything more complex than label + single image goes via children
  25. * (a custom HStack-equivalent). There are no startDecorator /
  26. * endDecorator / leadingDecorator / trailingDecorator props anymore;
  27. * use `image` + `imagePlacement` for the common case, children for the
  28. * rest. This keeps the API mappable 1:1 to `@expo/ui/swift-ui` Button.
  29. *
  30. * Native: `native.expo_ui_buttons` (configs) routes eligible buttons through
  31. * Expo UI (`neo-button-expoui.{ios,android}.tsx`). iOS draws Lucide or SF
  32. * glyphs natively and hosts icon-like custom children (logo) in the SwiftUI
  33. * label. Long-press, labelled custom children, glyphs with no native asset,
  34. * and `selected` on non-glass styles stay on the JS surface (Android: also
  35. * unmapped icons and all custom children). Glass keeps Expo UI for selected
  36. * (tinted liquid glass) and for `addon` counters (SwiftUI child in the label).
  37. */
  38. import React, { createContext, memo, useContext, type ReactNode } from 'react';
  39. import type { ViewStyle } from 'react-native';
  40. import { Pressable, View, MotionView, Row } from 'app/design/view';
  41. import { Text } from 'app/design/typography';
  42. import { Icon } from 'app/ui/atoms/icon';
  43. import Link from 'app/ui/atoms/link';
  44. import Tooltip from 'app/ui/atoms/tooltip';
  45. import Loading from 'app/ui/atoms/loading';
  46. import { useIconClassColor } from 'app/ui/atoms/icon-class-color';
  47. import { useWindowWidth } from 'app/context/measure';
  48. import {
  49. cn,
  50. isWeb,
  51. isEmoji,
  52. appSetting,
  53. getBreakpoint,
  54. FeedbackHaptics,
  55. sanitazeUrl,
  56. isExternalUrl,
  57. openExternalLink,
  58. decodeTextNode,
  59. } from 'app/lib/util';
  60. import {
  61. useResolvedNeoButton,
  62. parseNeoButtonAddon,
  63. useNeoButtonExpoUI,
  64. NeoButtonStyleProvider,
  65. NeoControlSizeProvider,
  66. useNeoButtonStyle,
  67. useNeoControlSize,
  68. usePointerCapability,
  69. useNeoEnv,
  70. resolveScoped,
  71. } from 'app/design/controls/neo-button/neo-button-resolver';
  72. import { NeoButtonExpoUI, getNativeIcon, hostsCustomContent } from 'app/design/controls/neo-button/neo-button-expoui';
  73. import { selectedColorScheme, useNeoButtonColorScheme } from 'app/design/controls/neo-button/color-scheme-context';
  74. import { useNeoButtonGroupItem } from 'app/design/controls/neo-button/neo-button-group';
  75. import type { NeoButtonAddon, NeoButtonClassNames, NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
  76. import { cueAttributes, usePressCueOverride, type PressCue } from 'app/lib/sound';
  77. export type { NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
  78. // `width="fill"` takes leftover space in a flex parent (`flex-auto`), not
  79. // 100% of the parent (`w-full`) — that would shove siblings out of the row.
  80. // `min-w-0` lets the label ellipsis instead of overflowing. Inner surface
  81. // still uses `w-full` so the chrome fills this flex item.
  82. const FILL_WIDTH_CLASS = 'flex-auto min-w-0';
  83. // `showTitleFromSize`: the label shows from this breakpoint up (icon + label
  84. // buttons only). Static literals for Tailwind/Uniwind scanning. Web uses the
  85. // classes; native compares the window breakpoint (`useTitleHidden`).
  86. const BP_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'];
  87. const TITLE_FROM_SIZE_CLASS: Record<string, string> = {
  88. sm: 'hidden sm:block',
  89. md: 'hidden md:block',
  90. lg: 'hidden lg:block',
  91. xl: 'hidden xl:block',
  92. '2xl': 'hidden 2xl:block',
  93. };
  94. /** True below the `showTitleFromSize` breakpoint. Only native acts on it (web uses classes). */
  95. function useTitleHidden(showTitleFromSize: unknown): boolean {
  96. const width = useWindowWidth();
  97. if (typeof showTitleFromSize !== 'string' || !TITLE_FROM_SIZE_CLASS[showTitleFromSize]) return false;
  98. return BP_ORDER.indexOf(getBreakpoint(width) || '') < BP_ORDER.indexOf(showTitleFromSize);
  99. }
  100. const hasImageValue = (image: unknown) =>
  101. (typeof image === 'string' && image !== '') || React.isValidElement(image);
  102. /**
  103. * Resolved label classes + icon size of the enclosing JS NeoButton, for element
  104. * images that should follow the button's state colours (`LegacyDecoratorStack`).
  105. */
  106. type NeoButtonContentValue = { textCls: string; iconSize: number };
  107. const NeoButtonContentContext = createContext<NeoButtonContentValue | null>(null);
  108. export const useNeoButtonContent = () => useContext(NeoButtonContentContext);
  109. const ICON_ACCESSIBLE_MAP: Record<string, string> = {
  110. X: 'Close', ChevronLeft: 'Previous', ChevronRight: 'Next',
  111. ChevronUp: 'Up', ChevronDown: 'Down', Plus: 'Add', Minus: 'Remove',
  112. Trash: 'Delete', Trash2: 'Delete', Edit: 'Edit', Edit2: 'Edit', Edit3: 'Edit',
  113. Search: 'Search', Menu: 'Menu', MoreVertical: 'More options',
  114. MoreHorizontal: 'More options', Heart: 'Like', Star: 'Favorite',
  115. Share: 'Share', Share2: 'Share', MessageCircle: 'Comment',
  116. MessageSquare: 'Messages', Send: 'Send', Bell: 'Notifications',
  117. Settings: 'Settings', Home: 'Home', User: 'Profile', UserRound: 'Login',
  118. LogIn: 'Login', LogOut: 'Logout',
  119. };
  120. const getAccessibleName = (accessibilityLabel: string | undefined, tooltip: unknown, label: unknown, image: unknown): string | undefined => {
  121. if (accessibilityLabel) return accessibilityLabel;
  122. if (tooltip && typeof tooltip === 'string') return tooltip;
  123. if (label && typeof label === 'string') return label;
  124. if (typeof image === 'string') {
  125. if (ICON_ACCESSIBLE_MAP[image]) return ICON_ACCESSIBLE_MAP[image];
  126. return image.replace(/([A-Z])/g, ' $1').trim();
  127. }
  128. return undefined;
  129. };
  130. const getAddon = (addon: NeoButtonAddon, hasLabel: boolean) => {
  131. const parsed = parseNeoButtonAddon(addon);
  132. if (!parsed) return null;
  133. const isPrimary = parsed.variant === 'primary';
  134. const bg = isPrimary ? 'bg-destructive' : 'bg-accent';
  135. const textColor = isPrimary ? 'text-destructive-foreground' : 'text-accent-foreground';
  136. const pos = (typeof addon === 'object' ? addon?.position : undefined) === 'bottom' ? 'bottom-0 -end-1' : '-top-2 -end-2';
  137. const text = parsed.text;
  138. if (!hasLabel) {
  139. return (
  140. <View className={`absolute ${bg} border-2 border-card rounded-full px-1 min-w-6 min-h-6 items-center justify-center ${pos}`}>
  141. <Text className={`${textColor} text-xs font-semibold`}>{text}</Text>
  142. </View>
  143. );
  144. }
  145. return (
  146. <View className="flex-1 items-center flex-row">
  147. <View className={`${bg} rounded-full min-w-5 min-h-5 px-1.5 py-0.5 border border-card/80 items-center`}>
  148. <Text className={`${textColor} text-xs font-semibold`}>{text}</Text>
  149. </View>
  150. </View>
  151. );
  152. };
  153. /* --------------------------- image / label render -------------------------- */
  154. /**
  155. * Loading spinner in the label colour of the current style/state, so it stays
  156. * visible on prominent fills. Web: `currentColor` from the wrapper's `text-*`
  157. * class. Native: the class resolved to a colour (Uniwind).
  158. */
  159. function NeoSpinner({ className, color }: { className?: string; color?: string }) {
  160. const classColor = useIconClassColor(className);
  161. if (isWeb) {
  162. return (
  163. <View className={cn(className, 'overflow-visible')}>
  164. <Loading size="small" color={color ?? 'currentColor'} />
  165. </View>
  166. );
  167. }
  168. return <Loading size="small" color={color ?? classColor} />;
  169. }
  170. const NeoImage = memo(function NeoImage({ source, size, color, className }: {
  171. source?: unknown;
  172. size?: number;
  173. color?: string;
  174. className?: string;
  175. }) {
  176. if (source === undefined || source === null || source === '' || source === false) {
  177. return null;
  178. }
  179. if (source === '_loading') return <NeoSpinner className={className} color={color} />;
  180. if (React.isValidElement(source)) return source;
  181. if (typeof source !== 'string') return null;
  182. if (isEmoji(source)) {
  183. return <Text className={className} style={color ? { color } : undefined}>{source}</Text>;
  184. }
  185. return (
  186. <Icon
  187. size={size}
  188. className={cn(className, 'pointer-events-none')}
  189. icon={source}
  190. {...(color ? { color } : {})}
  191. />
  192. );
  193. });
  194. /**
  195. * SwiftUI Label(title:image:) equivalent. Wins over `image`/`label` props
  196. * when `children` is provided (custom HStack content).
  197. */
  198. function renderLabel({
  199. label, image, imagePlacement,
  200. iconSize, labelGap, labelGapCls, fontCls, textCls, tintColor, classNames, loading,
  201. spreadContent, hideLabel,
  202. }: {
  203. label: ReactNode;
  204. image: unknown;
  205. imagePlacement: string;
  206. iconSize: number;
  207. labelGap: number;
  208. labelGapCls?: string;
  209. fontCls?: string;
  210. textCls?: string;
  211. tintColor?: string;
  212. classNames?: NeoButtonClassNames;
  213. loading?: boolean;
  214. spreadContent?: boolean;
  215. /** Native `showTitleFromSize` below its breakpoint: keep the padding, drop the Text. */
  216. hideLabel?: boolean;
  217. }) {
  218. // While loading, the label and image keep their place, invisible, under the
  219. // centred spinner, so the button keeps its size. Only a button with neither
  220. // draws the spinner here, in the image slot.
  221. const effectiveImage = (loading && !label && !image) ? '_loading' : image;
  222. if (!label && !effectiveImage) return null;
  223. const spreadTrailing =
  224. spreadContent && imagePlacement === 'trailing' && !!effectiveImage && !!label;
  225. // Prefer the gap utility class; fall back to inline columnGap if the
  226. // resolved value isn't mapped. `spreadTrailing` uses justify-between (no gap).
  227. const useGapClass = !spreadTrailing && !!labelGapCls;
  228. return (
  229. <Row
  230. className={cn(
  231. 'flex-row items-center min-w-0 max-w-full',
  232. spreadTrailing && 'flex-1 w-full justify-between',
  233. useGapClass && labelGapCls,
  234. loading && effectiveImage !== '_loading' && 'opacity-0',
  235. )}
  236. style={(spreadTrailing || useGapClass) ? undefined : { columnGap: labelGap }}
  237. >
  238. {imagePlacement === 'leading' && effectiveImage ? (
  239. <View className="shrink-0">
  240. <NeoImage
  241. source={effectiveImage}
  242. size={iconSize}
  243. color={tintColor}
  244. className={cn(textCls, classNames?.image)}
  245. />
  246. </View>
  247. ) : null}
  248. {label && !hideLabel ? (
  249. <Text
  250. className={cn('min-w-0 shrink', fontCls, textCls, classNames?.text)}
  251. {...(isWeb ? {} : { numberOfLines: 1 })}
  252. style={tintColor ? { color: tintColor } : undefined}
  253. >
  254. {label}
  255. </Text>
  256. ) : null}
  257. {imagePlacement === 'trailing' && effectiveImage ? (
  258. <View className="shrink-0">
  259. <NeoImage
  260. source={effectiveImage}
  261. size={iconSize}
  262. color={tintColor}
  263. className={cn(textCls, classNames?.image)}
  264. />
  265. </View>
  266. ) : null}
  267. </Row>
  268. );
  269. }
  270. /* -------------------- transition wrapper (per style) ---------------------- */
  271. const getWebTransition = (press: any, property: string) => {
  272. const duration = press?.duration ?? (press?.spring ? 140 : 120);
  273. return {
  274. transitionProperty: property,
  275. transitionDuration: `${duration}ms`,
  276. transitionTimingFunction: 'cubic-bezier(0.2, 0, 0, 1)',
  277. willChange: property,
  278. };
  279. };
  280. function PressTransition({ active, transition, children, className }: {
  281. active: boolean;
  282. transition: any;
  283. children?: ReactNode;
  284. className?: string;
  285. }) {
  286. const press = transition?.press;
  287. if (!press || press === false || press.type === 'shadow') {
  288. // Style-driven press feedback via class swap only. No wrapper, so no
  289. // inline `transform: scale(1)` is left behind on every button.
  290. return <View className={className}>{children}</View>;
  291. }
  292. if (press.type === 'opacity') {
  293. if (isWeb) {
  294. return (
  295. <View
  296. className={className}
  297. style={{
  298. opacity: active ? (press.to ?? 0.85) : (press.from ?? 1),
  299. ...getWebTransition(press, 'opacity'),
  300. }}
  301. >
  302. {children}
  303. </View>
  304. );
  305. }
  306. return (
  307. <MotionView
  308. className={className}
  309. animate={{ opacity: active ? (press.to ?? 0.85) : (press.from ?? 1) }}
  310. transition={press.spring ? { type: 'spring', ...press.spring } : { duration: (press.duration ?? 100) / 1000 }}
  311. >
  312. {children}
  313. </MotionView>
  314. );
  315. }
  316. // Default: 'scale'.
  317. if (isWeb) {
  318. const scale = active ? (press.to ?? 0.97) : (press.from ?? 1);
  319. return (
  320. <View
  321. className={className}
  322. style={{
  323. transform: `scale(${scale})`,
  324. ...getWebTransition(press, 'transform'),
  325. }}
  326. >
  327. {children}
  328. </View>
  329. );
  330. }
  331. return (
  332. <MotionView
  333. className={className}
  334. animate={{ scale: active ? (press.to ?? 0.97) : (press.from ?? 1) }}
  335. transition={press.spring ? { type: 'spring', ...press.spring } : undefined}
  336. >
  337. {children}
  338. </MotionView>
  339. );
  340. }
  341. function PressHighlight({ active, rounded, color }: { active: boolean; rounded?: string; color?: string }) {
  342. const className = cn('absolute inset-0 pointer-events-none z-10', rounded);
  343. if (isWeb) {
  344. return (
  345. <View
  346. className={className}
  347. style={{
  348. backgroundColor: color,
  349. opacity: active ? 0.18 : 0,
  350. transitionProperty: 'opacity',
  351. transitionDuration: '120ms',
  352. transitionTimingFunction: 'ease-out',
  353. } as ViewStyle}
  354. />
  355. );
  356. }
  357. return (
  358. <MotionView
  359. className={className}
  360. animate={{ opacity: active ? 0.18 : 0 }}
  361. transition={{ duration: 0.12 }}
  362. style={{ backgroundColor: color }}
  363. />
  364. );
  365. }
  366. /* --------------------------- native mode gating ---------------------------- */
  367. function isNativeGlassStyle(style: unknown) {
  368. return style === 'glass' || style === 'glassProminent';
  369. }
  370. function canRenderExpoUIButton(props: NeoButtonProps, native: { config: Record<string, any> }) {
  371. if (props.expoUI === false) return false;
  372. const hasChildren = props.children !== undefined && props.children !== null && props.children !== false;
  373. if (props.onLongPress) return false;
  374. const style = props.buttonStyle ?? props.style;
  375. const glass = isNativeGlassStyle(style);
  376. // Passive buttons (chips, drag handles, children-triggers) stay JS so a
  377. // native button never swallows the parent's touches. Passive glass keeps
  378. // Expo UI (liquid glass menu triggers in headers).
  379. if (!props.onPress && !glass) return false;
  380. // iOS Expo UI would render String(label) = "[object Object]".
  381. if (React.isValidElement(props.label ?? props.title)) return false;
  382. // Expo UI has no spinner.
  383. if (props.loading !== undefined && !glass) return false;
  384. // Glass keeps Expo UI for selected + addon. Other styles have no native
  385. // selected/counter mapping and stay on the JS `selectedState` wash.
  386. if (parseNeoButtonAddon(props.addon) && !glass) return false;
  387. if (props.selected !== undefined && !glass) return false;
  388. const label = props.label ?? props.title;
  389. const hasLabel = (typeof label === 'string' && label !== '') || typeof label === 'number';
  390. // Icon-like custom content (header logo, avatar): iOS hosts it in the
  391. // SwiftUI label so it keeps native glass. Labelled custom rows stay JS.
  392. if (hasChildren) return hostsCustomContent && !hasLabel && React.isValidElement(props.children);
  393. const rawImage = props.systemImage ?? props.image;
  394. const hasImage = (typeof rawImage === 'string' && rawImage !== '') || React.isValidElement(rawImage);
  395. if (!hasLabel && !hasImage) return false;
  396. if (!hasImage) return true;
  397. if (React.isValidElement(rawImage)) return hostsCustomContent;
  398. return !!getNativeIcon(rawImage, native.config);
  399. }
  400. /* -------------------------- main Button component ------------------------- */
  401. const TEXT_PROPS = ['label', 'title', 'loadingLabel', 'accessibilityLabel', 'alt', 'tooltip'] as const;
  402. /**
  403. * UNA sends titles HTML-escaped (`&amp;`). `Text` decodes the JS label, but the
  404. * same strings also become accessible names, tooltips and SwiftUI / Compose
  405. * labels, so decode them once here. Props without an entity come back as is.
  406. */
  407. const decodeTextProps = (props: NeoButtonProps): NeoButtonProps => {
  408. const hasEntity = TEXT_PROPS.some((key) => {
  409. const value = props[key];
  410. return typeof value === 'string' && value.includes('&');
  411. });
  412. if (!hasEntity) return props;
  413. return {
  414. ...props,
  415. label: decodeTextNode(props.label),
  416. title: decodeTextNode(props.title),
  417. loadingLabel: decodeTextNode(props.loadingLabel),
  418. accessibilityLabel: decodeTextNode(props.accessibilityLabel),
  419. alt: decodeTextNode(props.alt),
  420. tooltip: decodeTextNode(props.tooltip),
  421. };
  422. };
  423. export const NeoButton = (rawProps: NeoButtonProps) => {
  424. const props = decodeTextProps(rawProps);
  425. const native = useNeoButtonExpoUI(props);
  426. const titleHidden = useTitleHidden(props.showTitleFromSize);
  427. // A group segment draws on the group's surface, so it stays JS.
  428. const segment = useNeoButtonGroupItem();
  429. if (native && NeoButtonExpoUI && !segment) {
  430. // `showTitleFromSize` below its breakpoint: the native button turns
  431. // icon-only and keeps the label as its accessible name.
  432. const label = props.label ?? props.title;
  433. const dropTitle = titleHidden && !!label && hasImageValue(props.systemImage ?? props.image);
  434. const expoProps: NeoButtonProps = dropTitle
  435. ? {
  436. ...props,
  437. label: undefined,
  438. title: undefined,
  439. accessibilityLabel: props.accessibilityLabel
  440. ?? (typeof label === 'string' || typeof label === 'number' ? String(label) : undefined),
  441. }
  442. : props;
  443. if (canRenderExpoUIButton(expoProps, native)) {
  444. return <NeoButtonExpoUI {...expoProps} nativeConfig={native.config} />;
  445. }
  446. }
  447. return <NeoButtonStyled {...props} titleHidden={titleHidden} />;
  448. };
  449. /**
  450. * A button's press cue when it sets none (Cuelume's "Which cue?"): a menu
  451. * trigger opens or closes, a close / cancel button closes, a prominent or
  452. * confirming button taps, other buttons tap subtly. Buttons without haptics
  453. * (editor toolbars, object actions that play their own cue) stay quiet.
  454. */
  455. function defaultPressCue({ role, style, haptics, menuTrigger, expanded }: {
  456. role: string;
  457. style: string;
  458. haptics: boolean;
  459. menuTrigger: boolean;
  460. expanded: boolean;
  461. }): PressCue {
  462. if (menuTrigger) return { cue: expanded ? 'close' : 'open', emphasis: 'subtle' };
  463. if (!haptics) return false;
  464. if (role === 'close') return 'close';
  465. if (role === 'cancel') return { cue: 'close', emphasis: 'subtle' };
  466. if (role === 'confirm' || style === 'borderedProminent' || style === 'glassProminent') return 'tap';
  467. return { cue: 'tap', emphasis: 'subtle' };
  468. }
  469. /* ----------------------- JS-styled implementation ------------------------- */
  470. const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
  471. const {
  472. // SwiftUI core (`buttonStyle` avoids RN `Link` clobbering `style` on native)
  473. role, style, buttonStyle, controlSize, borderShape, tint,
  474. // Content
  475. label, loadingLabel, title, image, systemImage, imagePlacement,
  476. // Behaviour
  477. disabled = false,
  478. loading = false,
  479. selected = false,
  480. selectedState = 'pressedToggle',
  481. addon = '',
  482. interactive = false,
  483. haptics,
  484. sound,
  485. onPress,
  486. onPressIn,
  487. onPressOut,
  488. onLongPress,
  489. // Layout
  490. width, align, contentInsets,
  491. showTitleFromSize = '',
  492. // Accessibility / web
  493. accessibilityLabel, alt, tooltip = false, tooltipSide,
  494. hitarea = true, hitSlop, focusRing, accessibilityRole,
  495. // Native renderer gating (handled in `NeoButton`).
  496. expoUI: _expoUI, titleHidden = false,
  497. // Animation override
  498. transition,
  499. pressAnimation,
  500. glassEffect,
  501. // Style escape hatches
  502. className = '', textClassName = '', classNames = {},
  503. // Children = custom HStack-equivalent label content
  504. children,
  505. forwardedRef,
  506. // Link-related: forwarded by NeoButtonLink, ignored here.
  507. href, target, asExternal, emulate, variant, size, mode, noprefetch,
  508. // Anything else (e.g. data-* attrs) is forwarded to the surface.
  509. ...rest
  510. } = props;
  511. // Inside a NeoButtonGroup: the group's role and tint unless the button sets
  512. // its own (style and size come through the group's providers). No press
  513. // scale, which would push the segment over its neighbours, and no web glass
  514. // layers: the group draws the surface.
  515. const segment = useNeoButtonGroupItem();
  516. const resolved = useResolvedNeoButton({
  517. role: role ?? segment?.role,
  518. style, buttonStyle, controlSize, borderShape,
  519. tint: tint ?? segment?.tint,
  520. transition: segment ? false : transition,
  521. focusRing,
  522. pressAnimation: segment ? false : pressAnimation,
  523. glassEffect: segment ? 'standard' : glassEffect,
  524. image: image ?? systemImage,
  525. systemImage,
  526. imagePlacement, align, width, disabled, haptics,
  527. });
  528. // Effective content props.
  529. // While `loading=true` the content stays as it is (hidden under a centred
  530. // spinner, so the button keeps its size), and `loadingLabel`, when given,
  531. // becomes the accessible name instead of swapping the visible text.
  532. const effectiveLabel = label ?? title ?? '';
  533. const loadingName = loading && loadingLabel ? loadingLabel : undefined;
  534. const effectiveImage = image ?? systemImage ?? resolved.defaultImage;
  535. const effectivePlacement = imagePlacement ?? resolved.imagePlacement;
  536. const isCustomChildren = children !== undefined && children !== null && children !== false;
  537. const isTitle = !!effectiveLabel;
  538. const hasImage = !!effectiveImage || loading;
  539. const isIconOnly = hasImage && !isTitle && !isCustomChildren;
  540. const isPressable = !!(onPress || onLongPress || href);
  541. const isInteractive = !disabled && (isPressable || interactive);
  542. const isActive = isPressable && !disabled;
  543. const isPressedToggle = !!selected;
  544. const soundOverride = usePressCueOverride();
  545. const accessibleName =
  546. loadingName ?? accessibilityLabel ?? alt ?? getAccessibleName(undefined, tooltip, effectiveLabel, effectiveImage);
  547. /* ----------------------- interaction state -------------------------- */
  548. const [isPressed, setIsPressed] = React.useState(false);
  549. const [isHovered, setIsHovered] = React.useState(false);
  550. const [isFocused, setIsFocused] = React.useState(false);
  551. const stateKey =
  552. disabled ? 'disabled' :
  553. isPressedToggle ? selectedState :
  554. isPressed ? 'pressed' :
  555. isHovered ? 'hovered' :
  556. isFocused ? 'focused' :
  557. 'default';
  558. /* ------------------------------ handlers ---------------------------- */
  559. // Haptics fire on press-in (finger down), not onPress (finger up): JS
  560. // Pressable has no built-in feedback, so this is what makes styled
  561. // buttons answer the touch when `native.expo_ui_buttons` is off.
  562. const fireHaptics = () => {
  563. if (resolved.haptics) FeedbackHaptics(resolved.haptics);
  564. };
  565. const handlePress = (event: any) => {
  566. // `loading` blocks presses (the button keeps focus and stays in the tab order).
  567. if (loading) return;
  568. // Blur only after a real pointer click; keyboard activation keeps focus (and the ring).
  569. if (isWeb && event?.type === 'click' && event?.detail > 0) event?.currentTarget?.blur?.();
  570. onPress?.(event);
  571. };
  572. const handlePressIn = (event: any) => {
  573. if (!loading) fireHaptics();
  574. setIsPressed(true);
  575. onPressIn?.(event);
  576. };
  577. const handleLongPress = (event: any) => {
  578. onLongPress?.(event);
  579. };
  580. const refProps = forwardedRef ? { ref: forwardedRef } : {};
  581. // A menu trigger reports its state with `aria-expanded` (DropdownPopup);
  582. // `aria-pressed` next to it would announce a toggle instead.
  583. const isMenuTrigger = !!rest['aria-haspopup'];
  584. // Web press sound (`bind()` plays it from the attributes). Native buttons
  585. // answer with haptics only.
  586. const pressCue: PressCue = sound !== undefined
  587. ? sound
  588. : soundOverride !== undefined
  589. ? soundOverride
  590. : defaultPressCue({
  591. role: role ?? 'default',
  592. style: resolved.style,
  593. haptics: !!resolved.haptics,
  594. menuTrigger: isMenuTrigger,
  595. expanded: !!rest['aria-expanded'],
  596. });
  597. const soundAttributes = isWeb && isActive && !loading ? cueAttributes(pressCue) : {};
  598. const buttonAttributes = isPressable
  599. ? {
  600. ...(accessibleName ? { 'aria-label': accessibleName, alt: accessibleName } : {}),
  601. role: accessibilityRole || 'button',
  602. ...(isPressedToggle && !isMenuTrigger ? { 'aria-pressed': true } : {}),
  603. ...(disabled ? { 'aria-disabled': true } : {}),
  604. ...(loading ? { 'aria-busy': true } : {}),
  605. }
  606. : {};
  607. // hitSlop (theme `neo_button.controlSizes`) is the single source of truth.
  608. // Native applies it to the RN Pressable via this prop; web mirrors it with
  609. // the `hit-area-*` utility class (see `hitAreaClass` below) — RN has no
  610. // `::before`, so the two platforms diverge in mechanism but not in value.
  611. // A segment's hit area extends only past the group's outer edges, so no
  612. // segment covers its neighbour.
  613. const effectiveHitSlop = hitSlop ?? (segment && hitarea !== false ? {
  614. top: resolved.hitSlop,
  615. bottom: resolved.hitSlop,
  616. left: segment.outerStart ? resolved.hitSlop : 0,
  617. right: segment.outerEnd ? resolved.hitSlop : 0,
  618. } : undefined);
  619. const resolvedHitSlop = effectiveHitSlop !== undefined
  620. ? effectiveHitSlop
  621. : (hitarea === false ? undefined : {
  622. top: resolved.hitSlop, right: resolved.hitSlop,
  623. bottom: resolved.hitSlop, left: resolved.hitSlop,
  624. });
  625. /* --------------------------- sizing / padding ----------------------- */
  626. // Sizing comes from utility classes (theme numbers → classes in the
  627. // resolver). When a value isn't mapped we fall back to inline styles so
  628. // nothing silently breaks.
  629. const sizeClass = resolved.heightCls ?? '';
  630. const needsSizeFallback = !resolved.heightCls;
  631. const resolvedContentInsets = typeof contentInsets === 'string'
  632. ? resolved.contentInsets?.[contentInsets]
  633. : contentInsets;
  634. // contentInsets (when present) override padding per-side via inline style so
  635. // asymmetric media insets still work; the common case uses the px-* class.
  636. const insetLeft = resolvedContentInsets?.left ?? resolvedContentInsets?.start ?? resolvedContentInsets?.x;
  637. const insetRight = resolvedContentInsets?.right ?? resolvedContentInsets?.end ?? resolvedContentInsets?.x;
  638. // Icon-only normally drops to `px-0` so the box is square. Shapes flagged
  639. // `iconOnlyPadded` (capsule) keep the labelled button's padding instead, so
  640. // they stay visibly wider than a circle.
  641. const keepsPaddingWhenIconOnly = !isIconOnly || resolved.iconOnlyPadded;
  642. const pinIconOnlySquare = isIconOnly && !resolved.iconOnlyPadded;
  643. const paddingClass = keepsPaddingWhenIconOnly ? (resolved.paddingXCls ?? '') : 'px-0';
  644. const needsPaddingFallback = keepsPaddingWhenIconOnly && !resolved.paddingXCls;
  645. // Web-only hit-area utility (RN uses the hitSlop prop instead). See the
  646. // `hit-area-*` @utility in global.css. Only active buttons get one, so a
  647. // disabled button does not capture its neighbours' clicks. An explicit
  648. // `hitSlop` prop (number or per-side insets) uses `hit-area-sides` with
  649. // `--neo-hit-*` variables.
  650. const webHitArea = hitarea !== false && isActive && isWeb;
  651. const customHit = webHitArea && effectiveHitSlop !== undefined;
  652. const hitInsets = typeof effectiveHitSlop === 'number'
  653. ? { top: effectiveHitSlop, right: effectiveHitSlop, bottom: effectiveHitSlop, left: effectiveHitSlop }
  654. : (effectiveHitSlop ?? {});
  655. const hitAreaClass = !webHitArea ? '' : (customHit ? 'hit-area-sides' : (resolved.hitAreaCls ?? ''));
  656. /* ------------------- container / text classes ----------------------- */
  657. // A segment's corners: the group's radius on its outer edges, square inside.
  658. const roundedCls = segment ? segment.roundedCls : resolved.rounded;
  659. const containerCls = cn(
  660. 'flex-row items-center web:transition-[background-color,box-shadow,opacity] web:duration-200 web:ease-out',
  661. isInteractive && 'web:cursor-pointer',
  662. roundedCls,
  663. (resolved.aspectSquare || pinIconOnlySquare) ? 'aspect-square' : '',
  664. sizeClass,
  665. paddingClass,
  666. `justify-${resolved.align}`,
  667. segment ? resolved.segmentCls(stateKey) : resolved.containerCls(stateKey),
  668. // `min-w-0` is for labelled ellipsis. On icon-only it tw-merges away
  669. // `min-w-*` from the size class and the box collapses to the glyph.
  670. resolved.width === 'fill'
  671. ? 'w-full'
  672. : (pinIconOnlySquare ? '' : 'min-w-0 max-w-full'),
  673. className,
  674. classNames?.container,
  675. classNames?.surface,
  676. );
  677. const textCls = cn(
  678. 'whitespace-nowrap text-ellipsis overflow-hidden',
  679. resolved.textCls(stateKey),
  680. textClassName,
  681. );
  682. // Styles where `tint` repaints the surface as a fully opaque fill
  683. // (and the text inherits the prominent foreground colour, not the tint).
  684. // Mirrors SwiftUI's simple split: prominent styles use tint as fill;
  685. // non-prominent styles use tint for the label/image accent.
  686. const isProminent = resolved.style === 'borderedProminent' || resolved.style === 'glassProminent';
  687. // A segment's fill is the group's (it paints the tint itself).
  688. const tintFillsSurface = !segment && resolved.tint && isProminent;
  689. // Only the values that have no utility-class equivalent remain inline:
  690. // unmapped size/padding fallbacks, asymmetric contentInsets overrides, and
  691. // the arbitrary `tint` colour.
  692. const containerStyle = {
  693. ...(needsSizeFallback
  694. ? (resolved.aspectSquare
  695. ? { height: resolved.height, width: resolved.height }
  696. : { minHeight: resolved.height, minWidth: resolved.height })
  697. : null),
  698. ...(needsPaddingFallback ? { paddingLeft: resolved.paddingX, paddingRight: resolved.paddingX } : null),
  699. ...(insetLeft != null ? { paddingLeft: insetLeft } : null),
  700. ...(insetRight != null ? { paddingRight: insetRight } : null),
  701. ...(tintFillsSurface ? { backgroundColor: resolved.tint } : null),
  702. ...(customHit ? {
  703. '--neo-hit-t': hitInsets.top ?? 0,
  704. '--neo-hit-r': hitInsets.right ?? 0,
  705. '--neo-hit-b': hitInsets.bottom ?? 0,
  706. '--neo-hit-l': hitInsets.left ?? 0,
  707. } : null),
  708. };
  709. /* --------------------------- web ergonomics ------------------------- */
  710. const ringClass = !resolved.behaviors.focusRing
  711. ? 'u-neo-btn-ring-never'
  712. : (isInteractive ? 'u-neo-btn-ring' : '');
  713. /* ----------------------------- surface ------------------------------ */
  714. // Web Pressable does not surface onPressIn/Out to the DOM (see view.web.tsx).
  715. // Use View + pointer handlers for press visuals; onPress maps to onClick there.
  716. // Native keeps RN Pressable for press-in/out, hitSlop, and long-press.
  717. const useNativePressable = isPressable && !isWeb;
  718. const Cnt = useNativePressable ? Pressable : View;
  719. const hoverHandlers = resolved.behaviors.hover && isInteractive ? {
  720. onMouseEnter: () => setIsHovered(true),
  721. onMouseLeave: () => {
  722. setIsHovered(false);
  723. setIsPressed(false);
  724. },
  725. } : {};
  726. const pointerPressHandlers = isInteractive && isWeb ? {
  727. onPointerDown: (event: any) => {
  728. if (disabled) return;
  729. if (event.pointerType === 'mouse' && event.button !== 0) return;
  730. if (!loading) fireHaptics();
  731. setIsPressed(true);
  732. },
  733. onPointerUp: () => setIsPressed(false),
  734. onPointerCancel: () => setIsPressed(false),
  735. onPointerLeave: () => setIsPressed(false),
  736. } : {};
  737. const cntProps: Record<string, any> = {
  738. ...rest,
  739. ...soundAttributes,
  740. className: cn(
  741. `neo-btn neo-btn-${resolved.style} neo-btn-${resolved.controlSize}`,
  742. resolved.glassFx && cn('neo-glass', resolved.style === 'glassProminent' && 'neo-glass-prominent'),
  743. hitAreaClass,
  744. ringClass,
  745. classNames?.ring,
  746. ),
  747. style: containerStyle,
  748. ...refProps,
  749. ...buttonAttributes,
  750. ...(useNativePressable ? {
  751. disabled: !isActive,
  752. hitSlop: resolvedHitSlop,
  753. onPressIn: handlePressIn,
  754. onPressOut: (event: any) => {
  755. setIsPressed(false);
  756. onPressOut?.(event);
  757. },
  758. onFocus: () => setIsFocused(true),
  759. onBlur: () => setIsFocused(false),
  760. ...hoverHandlers,
  761. ...(onPress ? { onPress: handlePress } : {}),
  762. ...(onLongPress && resolved.behaviors.longPress ? { onLongPress: handleLongPress } : {}),
  763. } : {
  764. ...hoverHandlers,
  765. ...pointerPressHandlers,
  766. ...(isPressable ? {
  767. disabled: !isActive,
  768. onFocus: () => setIsFocused(true),
  769. onBlur: () => setIsFocused(false),
  770. ...(onPress ? { onPress: handlePress } : {}),
  771. } : {}),
  772. }),
  773. };
  774. /* ------------------------------ content ----------------------------- */
  775. const tintColor = (() => {
  776. if (!resolved.tint) return undefined;
  777. // When tint repaints the surface (borderedProminent / glassProminent),
  778. // the label/image keep the prominent foreground colour from the theme
  779. // so contrast stays correct on the freshly-tinted background.
  780. if (tintFillsSurface || (segment && isProminent)) return undefined;
  781. return resolved.tint;
  782. })();
  783. // `showTitleFromSize` (icon + label only): web hides the label with
  784. // responsive classes; native drops the Text below the breakpoint. Either
  785. // way the button stays labelled (padding, accessible name).
  786. const titleFromSizeCls = (typeof showTitleFromSize === 'string' && isTitle && !!effectiveImage && !isCustomChildren)
  787. ? TITLE_FROM_SIZE_CLASS[showTitleFromSize]
  788. : undefined;
  789. const labelClassNames = titleFromSizeCls && isWeb
  790. ? { ...classNames, text: cn(classNames?.text, titleFromSizeCls) }
  791. : classNames;
  792. // The spinner sits over the content, which stays in place invisible (see
  793. // `renderLabel`). A button with no content draws it inline instead.
  794. const spinnerOverlay = loading && (isTitle || !!effectiveImage || isCustomChildren);
  795. // Custom children get a wrapper whenever the button takes `loading` at all,
  796. // so toggling it only fades them: a new parent would remount them and reset
  797. // their state. Buttons without `loading` render them as before.
  798. const childrenContent = props.loading === undefined
  799. ? children
  800. : <Row className={cn('flex-row items-center', loading && 'opacity-0')}>{children}</Row>;
  801. const labelContent = isCustomChildren
  802. ? childrenContent
  803. : renderLabel({
  804. label: effectiveLabel,
  805. image: effectiveImage,
  806. imagePlacement: effectivePlacement,
  807. iconSize: resolved.iconSize,
  808. labelGap: resolved.labelGap,
  809. labelGapCls: resolved.labelGapCls,
  810. fontCls: resolved.fontCls,
  811. textCls,
  812. tintColor,
  813. classNames: labelClassNames,
  814. loading,
  815. spreadContent: resolved.align === 'between',
  816. hideLabel: !isWeb && !!titleFromSizeCls && titleHidden,
  817. });
  818. const addonContent = getAddon(addon, isTitle);
  819. const glassFx = resolved.glassFx;
  820. // Consumers are this button's own label content, which re-renders with it.
  821. const contentValue = { textCls, iconSize: resolved.iconSize };
  822. const buttonElement = (
  823. <Cnt {...cntProps} className={cn(cntProps.className, containerCls)}>
  824. {glassFx ? <View className="neo-glass-rim" aria-hidden /> : null}
  825. <NeoButtonContentContext.Provider value={contentValue}>
  826. {labelContent}
  827. </NeoButtonContentContext.Provider>
  828. {spinnerOverlay ? (
  829. <View className="absolute inset-0 items-center justify-center pointer-events-none">
  830. <NeoSpinner className={cn(textCls, classNames?.image)} color={tintColor} />
  831. </View>
  832. ) : null}
  833. {addonContent && (
  834. 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>
  835. )}
  836. {/* press highlight overlay (visual feedback, runs in addition to
  837. the per-style transition). Glass effect draws its own. */}
  838. {glassFx ? (
  839. <View className="neo-glass-shine" aria-hidden />
  840. ) : (resolved.style !== 'plain' && resolved.style !== 'link') ? (
  841. <PressHighlight
  842. active={isPressed}
  843. rounded={roundedCls}
  844. color={resolved.highlightBg}
  845. />
  846. ) : null}
  847. </Cnt>
  848. );
  849. // `u-neo-btn-root` lifts a focused button above later siblings so they
  850. // cannot paint over its ring (global.web.css). The layout classes go on
  851. // the outermost box: the Tooltip wrapper when there is one.
  852. const { isDesktop } = useNeoEnv();
  853. // A surface with its own brightness (header on a coloured band): on web the
  854. // root becomes a `.light` / `.dark` colour-scheme scope, so the button draws
  855. // with that scheme's tokens (global.css), the opposite one when selected.
  856. // iOS: see color-scheme-context.
  857. const colorScheme = selectedColorScheme(useNeoButtonColorScheme(), !!selected);
  858. const schemeClass = isWeb && colorScheme ? colorScheme : '';
  859. // Web only: native Tooltip renders no wrapper and drops `className`, so a
  860. // wide native screen (isDesktop) would lose the root layout classes.
  861. const tooltipWraps = isWeb && !!tooltip && isDesktop && !!appSetting('layout', 'tooltips');
  862. const rootLayoutClass = cn(
  863. // Segments of a `width="fill"` group share its width.
  864. resolved.width === 'fill' || segment?.fill
  865. ? FILL_WIDTH_CLASS
  866. : (pinIconOnlySquare ? 'self-start' : 'self-start max-w-full min-w-0'),
  867. classNames?.root,
  868. );
  869. const wrapperClass = cn('u-neo-btn-root', schemeClass, tooltipWraps ? '' : rootLayoutClass);
  870. // Glass effect: press/hover motion is CSS keyed off `data-neo-glass`,
  871. // so the JS PressTransition scale is skipped.
  872. const wrapped = glassFx ? (
  873. <View
  874. className={cn(
  875. 'neo-glass-wrap',
  876. `neo-glass-${resolved.controlSize}`,
  877. !resolved.behaviors.pressAnimation && 'neo-glass-still',
  878. wrapperClass,
  879. )}
  880. data-neo-glass={stateKey}
  881. >
  882. <View className={cn('neo-glass-shadow', resolved.rounded)} aria-hidden />
  883. {buttonElement}
  884. </View>
  885. ) : (
  886. <PressTransition
  887. active={isPressed}
  888. transition={resolved.behaviors.pressAnimation ? resolved.transition : false}
  889. className={wrapperClass}
  890. >
  891. {buttonElement}
  892. </PressTransition>
  893. );
  894. /* ---------------------------- tooltip wrap -------------------------- */
  895. if (tooltipWraps) {
  896. return (
  897. <Tooltip content={tooltip} side={tooltipSide} enabled className={cn('u-neo-btn-root', schemeClass, rootLayoutClass)}>
  898. {wrapped}
  899. </Tooltip>
  900. );
  901. }
  902. return wrapped;
  903. };
  904. /* ------------------------------- ref wrapper ------------------------------ */
  905. export const NeoButtonRef = React.forwardRef<any, NeoButtonProps>((props, forwardedRef) => (
  906. <NeoButton {...props} forwardedRef={forwardedRef} />
  907. ));
  908. /* ------------------------------ link wrapper ------------------------------ */
  909. export const NeoButtonLink = ({
  910. href = '',
  911. // `''` keeps legacy ButtonLink parity: external links open in the same
  912. // tab unless a `target` is passed.
  913. target = '',
  914. asExternal = false,
  915. style,
  916. emulate,
  917. onPress,
  918. onLongPress: _onLongPress,
  919. ...props
  920. }: NeoButtonProps) => {
  921. const finalHref = sanitazeUrl(href);
  922. const isExternal = isExternalUrl(finalHref) || asExternal === true;
  923. // Web: the <a> is the click target, so the hit area lives on it. Resolve
  924. // it from the same control size the button uses (hitSlop depends only on
  925. // controlSize + env scope).
  926. const { hitAreaCls } = useResolvedNeoButton({ controlSize: props.controlSize });
  927. // Disabled: no anchor, so it is neither navigable nor a tab stop.
  928. if (props.disabled) {
  929. return <NeoButton {...props} buttonStyle={style} />;
  930. }
  931. // Native external URL: the button itself opens the in-app browser.
  932. if (isExternal && !isWeb) {
  933. return (
  934. <NeoButton
  935. {...props}
  936. buttonStyle={style}
  937. accessibilityRole="link"
  938. onPress={(event?: any) => {
  939. onPress?.(event);
  940. openExternalLink(finalHref);
  941. }}
  942. />
  943. );
  944. }
  945. // Web: `classNames.root` lands on the <a>, the outermost box, so layout
  946. // classes reach the parent's flex layout. Link renders no anchor for an
  947. // empty href (unless emulated): the classes then stay on the button.
  948. const webAnchor = isWeb && (!!finalHref || emulate === true);
  949. const rootOnAnchor = webAnchor ? props.classNames?.root : undefined;
  950. // Web hit area on the anchor: the size's `hit-area-*`, or `hit-area-sides`
  951. // with `--neo-hit-*` for an explicit `hitSlop` (number or per-side insets).
  952. const anchorHit = webAnchor && props.hitarea !== false;
  953. const customHit = anchorHit && props.hitSlop !== undefined;
  954. const hitInsets = typeof props.hitSlop === 'number'
  955. ? { top: props.hitSlop, right: props.hitSlop, bottom: props.hitSlop, left: props.hitSlop }
  956. : (props.hitSlop ?? {});
  957. const anchorStyle = customHit
  958. ? {
  959. '--neo-hit-t': hitInsets.top ?? 0,
  960. '--neo-hit-r': hitInsets.right ?? 0,
  961. '--neo-hit-b': hitInsets.bottom ?? 0,
  962. '--neo-hit-l': hitInsets.left ?? 0,
  963. }
  964. : undefined;
  965. const linkClassName = cn(
  966. 'u-neo-btn-link',
  967. anchorHit ? (customHit ? 'hit-area-sides' : hitAreaCls) : '',
  968. props.width === 'fill'
  969. ? cn('block', FILL_WIDTH_CLASS)
  970. : 'inline-flex items-center max-w-full min-w-0 shrink',
  971. rootOnAnchor,
  972. );
  973. const neoProps: NeoButtonProps = {
  974. ...props,
  975. classNames: rootOnAnchor ? { ...props.classNames, root: undefined } : props.classNames,
  976. buttonStyle: style,
  977. // Web: the <a> owns the hit area. Native: `Link asChild` makes the
  978. // NeoButton the pressable, so it keeps its own hitSlop.
  979. hitarea: webAnchor ? false : props.hitarea,
  980. interactive: true,
  981. // Native: announced as a link (web: the <a> is the link; the inner
  982. // button is not pressable, so it is neither a tab stop nor a button).
  983. // `role` keeps `Link asChild`'s injected `role="link"` out of the
  984. // SwiftUI role axis.
  985. ...(isWeb ? {} : { accessibilityRole: 'link', role: props.role ?? 'default' }),
  986. };
  987. const label = props.label ?? props.title;
  988. const linkAccessibleName =
  989. props.accessibilityLabel ||
  990. props.alt ||
  991. (typeof label === 'string' && label !== '' ? label : undefined) ||
  992. (typeof props.tooltip === 'string' && props.tooltip !== '' ? props.tooltip : undefined);
  993. // Runs before navigation. Web `<a>`: onClick (`preventDefault()` cancels
  994. // navigation). Emulated web links and native: Link's own onPress handler.
  995. const pressProps = onPress
  996. ? (isWeb && emulate !== true ? { onClick: onPress } : { onPress })
  997. : {};
  998. return (
  999. <Link
  1000. href={href}
  1001. target={target}
  1002. asExternal={asExternal}
  1003. emulate={emulate}
  1004. mode="plain"
  1005. className={linkClassName}
  1006. alt={linkAccessibleName}
  1007. {...(anchorStyle ? { style: anchorStyle } : null)}
  1008. {...pressProps}
  1009. >
  1010. <NeoButton {...neoProps} />
  1011. </Link>
  1012. );
  1013. };
  1014. /* ----------------------- providers + resolver re-exports ------------------ */
  1015. export {
  1016. NeoButtonStyleProvider,
  1017. NeoControlSizeProvider,
  1018. useNeoButtonStyle,
  1019. useNeoControlSize,
  1020. useResolvedNeoButton,
  1021. useNeoButtonExpoUI,
  1022. usePointerCapability,
  1023. resolveScoped,
  1024. };
  1025. export {
  1026. isExpoUI,
  1027. isNativeTabsEnabled,
  1028. isTabBarLabelsEnabled,
  1029. } from 'app/lib/util';
  1030. /* ----------------------------- internals ---------------------------------- */
  1031. export const __neoButtonInternals = {
  1032. renderLabel,
  1033. PressTransition,
  1034. NeoImage,
  1035. getAccessibleName,
  1036. };