'use client' import type { NativeStackHeaderItem, NativeStackHeaderItemButton, NativeStackHeaderItemMenu, NativeStackHeaderItemMenuAction, NativeStackHeaderItemMenuSubmenu, NativeStackNavigationOptions, } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' import { Children, createContext, Fragment, isValidElement, useContext, useId, useMemo, type ElementType, type ReactElement, type ReactNode, } from 'react' import { Platform, StyleSheet, type ImageSourcePropType } from 'react-native' import { MenuAction } from '../menu' import { ToolbarHost, ToolbarItem } from '../toolbar' import type { StackToolbarBadgeProps, StackToolbarButtonProps, StackToolbarIconProps, StackToolbarItemProps, StackToolbarLabelProps, StackToolbarMenuActionProps, StackToolbarMenuProps, StackToolbarPlacement, StackToolbarProps, StackToolbarSearchBarSlotProps, StackToolbarSpacerProps, StackToolbarViewProps, } from './StackToolbar.types' import { useIsomorphicLayoutEffect } from '@vxrn/use-isomorphic-layout-effect' function isChildOfType( element: ReactNode, type: ElementType ): element is ReactElement> { return isValidElement(element) && element.type === type } const StackToolbarPlacementContext = createContext(null) export function StackToolbarLabel(_props: StackToolbarLabelProps) { return null } export function StackToolbarIcon(_props: StackToolbarIconProps) { return null } export function StackToolbarBadge(_props: StackToolbarBadgeProps) { return null } type ToolbarContent = { label: string icon?: StackToolbarIconProps badge?: StackToolbarBadgeProps } function getToolbarContent( children: ReactNode, icon?: string | ImageSourcePropType, iconRenderingMode?: 'template' | 'original' ): ToolbarContent { let label = '' let iconProps: StackToolbarIconProps | undefined = typeof icon === 'string' ? { sf: icon } : icon ? { src: icon, renderingMode: iconRenderingMode } : undefined let badge: StackToolbarBadgeProps | undefined Children.forEach(children, (child) => { if (typeof child === 'string' || typeof child === 'number') { label += String(child) } else if (isChildOfType(child, StackToolbarLabel)) { label = child.props.children ?? '' } else if (isChildOfType(child, StackToolbarIcon)) { iconProps = child.props } else if (isChildOfType(child, StackToolbarBadge)) { badge = child.props } }) return { label, icon: iconProps, badge } } function getHeaderIcon( content: ToolbarContent, tintColor?: StackToolbarItemProps['tintColor'] ) { if (content.icon?.sf) { return { type: 'sfSymbol' as const, name: content.icon.sf } } if (content.icon?.src) { return { type: 'image' as const, source: content.icon.src, tinted: (content.icon.renderingMode ?? (tintColor ? 'template' : 'original')) === 'template', } } if (content.icon?.xcasset) { return { type: 'image' as const, source: { uri: content.icon.xcasset }, tinted: (content.icon.renderingMode ?? (tintColor ? 'template' : 'original')) === 'template', } } return undefined } function getHeaderItemSharedProps( props: StackToolbarButtonProps | StackToolbarMenuProps, content: ToolbarContent ) { const labelStyle = StyleSheet.flatten(props.style) const badgeStyle = StyleSheet.flatten(content.badge?.style) return { label: content.label, labelStyle, icon: getHeaderIcon(content, props.tintColor), variant: props.variant, tintColor: props.tintColor, disabled: props.disabled, hidesSharedBackground: props.hidesSharedBackground, sharesBackground: !props.separateBackground, accessibilityLabel: props.accessibilityLabel ?? content.label, accessibilityHint: props.accessibilityHint, badge: content.badge ? { value: content.badge.children ?? '', style: badgeStyle, } : undefined, } } function getMenuItems(children: ReactNode): NativeStackHeaderItemMenu['menu']['items'] { return Children.toArray(children) .map( ( child ): NativeStackHeaderItemMenuAction | NativeStackHeaderItemMenuSubmenu | null => { if (isChildOfType(child, StackToolbarMenuAction)) { const content = getToolbarContent( child.props.children, child.props.icon, child.props.iconRenderingMode ) return { type: 'action', label: content.label, description: child.props.subtitle, icon: getHeaderIcon(content, child.props.tintColor), onPress: child.props.onPress ?? (() => {}), state: child.props.isOn ? 'on' : 'off', disabled: child.props.disabled, destructive: child.props.destructive, hidden: child.props.hidden, keepsMenuPresented: child.props.unstable_keepPresented, discoverabilityLabel: child.props.discoverabilityLabel, } as NativeStackHeaderItemMenuAction } if (isChildOfType(child, StackToolbarMenu)) { const content = getToolbarContent( child.props.children, child.props.icon, child.props.iconRenderingMode ) return { type: 'submenu', label: content.label || child.props.title || '', icon: getHeaderIcon(content, child.props.tintColor), inline: child.props.inline, layout: child.props.palette ? 'palette' : 'default', destructive: child.props.destructive, multiselectable: child.props.singleSelection === false, items: getMenuItems(child.props.children), } as NativeStackHeaderItemMenuSubmenu } return null } ) .filter( ( item ): item is NativeStackHeaderItemMenuAction | NativeStackHeaderItemMenuSubmenu => Boolean(item) ) } function convertToolbarChildToHeaderItem(child: ReactNode): NativeStackHeaderItem | null { if (isChildOfType(child, StackToolbarButton)) { if (child.props.hidden) return null const content = getToolbarContent( child.props.children, child.props.icon, child.props.iconRenderingMode ) return { ...getHeaderItemSharedProps(child.props, content), type: 'button', onPress: child.props.onPress ?? (() => {}), selected: child.props.selected, } as NativeStackHeaderItemButton } if (isChildOfType(child, StackToolbarMenu)) { if (child.props.hidden) return null const content = getToolbarContent( child.props.children, child.props.icon, child.props.iconRenderingMode ) const contentWithTitle = { ...content, label: content.label || child.props.title || '', } return { ...getHeaderItemSharedProps(child.props, contentWithTitle), type: 'menu', menu: { title: child.props.title, multiselectable: child.props.singleSelection === false, layout: child.props.palette ? 'palette' : 'default', items: getMenuItems(child.props.children), }, } as NativeStackHeaderItemMenu } if (isChildOfType(child, StackToolbarSpacer)) { if (child.props.hidden) return null if (child.props.width === undefined) { if (process.env.NODE_ENV !== 'production') { console.warn('StackToolbar.Spacer requires a width in header placements.') } return null } return { type: 'spacing', spacing: child.props.width } } if (isChildOfType(child, StackToolbarView)) { if (child.props.hidden) return null return { type: 'custom', element: {child.props.children}, hidesSharedBackground: child.props.hidesSharedBackground, } } return null } export function appendStackToolbarPropsToOptions( options: NativeStackNavigationOptions, props: StackToolbarProps ): NativeStackNavigationOptions { if (Platform.OS !== 'ios') return options const placement = props.placement ?? 'bottom' if (placement === 'bottom') { if (process.env.NODE_ENV !== 'production') { console.warn( 'StackToolbar with bottom placement must be rendered in a page component.' ) } return options } if (placement !== 'left' && placement !== 'right') { throw new Error( `Invalid placement "${placement}" for StackToolbar. Expected "left", "right", or "bottom".` ) } if (props.asChild) { return placement === 'left' ? { ...options, headerShown: true, headerLeft: () => props.children } : { ...options, headerShown: true, headerRight: () => props.children } } const allChildren = Children.toArray(props.children) const items = allChildren .map(convertToolbarChildToHeaderItem) .filter((item): item is NativeStackHeaderItem => Boolean(item)) if (process.env.NODE_ENV !== 'production' && items.length !== allChildren.length) { const invalidChildren = allChildren.filter( (child) => !isChildOfType(child, StackToolbarButton) && !isChildOfType(child, StackToolbarMenu) && !isChildOfType(child, StackToolbarSpacer) && !isChildOfType(child, StackToolbarView) ) if (invalidChildren.length) { console.warn( `StackToolbar with placement="${placement}" only accepts StackToolbar.Button, Menu, Spacer, and View children.` ) } } const getItems = () => items return placement === 'left' ? { ...options, headerShown: true, unstable_headerLeftItems: getItems } : { ...options, headerShown: true, unstable_headerRightItems: getItems } } function StackToolbarComponent(props: StackToolbarProps) { const parentPlacement = useContext(StackToolbarPlacementContext) const navigation = useNavigation() const placement = props.placement ?? 'bottom' const options = useMemo( () => (placement === 'bottom' ? {} : appendStackToolbarPropsToOptions({}, props)), [placement, props.asChild, props.children] ) if (parentPlacement) { throw new Error('StackToolbar cannot be nested inside another StackToolbar.') } useIsomorphicLayoutEffect(() => { if (Platform.OS !== 'ios' || placement === 'bottom') return navigation.setOptions(options) }, [navigation, options, placement]) if (Platform.OS !== 'ios' || placement !== 'bottom') return null return ( {props.children} ) } export function StackToolbarButton(props: StackToolbarButtonProps) { const placement = useContext(StackToolbarPlacementContext) const identifier = useId() if (Platform.OS !== 'ios') return null if (!placement) throw new Error('StackToolbar.Button must be used inside StackToolbar.') const content = getToolbarContent(props.children, props.icon, props.iconRenderingMode) const badgeStyle = StyleSheet.flatten(content.badge?.style) return (