UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

973 lines (914 loc) • 32.5 kB
import type { ColorValue, ImageSourcePropType, StyleProp, TextStyle } from 'react-native' import type { NativeStackHeaderItem, NativeStackHeaderItemMenuAction, NativeStackHeaderItemMenuSubmenu, NativeStackNavigationOptions, } from '@react-navigation/native-stack' import { Children, isValidElement, type ReactElement, type ReactNode } from 'react' /** * Expo-exact toolbar placement. Bottom is the navigation-controller toolbar * owned by the screen; left/right are header items owned by * react-native-screens. Default is bottom, as in Expo. */ export type StackToolbarPlacement = 'left' | 'right' | 'bottom' export type StackToolbarVariant = 'plain' | 'done' | 'prominent' export interface StackToolbarProps { children?: ReactNode placement?: StackToolbarPlacement /** * Left/right only. Renders children as a custom header element instead of * header items, as in Expo. */ asChild?: boolean } export interface StackToolbarLabelProps { children?: string } export type StackToolbarIconProps = | { src: ImageSourcePropType renderingMode?: 'template' | 'original' } | { /** SF Symbol name. The only icon form the bottom bar takes. */ sf: string } | { /** Xcode asset catalog image name. */ xcasset: string renderingMode?: 'template' | 'original' } export interface StackToolbarBadgeProps { children?: string style?: StyleProp< Pick<TextStyle, 'fontFamily' | 'fontSize' | 'color' | 'fontWeight' | 'backgroundColor'> > } export interface StackToolbarButtonProps { /** * Plain text, or Icon/Label/Badge primitives. Anything else throws in dev, * as in Expo. */ children?: ReactNode /** SF Symbol name or image source. Bottom takes SF strings only. */ icon?: string | ImageSourcePropType /** Custom image for bottom placement. */ image?: ImageSourcePropType iconRenderingMode?: 'template' | 'original' variant?: StackToolbarVariant tintColor?: ColorValue /** Label text style. Maps to labelStyle/titleStyle. */ style?: StyleProp<TextStyle> disabled?: boolean hidden?: boolean selected?: boolean hidesSharedBackground?: boolean /** * Separate this item's background from its neighbors. Default false, as in * Expo. Maps to sharesBackground = !separateBackground. */ separateBackground?: boolean accessibilityLabel?: string accessibilityHint?: string onPress?: () => void } export interface StackToolbarMenuProps { /** Menu/MenuAction/Label/Icon/Badge. Anything else throws in dev. */ children?: ReactNode /** Title shown on top of the menu. Optional, as in Expo. */ title?: string icon?: string | ImageSourcePropType image?: ImageSourcePropType iconRenderingMode?: 'template' | 'original' variant?: StackToolbarVariant tintColor?: ColorValue style?: StyleProp<TextStyle> disabled?: boolean destructive?: boolean hidden?: boolean hidesSharedBackground?: boolean separateBackground?: boolean /** Submenus only. Maps to UIMenu.Options.displayInline. */ inline?: boolean /** Submenus only. Maps to UIMenu.Options.displayAsPalette. */ palette?: boolean /** Bottom only. Preferred size of the menu elements (iOS 16+). */ elementSize?: 'auto' | 'small' | 'medium' | 'large' accessibilityLabel?: string accessibilityHint?: string } export interface StackToolbarMenuActionProps { /** Icon, Label, or string title. */ children?: ReactNode icon?: string | ImageSourcePropType image?: ImageSourcePropType iconRenderingMode?: 'template' | 'original' disabled?: boolean destructive?: boolean hidden?: boolean /** Expo-exact name. Maps to keepsMenuPresented. */ unstable_keepPresented?: boolean isOn?: boolean onPress?: () => void discoverabilityLabel?: string /** Maps to header action description, bottom action subtitle. */ subtitle?: string accessibilityLabel?: string accessibilityHint?: string } export interface StackToolbarSpacerProps { hidden?: boolean /** * Fixed width. Required in left/right; without it the spacer is flexible * (bottom only). */ width?: number /** Bottom only. Whether the spacer joins the shared glass background. */ sharesBackground?: boolean } export interface StackToolbarSearchBarSlotProps { hidden?: boolean hidesSharedBackground?: boolean separateBackground?: boolean } /** Marker set on toolbar compound components to identify slots/leaf nodes. */ export const TOOLBAR_KIND = '__oneStackToolbarKind' as const export type StackToolbarKind = | 'toolbar' | 'button' | 'menu' | 'menuAction' | 'spacer' | 'searchBarSlot' | 'label' | 'icon' | 'badge' function kindOf(element: ReactNode): StackToolbarKind | undefined { if (!isValidElement(element)) return undefined return (element.type as { [TOOLBAR_KIND]?: StackToolbarKind })?.[TOOLBAR_KIND] } function isKind(element: ReactNode, kind: StackToolbarKind): boolean { return kindOf(element) === kind } function warn(message: string) { if (process.env.NODE_ENV !== 'production') { console.warn(message) } } /** Safe display name for any child type, including fragments and host tags. */ function childName(child: ReactNode): string { if (!isValidElement(child)) return 'unknown' const type = child.type as { name?: string } | string | symbol if (typeof type === 'string') return type if (typeof type === 'symbol') return 'Fragment' return type?.name ?? 'unknown' } function fallbackIdentifier(slot: string, index: number, title?: string): string { const slug = (title ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 24) || 'item' return `stack-toolbar-${slot}-${slug}-${index}` } // primitives shared by both paths, mirroring Expo's shared.js type IconResolution = | { form: 'sf'; name: string } | { form: 'src'; source: ImageSourcePropType; renderingMode?: 'template' | 'original' } | { form: 'xcasset'; name: string; renderingMode?: 'template' | 'original' } function firstChildOfKind(children: ReactNode, kind: StackToolbarKind): ReactElement | null { let found: ReactElement | null = null Children.forEach(children, (child) => { if (found || !isValidElement(child)) return if (isKind(child, kind)) found = child }) return found } /** Joined string/number children, as in Expo's convertChildrenToString. */ export function toolbarChildrenToString(children: ReactNode): string { return Children.toArray(children) .filter( (child): child is string | number => typeof child === 'string' || typeof child === 'number' ) .join('') } function resolveIcon( iconProp: string | ImageSourcePropType | undefined, children: ReactNode ): IconResolution | undefined { const iconChild = firstChildOfKind(children, 'icon') if (iconChild) { const props = iconChild.props as StackToolbarIconProps if ('sf' in props) return { form: 'sf', name: props.sf } if ('xcasset' in props) return { form: 'xcasset', name: props.xcasset, renderingMode: props.renderingMode } return { form: 'src', source: props.src, renderingMode: props.renderingMode } } if (typeof iconProp === 'string') return { form: 'sf', name: iconProp } if (iconProp) return { form: 'src', source: iconProp } return undefined } /** * Dev-time children check, as in Expo: Button takes a single string or * Label/Icon/Badge; Menu takes Menu/MenuAction/Label/Icon/Badge. One reads * descriptors without rendering them, so the converters check instead of the * components. */ function checkButtonChildren(children: ReactNode): void { if (process.env.NODE_ENV === 'production' || typeof children === 'string') return const all = Children.toArray(children) const valid = all.filter( (child) => typeof child === 'string' || typeof child === 'number' || (isValidElement(child) && (isKind(child, 'label') || isKind(child, 'icon') || isKind(child, 'badge'))) ) if (all.length !== valid.length) { throw new Error( 'Stack.Toolbar.Button only accepts a single string or Stack.Toolbar.Label, Stack.Toolbar.Icon, and Stack.Toolbar.Badge as its children.' ) } } function checkMenuChildren(children: ReactNode): void { if (process.env.NODE_ENV === 'production') return const all = Children.toArray(children) const valid = all.filter( (child) => isValidElement(child) && (isKind(child, 'menu') || isKind(child, 'menuAction') || isKind(child, 'label') || isKind(child, 'icon') || isKind(child, 'badge')) ) if (all.length !== valid.length) { throw new Error( 'Stack.Toolbar.Menu only accepts Stack.Toolbar.Menu, Stack.Toolbar.MenuAction, Stack.Toolbar.Label, Stack.Toolbar.Icon, and Stack.Toolbar.Badge as its children.' ) } } function labelFromChildren(children: ReactNode): string { const labelChild = firstChildOfKind(children, 'label') if (labelChild) return (labelChild.props as StackToolbarLabelProps).children ?? '' return toolbarChildrenToString(children) } interface BadgeData { value: string backgroundColor?: ColorValue color?: ColorValue fontFamily?: string fontSize?: number fontWeight?: TextStyle['fontWeight'] } function badgeFromChildren(children: ReactNode): BadgeData | undefined { const badgeChild = firstChildOfKind(children, 'badge') if (!badgeChild) return undefined const props = badgeChild.props as StackToolbarBadgeProps const style = props.style as BadgeData | undefined return { value: props.children ?? '', ...(style?.backgroundColor !== undefined && { backgroundColor: style.backgroundColor }), ...(style?.color !== undefined && { color: style.color }), ...(style?.fontFamily !== undefined && { fontFamily: style.fontFamily }), ...(style?.fontSize !== undefined && { fontSize: style.fontSize }), ...(style?.fontWeight !== undefined && { fontWeight: style.fontWeight }), } } function labelStyleFromStyle(style: StyleProp<TextStyle> | undefined) { if (!style) return undefined const flat = style as TextStyle const picked = { ...(flat.fontFamily !== undefined && { fontFamily: flat.fontFamily }), ...(flat.fontSize !== undefined && { fontSize: flat.fontSize }), ...(flat.fontWeight !== undefined && { fontWeight: flat.fontWeight }), ...(flat.color !== undefined && { color: flat.color }), } return Object.keys(picked).length ? picked : undefined } // header path (left/right): NativeStackHeaderItem, mirroring Expo's // convertStackToolbar*PropsToRNHeaderItem converters type HeaderIcon = NonNullable<NativeStackHeaderItemMenuAction['icon']> function headerIconFromResolution( resolution: IconResolution | undefined, iconRenderingMode: 'template' | 'original' | undefined, tintColor: ColorValue | undefined ): HeaderIcon | undefined { if (!resolution) return undefined if (resolution.form === 'sf') { return { type: 'sfSymbol', name: resolution.name } as HeaderIcon } const source = resolution.form === 'src' ? resolution.source : { uri: resolution.name } const effectiveMode = resolution.renderingMode ?? iconRenderingMode ?? (tintColor ? 'template' : 'original') return { type: 'image', source, tinted: effectiveMode === 'template', } as HeaderIcon } function buttonPropsToHeaderButton( props: StackToolbarButtonProps ): NativeStackHeaderItem | null { if (props.hidden) return null checkButtonChildren(props.children) const label = labelFromChildren(props.children) const icon = headerIconFromResolution( resolveIcon(props.icon, props.children), props.iconRenderingMode, props.tintColor ) const badge = badgeFromChildren(props.children) return { type: 'button', label, ...(icon && { icon }), ...(props.variant !== undefined && { variant: props.variant }), ...(props.tintColor !== undefined && { tintColor: props.tintColor }), ...(props.disabled !== undefined && { disabled: props.disabled }), sharesBackground: !props.separateBackground, ...(props.hidesSharedBackground !== undefined && { hidesSharedBackground: props.hidesSharedBackground, }), ...(badge && { badge: { value: badge.value, ...((badge.color !== undefined || badge.backgroundColor !== undefined || badge.fontFamily !== undefined || badge.fontSize !== undefined || badge.fontWeight !== undefined) && { style: { ...(badge.color !== undefined && { color: badge.color }), ...(badge.backgroundColor !== undefined && { backgroundColor: badge.backgroundColor, }), ...(badge.fontFamily !== undefined && { fontFamily: badge.fontFamily }), ...(badge.fontSize !== undefined && { fontSize: badge.fontSize }), ...(badge.fontWeight !== undefined && { fontWeight: badge.fontWeight }), }, }), }, }), ...(labelStyleFromStyle(props.style) && { labelStyle: labelStyleFromStyle(props.style), }), ...(props.accessibilityLabel !== undefined && { accessibilityLabel: props.accessibilityLabel, }), ...(props.accessibilityHint !== undefined && { accessibilityHint: props.accessibilityHint, }), onPress: props.onPress ?? (() => {}), selected: !!props.selected, } as NativeStackHeaderItem } /** * Menu label/title from children and title prop, as in Expo's * computeMenuLabelAndTitle: title alone feeds both; a Label child feeds the * bar label while title feeds the menu title. */ function menuLabelAndTitle( children: ReactNode, title: string | undefined ): { label: string; menuTitle: string } { const labelChild = firstChildOfKind(children, 'label') const labelFromChild = (labelChild?.props as StackToolbarLabelProps | undefined)?.children return { label: labelFromChild ?? title ?? '', menuTitle: title ?? '' } } function menuActionPropsToHeaderAction( props: StackToolbarMenuActionProps ): NativeStackHeaderItemMenuAction { // children and subtitle are mapped below; spreading them would send a // React element across the native bridge and drop every header item const { children, subtitle, isOn, unstable_keepPresented, icon, image, iconRenderingMode, ...rest } = props as StackToolbarMenuActionProps & { image?: ImageSourcePropType } void image const shared = labelFromChildren(children) const iconResolution = resolveIcon(icon, children) const item = { ...rest, description: subtitle, type: 'action', label: shared, state: isOn ? ('on' as const) : ('off' as const), onPress: props.onPress ?? (() => {}), } as NativeStackHeaderItemMenuAction & { keepsMenuPresented?: boolean } if (unstable_keepPresented !== undefined) { item.keepsMenuPresented = unstable_keepPresented } const headerIcon = headerIconFromResolution(iconResolution, iconRenderingMode, undefined) if (headerIcon) item.icon = headerIcon return item } function menuElementToHeaderSubmenu( element: ReactElement, placement: 'left' | 'right' ): NativeStackHeaderItemMenuSubmenu | null { const props = element.props as StackToolbarMenuProps if (props.hidden) return null checkMenuChildren(props.children) const items = menuChildrenToHeaderMenuItems(props.children, placement) return { type: 'submenu', label: labelFromChildren(props.children) || props.title || '', multiselectable: true, ...(props.inline !== undefined && { inline: props.inline }), ...(props.palette !== undefined && { layout: (props.palette ? 'palette' : 'default') as 'palette' | 'default', }), ...(props.destructive !== undefined && { destructive: props.destructive }), ...(headerIconFromResolution( resolveIcon(props.icon, props.children), props.iconRenderingMode, props.tintColor ) && { icon: headerIconFromResolution( resolveIcon(props.icon, props.children), props.iconRenderingMode, props.tintColor ), }), items, } } function menuChildrenToHeaderMenuItems( children: ReactNode, placement: 'left' | 'right' ): (NativeStackHeaderItemMenuAction | NativeStackHeaderItemMenuSubmenu)[] { const items: (NativeStackHeaderItemMenuAction | NativeStackHeaderItemMenuSubmenu)[] = [] Children.forEach(children, (child) => { if (!isValidElement(child)) return if (isKind(child, 'menuAction')) { items.push(menuActionPropsToHeaderAction(child.props as StackToolbarMenuActionProps)) } else if (isKind(child, 'menu')) { const submenu = menuElementToHeaderSubmenu(child, placement) if (submenu) items.push(submenu) } }) return items } function menuPropsToHeaderMenu( element: ReactElement, placement: 'left' | 'right' ): NativeStackHeaderItem | null { const props = element.props as StackToolbarMenuProps if (props.hidden) return null checkMenuChildren(props.children) const { label, menuTitle } = menuLabelAndTitle(props.children, props.title) const icon = headerIconFromResolution( resolveIcon(props.icon, props.children), props.iconRenderingMode, props.tintColor ) const badge = badgeFromChildren(props.children) return { type: 'menu', label, ...(icon && { icon }), ...(props.variant !== undefined && { variant: props.variant }), ...(props.tintColor !== undefined && { tintColor: props.tintColor }), ...(props.disabled !== undefined && { disabled: props.disabled }), sharesBackground: !props.separateBackground, ...(props.hidesSharedBackground !== undefined && { hidesSharedBackground: props.hidesSharedBackground, }), ...(badge && { badge: { value: badge.value } }), ...(props.accessibilityLabel !== undefined && { accessibilityLabel: props.accessibilityLabel, }), ...(props.accessibilityHint !== undefined && { accessibilityHint: props.accessibilityHint, }), menu: { ...(menuTitle ? { title: menuTitle } : null), multiselectable: true, ...(props.palette !== undefined && { layout: (props.palette ? 'palette' : 'default') as 'palette' | 'default', }), items: menuChildrenToHeaderMenuItems(props.children, placement), }, } as NativeStackHeaderItem } function spacerPropsToHeaderSpacing( props: StackToolbarSpacerProps, placement: 'left' | 'right' ): NativeStackHeaderItem | null { if (props.hidden) return null if (props.width === undefined) { warn( `Warning: Stack.Toolbar.Spacer requires \`width\` when used in ${placement} placement. Flexible spacers are only supported in bottom placement.` ) return null } return { type: 'spacing', spacing: props.width } as NativeStackHeaderItem } /** * Convert toolbar children to native header items. Warns on children that do * not belong in left/right (search slots, primitives), as in Expo. */ export function toolbarChildrenToHeaderItems( children: ReactNode, placement: 'left' | 'right' ): NativeStackHeaderItem[] { const items: NativeStackHeaderItem[] = [] Children.forEach(children, (child) => { if (!isValidElement(child)) return if (isKind(child, 'button')) { const item = buttonPropsToHeaderButton(child.props as StackToolbarButtonProps) if (item) items.push(item) } else if (isKind(child, 'menu')) { const menu = menuPropsToHeaderMenu(child, placement) if (menu) items.push(menu) } else if (isKind(child, 'spacer')) { const spacing = spacerPropsToHeaderSpacing( child.props as StackToolbarSpacerProps, placement ) if (spacing) items.push(spacing) } else { warn( `Warning: Stack.Toolbar with placement="${placement}" only accepts <Stack.Toolbar.Button>, <Stack.Toolbar.Menu>, and <Stack.Toolbar.Spacer> as children. Found invalid child: ${childName(child)}` ) } }) return items } // bottom path: plain descriptors rendered by the toolbar host export interface BottomToolbarButtonData { kind: 'button' identifier: string title?: string systemImageName?: string xcassetName?: string image?: ImageSourcePropType imageRenderingMode?: 'template' | 'original' tintColor?: ColorValue barButtonItemStyle?: 'plain' | 'done' | 'prominent' sharesBackground: boolean hidesSharedBackground?: boolean hidden?: boolean selected?: boolean disabled?: boolean badgeConfiguration?: BadgeData titleStyle?: { fontFamily?: string fontSize?: number fontWeight?: TextStyle['fontWeight'] color?: ColorValue } accessibilityLabel?: string accessibilityHint?: string handler: () => void } export interface BottomToolbarMenuActionData { kind: 'action' identifier: string title: string icon?: string xcassetName?: string image?: ImageSourcePropType imageRenderingMode?: 'template' | 'original' disabled?: boolean destructive?: boolean hidden?: boolean isOn?: boolean keepPresented?: boolean discoverabilityLabel?: string subtitle?: string accessibilityLabel?: string accessibilityHint?: string handler: () => void } export interface BottomToolbarSubmenuData { kind: 'submenu' identifier: string title: string icon?: string xcassetName?: string image?: ImageSourcePropType imageRenderingMode?: 'template' | 'original' destructive?: boolean hidden?: boolean inline?: boolean palette?: boolean accessibilityLabel?: string accessibilityHint?: string children: (BottomToolbarMenuActionData | BottomToolbarSubmenuData)[] } export interface BottomToolbarMenuData { kind: 'menu' identifier: string /** Title shown on top of the menu. */ title: string /** Bar-button label. */ label: string systemImageName?: string xcassetName?: string image?: ImageSourcePropType imageRenderingMode?: 'template' | 'original' tintColor?: ColorValue barButtonItemStyle?: 'plain' | 'done' | 'prominent' sharesBackground: boolean hidesSharedBackground?: boolean disabled?: boolean destructive?: boolean hidden?: boolean inline?: boolean palette?: boolean elementSize?: 'auto' | 'small' | 'medium' | 'large' accessibilityLabel?: string accessibilityHint?: string children: (BottomToolbarMenuActionData | BottomToolbarSubmenuData)[] } export interface BottomToolbarSpacerData { kind: 'spacer' identifier: string width?: number sharesBackground?: boolean hidden?: boolean } export interface BottomToolbarSearchBarSlotData { kind: 'searchBar' identifier: string sharesBackground: boolean hidesSharedBackground?: boolean hidden?: boolean } export type BottomToolbarData = | BottomToolbarButtonData | BottomToolbarMenuData | BottomToolbarMenuActionData | BottomToolbarSubmenuData | BottomToolbarSpacerData | BottomToolbarSearchBarSlotData interface BottomIconFields { sfName?: string xcassetName?: string image?: ImageSourcePropType imageRenderingMode?: 'template' | 'original' } function bottomIconFields( iconProp: string | ImageSourcePropType | undefined, imageProp: ImageSourcePropType | undefined, iconRenderingMode: 'template' | 'original' | undefined, tintColor: ColorValue | undefined, children: ReactNode, component: string ): BottomIconFields { const resolution = resolveIcon(iconProp, children) if (!resolution) { return { ...(imageProp !== undefined && { image: imageProp, imageRenderingMode: iconRenderingMode ?? (tintColor !== undefined ? 'template' : 'original'), }), } } if (resolution.form === 'sf') return { sfName: resolution.name } if (resolution.form === 'xcasset') { return { xcassetName: resolution.name, imageRenderingMode: resolution.renderingMode ?? iconRenderingMode, } } warn( `Warning: Stack.Toolbar.${component} in bottom placement does not support image icons via the \`icon\` prop or <Stack.Toolbar.Icon src={...} />; the image will not render. Use the \`icon\` prop with an SF Symbol name, the \`image\` prop, or <Stack.Toolbar.Icon xcasset="..." />.` ) return {} } function bottomButtonData( props: StackToolbarButtonProps, index: number ): BottomToolbarButtonData { checkButtonChildren(props.children) const title = labelFromChildren(props.children) || undefined const { sfName, ...iconRest } = bottomIconFields( props.icon, props.image, props.iconRenderingMode, props.tintColor, props.children, 'Button' ) return { kind: 'button', identifier: fallbackIdentifier('bottom', index, title), ...(title !== undefined && { title }), ...(sfName !== undefined && { systemImageName: sfName }), ...iconRest, tintColor: props.tintColor, ...(props.variant !== undefined && { barButtonItemStyle: props.variant }), sharesBackground: !props.separateBackground, hidesSharedBackground: props.hidesSharedBackground, hidden: props.hidden, selected: props.selected, disabled: props.disabled, // Badges render on bottom toolbarItems items (proven on the iOS 27 // simulator by the B2 probe), so One passes them through. Intentional // divergence from Expo, which calls bottom badges an iOS limitation. badgeConfiguration: badgeFromChildren(props.children), ...(labelStyleFromStyle(props.style) && { titleStyle: labelStyleFromStyle(props.style) }), accessibilityLabel: props.accessibilityLabel, accessibilityHint: props.accessibilityHint, handler: props.onPress ?? (() => {}), } } function bottomMenuActionData( props: StackToolbarMenuActionProps, index: number, path: string ): BottomToolbarMenuActionData { const title = labelFromChildren(props.children) const { sfName, ...iconRest } = bottomIconFields( props.icon, props.image, props.iconRenderingMode, undefined, props.children, 'MenuAction' ) return { kind: 'action', identifier: fallbackIdentifier(path, index, title), title, ...(sfName !== undefined && { icon: sfName }), ...iconRest, disabled: props.disabled, destructive: props.destructive, hidden: props.hidden, isOn: props.isOn, keepPresented: props.unstable_keepPresented, discoverabilityLabel: props.discoverabilityLabel, subtitle: props.subtitle, accessibilityLabel: props.accessibilityLabel, accessibilityHint: props.accessibilityHint, handler: props.onPress ?? (() => {}), } } function bottomMenuChildren( children: ReactNode, path: string ): (BottomToolbarMenuActionData | BottomToolbarSubmenuData)[] { const data: (BottomToolbarMenuActionData | BottomToolbarSubmenuData)[] = [] Children.forEach(children, (child, index) => { if (!isValidElement(child)) return if (isKind(child, 'menuAction')) { data.push( bottomMenuActionData(child.props as StackToolbarMenuActionProps, index, path) ) } else if (isKind(child, 'menu')) { data.push(bottomSubmenuData(child, index, path)) } }) return data } function bottomSubmenuData( element: ReactElement, index: number, path: string ): BottomToolbarSubmenuData { const props = element.props as StackToolbarMenuProps checkMenuChildren(props.children) const title = labelFromChildren(props.children) || props.title || '' const { sfName, ...iconRest } = bottomIconFields( props.icon, props.image, props.iconRenderingMode, props.tintColor, props.children, 'Menu' ) return { kind: 'submenu', identifier: fallbackIdentifier(path, index, title), title, ...(sfName !== undefined && { icon: sfName }), ...iconRest, destructive: props.destructive, hidden: props.hidden, inline: props.inline, palette: props.palette, accessibilityLabel: props.accessibilityLabel, accessibilityHint: props.accessibilityHint, children: bottomMenuChildren(props.children, `${path}-submenu-${index}`), } } function bottomMenuData(props: StackToolbarMenuProps, index: number): BottomToolbarMenuData { checkMenuChildren(props.children) const { label, menuTitle } = menuLabelAndTitle(props.children, props.title) const { sfName, ...iconRest } = bottomIconFields( props.icon, props.image, props.iconRenderingMode, props.tintColor, props.children, 'Menu' ) return { kind: 'menu', identifier: fallbackIdentifier('bottom', index, label || menuTitle), title: menuTitle, label, ...(sfName !== undefined && { systemImageName: sfName }), ...iconRest, tintColor: props.tintColor, ...(props.variant !== undefined && { barButtonItemStyle: props.variant }), sharesBackground: !props.separateBackground, hidesSharedBackground: props.hidesSharedBackground, disabled: props.disabled, destructive: props.destructive, hidden: props.hidden, inline: props.inline, palette: props.palette, elementSize: props.elementSize, accessibilityLabel: props.accessibilityLabel, accessibilityHint: props.accessibilityHint, children: bottomMenuChildren(props.children, `bottom-menu-${index}`), } } /** * Convert bottom toolbar children to plain descriptors rendered by the * toolbar host. Unlike header items, hidden is preserved because the native * items own a hidden slot. */ export function toolbarChildrenToBottomData(children: ReactNode): BottomToolbarData[] { const data: BottomToolbarData[] = [] Children.forEach(children, (child, index) => { if (!isValidElement(child)) return if (isKind(child, 'button')) { data.push(bottomButtonData(child.props as StackToolbarButtonProps, index)) } else if (isKind(child, 'menu')) { data.push(bottomMenuData(child.props as StackToolbarMenuProps, index)) } else if (isKind(child, 'spacer')) { const props = child.props as StackToolbarSpacerProps data.push({ kind: 'spacer', identifier: fallbackIdentifier('bottom', index, 'spacer'), ...(props.width !== undefined && { width: props.width }), // omitted unless explicit: the native spacer keeps the UIKit default. ...(props.sharesBackground !== undefined && { sharesBackground: props.sharesBackground, }), ...(props.hidden !== undefined && { hidden: props.hidden }), }) } else if (isKind(child, 'searchBarSlot')) { const props = child.props as StackToolbarSearchBarSlotProps data.push({ kind: 'searchBar', identifier: fallbackIdentifier('bottom', index, 'search'), sharesBackground: !props.separateBackground, hidesSharedBackground: props.hidesSharedBackground, hidden: props.hidden, }) } else { warn( `Warning: Stack.Toolbar with bottom placement only accepts <Stack.Toolbar.Button>, <Stack.Toolbar.Menu>, <Stack.Toolbar.Spacer>, and <Stack.Toolbar.SearchBarSlot> as children. Found invalid child: ${childName(child)}` ) } }) return data } /** * Map one Stack.Toolbar to native-stack screen options. Left maps to * unstable_headerLeftItems and right to unstable_headerRightItems, the * genuine iOS header-item path in react-navigation 8 alpha, and forces the * header visible, as in Expo. An explicitly declared toolbar always wins * over incoming options: an empty conversion clears inherited items. Bottom * has no options equivalent (the toolbar is owned by the screen view * controller, so a bottom toolbar must mount in screen content); a bottom * toolbar in layout config warns and is ignored. */ export function appendStackToolbarPropsToOptions( options: NativeStackNavigationOptions, props: StackToolbarProps ): NativeStackNavigationOptions { const placement = props.placement ?? 'bottom' if (placement === 'bottom') { if (process.env.NODE_ENV !== 'production') { warn( 'Warning: Stack.Toolbar with bottom placement in layout config has no effect. Mount it in screen content so the toolbar host sits inside the screen view controller.' ) } return options } if (props.asChild) { const key = placement === 'left' ? 'headerLeft' : 'headerRight' return { ...options, headerShown: true, [key]: () => props.children } } const items = toolbarChildrenToHeaderItems(props.children, placement) const key = placement === 'left' ? 'unstable_headerLeftItems' : 'unstable_headerRightItems' return { ...options, headerShown: true, [key]: () => items } } export { isKind as isToolbarKind } export type { NativeStackHeaderItem }