From b79b445dcdde637ee1c10ca0a86484e6b1f5c4ce Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:05:42 +0530 Subject: [PATCH] feat: add a floating variant to NavigationBar --- CHANGELOG.md | 27 +++ api-surface.json | 1 + docs/docs/components/navigation-bar.mdx | 65 ++++++- docs/static/llms-full.txt | 17 +- example/app/navigation-bar.tsx | 58 ++++++ packages/components/llms.txt | 17 +- .../src/__tests__/NavigationBar.test.tsx | 169 ++++++++++++++++++ .../src/__tests__/elevation.test.tsx | 13 ++ .../__tests__/rn-prop-passthrough.test.tsx | 12 ++ .../src/__tests__/web/elevation.web.test.tsx | 13 ++ packages/components/src/index.ts | 1 + .../src/navigation-bar/NavigationBar.tsx | 59 +++++- .../src/navigation-bar/NavigationItem.tsx | 3 +- .../components/src/navigation-bar/index.ts | 1 + .../components/src/navigation-bar/styles.ts | 114 ++++++++++-- .../components/src/navigation-bar/types.ts | 80 +++++++-- scripts/build-llms.ts | 37 +++- 17 files changed, 632 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec78142..f9b25b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ and read the whole release rather than scanning for a label. Prior history: these packages were published as `@onlynative/*` through `0.0.0-alpha.8`. The `@rootnative` line below starts over at `0.0.0-alpha.0`. +## Unreleased + +**No prop is removed.** One new variant in `@rootnative/components`. One +type change can stop a build: an `interface` that extends +`NavigationBarProps`. + +- **New: `NavigationBar` `variant="floating"`.** A capsule that floats over + the content: 64dp high, at most 480dp wide, with a 16dp gap to each side + and to the bottom of the window. Each destination shows its icon only, + and its label is the screen-reader name. The active indicator fills the + destination. The bar places itself at the bottom of its parent and reads + the safe-area insets from a `SafeAreaProvider`. It floats over a bottom + inset of 40dp or less, which holds only a gesture handle, and it stays + 16dp above a taller inset. MD3 has no floating navigation bar. The + capsule takes the MD3 colors, and the height, the edge gap and the item + gap of the MD3 floating toolbar. Its elevation, level 3, is not an MD3 + value for this surface. The default `variant` is `'docked'`, the bar + as before. `labelVisibility`, `labelStyle` and `insetBottom` apply to the + docked bar only, and the type rejects them on the floating bar. The new + type `NavigationBarVariant` names the two values. + +- **`NavigationBarProps` is now a type alias**, so that the type can reject + the docked props on the floating bar. An `interface` cannot extend a + union, so an `interface` that extends `NavigationBarProps` does not + compile. Use an intersection in its place: + `type MyBarProps = NavigationBarProps & { … }`. + ## 0.0.0-alpha.34 — 2026-11-03 **No breaking change.** One new component, `Carousel`, in diff --git a/api-surface.json b/api-surface.json index 26a51fe4..4a766d3c 100644 --- a/api-surface.json +++ b/api-surface.json @@ -233,6 +233,7 @@ "NavigationBarItem", "NavigationBarLabelVisibility", "NavigationBarProps", + "NavigationBarVariant", "NavigationDrawer", "NavigationDrawerDivider", "NavigationDrawerDividerProps", diff --git a/docs/docs/components/navigation-bar.mdx b/docs/docs/components/navigation-bar.mdx index b445f4fd..0a856e23 100644 --- a/docs/docs/components/navigation-bar.mdx +++ b/docs/docs/components/navigation-bar.mdx @@ -10,7 +10,9 @@ Follows the [Material Design 3 Navigation bar](https://m3.material.io/components specification. Each destination is an icon over a label, with the active one marked by a `secondaryContainer` -pill behind its icon. The bar is 80dp tall on a `surfaceContainer` background. +pill behind its icon. The bar is 80dp tall on a `surfaceContainer` background. The +[floating](#floating) variant is a 64dp capsule over the content, with an icon for each +destination. :::info NavigationBar is a bar, not a navigator `NavigationBar` renders the destinations and tells you which one was pressed. It does not own @@ -140,7 +142,49 @@ inside the same pill shape, on active and inactive destinations alike. A bottom-anchored bar must clear the home indicator. Pass `insetBottom` to wrap the row in a `SafeAreaView` that adds the bottom inset below the bar's 80dp — leave it off (the default) when -an ancestor already applies the bottom edge, or the inset would be added twice. +an ancestor already applies the bottom edge, or the inset would be added twice. The floating bar +reads the insets by itself: see [Floating](#floating). + +## Floating + +`variant="floating"` draws the bar as a capsule that floats over the content. MD3 has no +floating navigation bar. This form takes the MD3 colors, and the height, the gap to the window +edges and the item gap of the MD3 floating toolbar: 64dp, 16dp and 4dp. Its level 3 elevation is +not an MD3 value for this surface. + +- The capsule is 64dp high and at most 480dp wide, with a 16dp gap to each side and to the + bottom of the window. +- Each destination shows its icon only. The label is the screen-reader name of the destination. +- The active indicator fills the destination. + +The bar places itself at the bottom of its parent, over the content. Make it the last child of a +view that reaches the bottom edge of the window. Pad the end of the content, so that the last +item can scroll clear of the bar: 80dp (the 64dp capsule and the 16dp gap), plus the bottom +inset when the inset is more than 40dp. + +```tsx + + {/* … */} + + +``` + +The bar reads the safe-area insets from a `SafeAreaProvider`. Expo Router adds one. + +- A bottom inset of 40dp or less holds only a gesture handle: the iOS home indicator or the + Android gesture bar. The capsule floats over it, 16dp from the bottom edge. +- A taller bottom inset holds the Android navigation buttons. The capsule stays 16dp above it. +- The side insets are added to the 16dp gap. +- Without a provider, the capsule keeps the 16dp gap. + +`labelVisibility`, `labelStyle` and `insetBottom` apply to the docked bar only. The type rejects +them with `variant="floating"`. ## Overrides @@ -151,7 +195,7 @@ an ancestor already applies the bottom edge, or the inset would be added twice. | `selectedContentColor` | Icon and label color of the active destination; the state layers re-derive from it | | `indicatorColor` | The active indicator pill | | `labelStyle` | Every destination's label `Text` — does not affect the icons | -| `style` | The bar container | +| `style` | The bar container; on the floating bar, the capsule | Disabled destinations always use the MD3 disabled treatment (38% content); no override changes that. @@ -175,6 +219,18 @@ These are the Expressive-updated androidx tokens: the indicator is 56dp wide (no pre-Expressive 64dp) and the active label is `secondary` (not `onSurface`). The 64dp-tall Expressive short bar is a possible 1.x variant; this bar uses compose's `TallContainerHeight`. +The floating bar changes these: + +| Token | Value | +|-------|-------| +| Container | `surfaceContainer`, elevation level 3, full corners | +| Height | 64dp | +| Width | The parent less a 16dp gap on each side, at most 480dp | +| Bottom gap | 16dp from the bottom edge, or 16dp above a bottom inset of more than 40dp | +| Padding | 6dp around the destinations, 4dp between them | +| Active indicator | Fills the destination, `secondaryContainer`, full corners | +| Label | None; the label is the screen-reader name | + ## Accessibility - The bar reports `role="tablist"` and each destination `role="tab"` with @@ -184,7 +240,8 @@ Expressive short bar is a possible 1.x variant; this bar uses compose's `TallCon hides the visible label; set `accessibilityLabel` on the item to override it. - Disabled destinations expose `accessibilityState.disabled` and stop responding. - On web, destinations show a keyboard focus ring (`secondary`, 3dp) around the indicator pill, - driven by the same focus-visible state as the rest of the library. + driven by the same focus-visible state as the rest of the library. On the floating bar, the + ring goes around the destination. ## Props diff --git a/docs/static/llms-full.txt b/docs/static/llms-full.txt index 0d356ef6..daa8232d 100644 --- a/docs/static/llms-full.txt +++ b/docs/static/llms-full.txt @@ -2463,6 +2463,14 @@ import { NavigationBar } from '@rootnative/components/navigation-bar' // (active icon+label), indicatorColor (pill), labelStyle. Disabled items stay // at the MD3 38%. + +// Floating: a 64dp capsule over the content, icons only, 16dp from the window +// edges. It places itself at the bottom of its parent and reads the safe-area +// insets: a bottom inset of 40 or less (a gesture handle) goes under it. + + {/* content: pad its end by 80 */} + + ``` Props: @@ -2470,16 +2478,17 @@ Props: - `value?: string` — Value of the active destination (controlled). - `defaultValue?: string` — Value of the initially active destination (uncontrolled). Defaults to the first item — a navigation bar with nothing active reads as broken. - `onValueChange?: (value: string) => void` — Called with the value of the destination that was pressed. -- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown. -- `insetBottom?: boolean` — Default: `false`. When `true`, wraps the row in a SafeAreaView that adds the bottom inset below the bar's 80dp, so it clears the home indicator. Leave it off when an ancestor (e.g. `Layout` with a `bottom` edge) already handles it. - `containerColor?: string` — Default: `surfaceContainer`. Override the bar background. - `contentColor?: string` — Default: `onSurfaceVariant`. Override the icon and label color of inactive destinations. - `selectedContentColor?: string` — Default: `onSecondaryContainer (icon) / secondary (label)`. Override the icon and label color of the active destination. State-layer colors are derived from it automatically. - `indicatorColor?: string` — Default: `secondaryContainer`. Override the active indicator pill color. -- `labelStyle?: StyleProp` — Style applied to every destination's label `Text` — does not affect the icons. -- `style?: StyleProp` — Style applied to the bar container. +- `style?: StyleProp` — Style applied to the bar container. On the floating bar, that is the capsule. - `accessibilityLabel?: string` — Screen-reader label for the bar itself. - `testID?: string` — Test id applied to the bar container. Each destination carries `-item-` and its indicator `-item--indicator`. +- `variant?: 'docked' | 'floating'` — Default: `'docked'`. `'docked'` is the MD3 navigation bar, an 80dp band along the bottom edge. `'floating'` is a 64dp capsule over the content, at most 480 wide, 16dp from the window edges, with an icon for each destination and no visible label; the indicator fills the destination. It places itself at the bottom of its parent and reads the insets from a `SafeAreaProvider`: it floats over a bottom inset of 40 or less and stays 16dp above a taller one. +- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown. Docked only. +- `insetBottom?: boolean` — Default: `false`. Adds the bottom safe-area inset below the bar's 80dp. Leave it off when an ancestor already handles the edge. Docked only. +- `labelStyle?: StyleProp` — Style for every label `Text`. Docked only. - Inherits `ViewProps` (except `children`) #### NavigationBarItem diff --git a/example/app/navigation-bar.tsx b/example/app/navigation-bar.tsx index e036f96e..bb541a6f 100644 --- a/example/app/navigation-bar.tsx +++ b/example/app/navigation-bar.tsx @@ -9,6 +9,7 @@ import { import { useTheme } from '@rootnative/core' import { useMemo, useState } from 'react' import { ScrollView, StyleSheet } from 'react-native' +import { SafeAreaProvider } from 'react-native-safe-area-context' import { catalogMetadata } from '../src/metadata' import { ScreenIntro } from '../src/ScreenIntro' import { ScreenNavFooter } from '../src/ScreenNavFooter' @@ -36,6 +37,35 @@ const PANELS: Record = { profile: 'Your account and settings.', } +// A nested `SafeAreaProvider` measures the insets of its own frame, so the +// capsule keeps its gap to the frame and not to the window. +function FloatingDemo() { + const [destination, setDestination] = useState('home') + + return ( + + + {PANELS[destination]} + {Array.from({ length: 12 }, (_, index) => ( + + Scroll content row {index + 1} + + ))} + + + + ) +} + function NavigationBarScreenContent() { const theme = useTheme() const [destination, setDestination] = useState('home') @@ -108,6 +138,22 @@ function NavigationBarScreenContent() { + + Floating + + + + + variant="floating" is a capsule over the content, + 16dp from the edges, with an icon for each destination. The + label is the screen-reader name. + + + + + + + Disabled and overrides @@ -151,4 +197,16 @@ const styles = StyleSheet.create({ scroll: { flexGrow: 1, }, + floatingFrame: { + flex: 0, + height: 320, + overflow: 'hidden', + }, + // 16 of padding plus the 64dp capsule and its 16dp gap, so the last row + // scrolls clear of it. + floatingScrollContent: { + padding: 16, + paddingBottom: 112, + rowGap: 16, + }, }) diff --git a/packages/components/llms.txt b/packages/components/llms.txt index 615bf249..053ccd0d 100644 --- a/packages/components/llms.txt +++ b/packages/components/llms.txt @@ -1803,6 +1803,14 @@ import { NavigationBar } from '@rootnative/components/navigation-bar' // (active icon+label), indicatorColor (pill), labelStyle. Disabled items stay // at the MD3 38%. + +// Floating: a 64dp capsule over the content, icons only, 16dp from the window +// edges. It places itself at the bottom of its parent and reads the safe-area +// insets: a bottom inset of 40 or less (a gesture handle) goes under it. + + {/* content: pad its end by 80 */} + + ``` Props: @@ -1810,16 +1818,17 @@ Props: - `value?: string` — Value of the active destination (controlled). - `defaultValue?: string` — Value of the initially active destination (uncontrolled). Defaults to the first item — a navigation bar with nothing active reads as broken. - `onValueChange?: (value: string) => void` — Called with the value of the destination that was pressed. -- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown. -- `insetBottom?: boolean` — Default: `false`. When `true`, wraps the row in a SafeAreaView that adds the bottom inset below the bar's 80dp, so it clears the home indicator. Leave it off when an ancestor (e.g. `Layout` with a `bottom` edge) already handles it. - `containerColor?: string` — Default: `surfaceContainer`. Override the bar background. - `contentColor?: string` — Default: `onSurfaceVariant`. Override the icon and label color of inactive destinations. - `selectedContentColor?: string` — Default: `onSecondaryContainer (icon) / secondary (label)`. Override the icon and label color of the active destination. State-layer colors are derived from it automatically. - `indicatorColor?: string` — Default: `secondaryContainer`. Override the active indicator pill color. -- `labelStyle?: StyleProp` — Style applied to every destination's label `Text` — does not affect the icons. -- `style?: StyleProp` — Style applied to the bar container. +- `style?: StyleProp` — Style applied to the bar container. On the floating bar, that is the capsule. - `accessibilityLabel?: string` — Screen-reader label for the bar itself. - `testID?: string` — Test id applied to the bar container. Each destination carries `-item-` and its indicator `-item--indicator`. +- `variant?: 'docked' | 'floating'` — Default: `'docked'`. `'docked'` is the MD3 navigation bar, an 80dp band along the bottom edge. `'floating'` is a 64dp capsule over the content, at most 480 wide, 16dp from the window edges, with an icon for each destination and no visible label; the indicator fills the destination. It places itself at the bottom of its parent and reads the insets from a `SafeAreaProvider`: it floats over a bottom inset of 40 or less and stays 16dp above a taller one. +- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown. Docked only. +- `insetBottom?: boolean` — Default: `false`. Adds the bottom safe-area inset below the bar's 80dp. Leave it off when an ancestor already handles the edge. Docked only. +- `labelStyle?: StyleProp` — Style for every label `Text`. Docked only. - Inherits `ViewProps` (except `children`) #### NavigationBarItem diff --git a/packages/components/src/__tests__/NavigationBar.test.tsx b/packages/components/src/__tests__/NavigationBar.test.tsx index 64e46a47..daca590d 100644 --- a/packages/components/src/__tests__/NavigationBar.test.tsx +++ b/packages/components/src/__tests__/NavigationBar.test.tsx @@ -2,6 +2,7 @@ import { lightTheme } from '@rootnative/core' import { renderSettled, renderWithTheme } from '@rootnative/utils/test' import { fireEvent, screen } from '@testing-library/react-native' import { StyleSheet } from 'react-native' +import { SafeAreaProvider } from 'react-native-safe-area-context' import { NavigationBar } from '../navigation-bar' import type { NavigationBarItem } from '../navigation-bar' @@ -224,3 +225,171 @@ describe('NavigationBar — overrides', () => { ).toBe('700') }) }) + +type Style = Record + +/** Every node's flattened style, in tree order. */ +function styles(node: unknown): Style[] { + if (!node || typeof node !== 'object') return [] + if (Array.isArray(node)) return node.flatMap(styles) + const { props, children } = node as { + props?: { style?: unknown } + children?: unknown[] + } + const own = props?.style ? [StyleSheet.flatten(props.style) as Style] : [] + return [...own, ...(children ?? []).flatMap(styles)] +} + +/** The view that places the floating capsule in its parent. */ +function track(): Style { + const found = styles(screen.toJSON()).find( + (style) => style.position === 'absolute' && style.left === 0, + ) + if (!found) throw new Error('no track') + return found +} + +function insetsOf(bottom: number, left = 0, right = 0) { + return { + frame: { x: 0, y: 0, width: 390, height: 844 }, + insets: { top: 0, bottom, left, right }, + } +} + +describe('NavigationBar — floating', () => { + it('shows an icon for each destination and names it with the label', () => { + renderBar({ variant: 'floating' }) + + expect(screen.queryByText('Home')).toBeNull() + expect(screen.queryByText('Search')).toBeNull() + expect( + screen.getByRole('tab', { name: 'Home' }).props.accessibilityState, + ).toMatchObject({ selected: true }) + expect(screen.getByRole('tab', { name: 'Library' })).toBeTruthy() + }) + + it('is a navigation landmark around a named tablist', () => { + renderBar({ variant: 'floating', accessibilityLabel: 'Main navigation' }) + expect(screen.getByTestId('nav').props.role).toBe('navigation') + const tablist = screen.getByTestId('nav-tablist') + expect(tablist.props.role).toBe('tablist') + expect(tablist.props.accessibilityLabel).toBe('Main navigation') + }) + + it('moves the active destination on press', () => { + const onValueChange = jest.fn() + renderBar({ variant: 'floating', onValueChange }) + + fireEvent.press(screen.getByRole('tab', { name: 'Search' })) + expect(onValueChange).toHaveBeenCalledWith('search') + expect( + screen.getByRole('tab', { name: 'Search' }).props.accessibilityState, + ).toMatchObject({ selected: true }) + }) + + it('is a 64dp capsule, at most 480 wide, on surfaceContainer', () => { + renderBar({ variant: 'floating' }) + + const capsule = StyleSheet.flatten(screen.getByTestId('nav').props.style) + expect(capsule).toMatchObject({ + width: '100%', + maxWidth: 480, + borderRadius: lightTheme.shape.cornerFull, + backgroundColor: lightTheme.colors.surfaceContainer, + }) + expect( + StyleSheet.flatten(screen.getByTestId('nav-tablist').props.style), + ).toMatchObject({ height: 64, padding: 6, gap: 4 }) + }) + + it('fills each destination with its indicator', () => { + renderBar({ variant: 'floating' }) + + // The docked bar lays the indicator out in a 56×32 pill. The floating + // pill grows to fill the destination instead. + const all = styles(screen.toJSON()) + expect(all.some((style) => style.width === 56)).toBe(false) + const item = StyleSheet.flatten( + screen.getByRole('tab', { name: 'Home' }).props.style, + ) + expect(item).toMatchObject({ flexGrow: 1, flexBasis: 0 }) + expect(item.paddingHorizontal).toBeUndefined() + expect( + StyleSheet.flatten( + screen.getByTestId('nav-item-home-indicator').props.style, + ).opacity, + ).toBe(1) + }) + + it('keeps the color overrides', () => { + renderBar({ + variant: 'floating', + containerColor: '#101010', + indicatorColor: '#404040', + }) + + expect( + StyleSheet.flatten(screen.getByTestId('nav').props.style).backgroundColor, + ).toBe('#101010') + expect( + StyleSheet.flatten( + screen.getByTestId('nav-item-home-indicator').props.style, + ).backgroundColor, + ).toBe('#404040') + }) + + it('sits 16 from the edges outside a SafeAreaProvider', () => { + renderBar({ variant: 'floating' }) + expect(track()).toMatchObject({ + bottom: 16, + paddingLeft: 16, + paddingRight: 16, + pointerEvents: 'box-none', + }) + }) + + it('floats over a bottom inset that holds only a gesture handle', () => { + renderWithTheme( + + + , + ) + expect(track().bottom).toBe(16) + }) + + it('stays above a taller bottom inset', () => { + renderWithTheme( + + + , + ) + expect(track().bottom).toBe(64) + }) + + it('keeps the gap inside the side insets', () => { + renderWithTheme( + + + , + ) + expect(track()).toMatchObject({ paddingLeft: 75, paddingRight: 63 }) + }) + + // The floating bar shows no label and reads the bottom inset by itself. + // The directive is the assertion: it fails the build if the union ever + // stops rejecting the docked props. The type error is on the attribute, + // because `never` rejects its value, so the directive sits above it. + it('rejects the docked props at the type level', () => { + renderWithTheme( + , + ) + // A consumer on plain JS gets no type error, so the label stays hidden. + expect(screen.queryByText('Home')).toBeNull() + }) +}) diff --git a/packages/components/src/__tests__/elevation.test.tsx b/packages/components/src/__tests__/elevation.test.tsx index 3005107c..c7156515 100644 --- a/packages/components/src/__tests__/elevation.test.tsx +++ b/packages/components/src/__tests__/elevation.test.tsx @@ -286,6 +286,19 @@ const STATIC_CASES = [ ), rest: lightTheme.elevation.level2, }, + { + name: 'NavigationBar — floating', + ui: ( + + ), + rest: lightTheme.elevation.level3, + }, { // The FAB inside is flat, so the bar is the one shadow node. name: 'BottomAppBar', diff --git a/packages/components/src/__tests__/rn-prop-passthrough.test.tsx b/packages/components/src/__tests__/rn-prop-passthrough.test.tsx index 16ab5c86..f9026a4f 100644 --- a/packages/components/src/__tests__/rn-prop-passthrough.test.tsx +++ b/packages/components/src/__tests__/rn-prop-passthrough.test.tsx @@ -75,6 +75,18 @@ describe('RN prop passthrough reaches the root node', () => { expect(screen.getByTestId('nav').props.nativeID).toBe('probe') }) + it('NavigationBar — floating', () => { + renderWithTheme( + , + ) + expect(screen.getByTestId('nav').props.nativeID).toBe('probe') + }) + it('NavigationRail', () => { renderWithTheme( , diff --git a/packages/components/src/__tests__/web/elevation.web.test.tsx b/packages/components/src/__tests__/web/elevation.web.test.tsx index 2c0ae011..5ce6a532 100644 --- a/packages/components/src/__tests__/web/elevation.web.test.tsx +++ b/packages/components/src/__tests__/web/elevation.web.test.tsx @@ -229,6 +229,19 @@ it.each([ ), level: lightTheme.elevation.level2, }, + { + name: 'NavigationBar — floating', + ui: ( + + ), + level: lightTheme.elevation.level3, + }, { // The FAB inside is flat, so the bar is the one box-shadow. name: 'BottomAppBar', diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index fba02097..79344b69 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -158,6 +158,7 @@ export type { NavigationBarItem, NavigationBarLabelVisibility, NavigationBarProps, + NavigationBarVariant, } from './navigation-bar' export { NavigationRail } from './navigation-rail' diff --git a/packages/components/src/navigation-bar/NavigationBar.tsx b/packages/components/src/navigation-bar/NavigationBar.tsx index 41c2dafa..0a9b4623 100644 --- a/packages/components/src/navigation-bar/NavigationBar.tsx +++ b/packages/components/src/navigation-bar/NavigationBar.tsx @@ -1,10 +1,18 @@ import { useTheme } from '@rootnative/core' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useContext, useMemo, useState } from 'react' import { View } from 'react-native' +import { SafeAreaInsetsContext } from 'react-native-safe-area-context' +import { pointerEvents } from '../internal/pointerEvents' import { SafeAreaView } from '../safe-area' import type { Edge } from '../safe-area' import { NavigationItem } from './NavigationItem' -import { createNavigationBarStyles, getNavigationBarColors } from './styles' +import { + FLOATING_BAR_GAP, + createFloatingNavigationBarStyles, + createNavigationBarStyles, + floatingBarBottom, + getNavigationBarColors, +} from './styles' import type { NavigationBarProps } from './types' const BOTTOM_EDGE: Edge[] = ['bottom'] @@ -14,6 +22,7 @@ export function NavigationBar({ value, defaultValue, onValueChange, + variant = 'docked', labelVisibility = 'always', insetBottom = false, containerColor, @@ -27,6 +36,7 @@ export function NavigationBar({ ...rest }: NavigationBarProps) { const theme = useTheme() + const floating = variant === 'floating' const colors = useMemo( () => getNavigationBarColors( @@ -41,6 +51,29 @@ export function NavigationBar({ () => createNavigationBarStyles(theme, containerColor), [theme, containerColor], ) + const floatingStyles = useMemo( + () => createFloatingNavigationBarStyles(theme, containerColor), + [theme, containerColor], + ) + + // Outside a `SafeAreaProvider` the floating bar has no insets to read, and + // it keeps the plain gap to the edges. + const insets = useContext(SafeAreaInsetsContext) + const insetBottomValue = insets?.bottom ?? 0 + const insetLeft = insets?.left ?? 0 + const insetRight = insets?.right ?? 0 + const trackStyle = useMemo( + () => [ + floatingStyles.track, + { + bottom: floatingBarBottom(insetBottomValue), + paddingLeft: FLOATING_BAR_GAP + insetLeft, + paddingRight: FLOATING_BAR_GAP + insetRight, + }, + pointerEvents.boxNone, + ], + [floatingStyles.track, insetBottomValue, insetLeft, insetRight], + ) const isControlled = value !== undefined const [selfValue, setSelfValue] = useState( @@ -61,7 +94,7 @@ export function NavigationBar({ // read on web. const row = ( ) + if (floating) { + return ( + + + {row} + + + ) + } + return ( { +/** + * The form of the bar. `'docked'` is the MD3 navigation bar: an 80dp band + * along the bottom edge, with a label under each icon. `'floating'` is a + * capsule that floats over the content, clear of the window edges, with an + * icon for each destination and no visible label. + */ +export type NavigationBarVariant = 'docked' | 'floating' + +interface NavigationBarCommonProps extends Omit { /** The destinations to render. MD3 recommends 3–5. */ items: NavigationBarItem[] /** Value of the active destination (controlled). */ @@ -53,18 +61,6 @@ export interface NavigationBarProps extends Omit { defaultValue?: string /** Called with the value of the destination that was pressed. */ onValueChange?: (value: string) => void - /** - * When destination labels are shown. - * @default 'always' - */ - labelVisibility?: NavigationBarLabelVisibility - /** - * When `true`, wraps the row in a SafeAreaView that adds the bottom inset - * below the bar's 80dp, so it clears the home indicator. Leave it off when - * an ancestor (e.g. `Layout` with a `bottom` edge) already handles it. - * @default false - */ - insetBottom?: boolean /** * Override the bar background. * @default surfaceContainer @@ -87,11 +83,9 @@ export interface NavigationBarProps extends Omit { */ indicatorColor?: string /** - * Style applied to every destination's label `Text` — does not affect the - * icons. + * Style applied to the bar container. On the floating bar, that is the + * capsule. */ - labelStyle?: StyleProp - /** Style applied to the bar container. */ style?: StyleProp /** Screen-reader label for the bar itself. */ accessibilityLabel?: string @@ -101,3 +95,55 @@ export interface NavigationBarProps extends Omit { */ testID?: string } + +/** + * The props that only one variant takes. The floating bar shows no label, + * and it reads the bottom inset by itself, so the type rejects + * `labelVisibility`, `labelStyle` and `insetBottom` on it. + */ +type NavigationBarVariantProps = + | { + /** + * The form of the bar. + * @default 'docked' + */ + variant?: 'docked' + /** + * When destination labels are shown. + * @default 'always' + */ + labelVisibility?: NavigationBarLabelVisibility + /** + * When `true`, wraps the row in a SafeAreaView that adds the bottom + * inset below the bar's 80dp, so it clears the home indicator. Leave + * it off when an ancestor (e.g. `Layout` with a `bottom` edge) already + * handles it. + * @default false + */ + insetBottom?: boolean + /** + * Style applied to every destination's label `Text` — does not affect + * the icons. + */ + labelStyle?: StyleProp + } + | { + /** + * A capsule that floats over the content. It places itself at the + * bottom of its parent, so make it the last child of a view that + * reaches the bottom edge of the window. It reads the safe-area + * insets from a `SafeAreaProvider`. + */ + variant: 'floating' + labelVisibility?: never + insetBottom?: never + labelStyle?: never + } + +/** + * Props of the NavigationBar. `labelVisibility`, `insetBottom` and + * `labelStyle` apply to the docked bar only — see + * `NavigationBarVariantProps`. + */ +export type NavigationBarProps = NavigationBarCommonProps & + NavigationBarVariantProps diff --git a/scripts/build-llms.ts b/scripts/build-llms.ts index 75690d06..fa417cfa 100644 --- a/scripts/build-llms.ts +++ b/scripts/build-llms.ts @@ -1291,6 +1291,14 @@ import { NavigationBar } from '@rootnative/components/navigation-bar' // (active icon+label), indicatorColor (pill), labelStyle. Disabled items stay // at the MD3 38%. + +// Floating: a 64dp capsule over the content, icons only, 16dp from the window +// edges. It places itself at the bottom of its parent and reads the safe-area +// insets: a bottom inset of 40 or less (a gesture handle) goes under it. + + {/* content: pad its end by 80 */} + + \`\`\``, 'navigation-rail': `\`\`\`tsx @@ -2003,9 +2011,34 @@ import { Grid } from '@rootnative/components/layout' if (dirName === 'navigation-bar') { let output = `### ${displayName}\n\n${example}\n\n` - const propsIface = interfaces.find((i) => i.name === 'NavigationBarProps') + // `NavigationBarProps` is a type alias: the shared props come from + // `NavigationBarCommonProps`, and the props of one variant are described + // here, because a union arm's props are invisible to the parser. + const propsIface = interfaces.find( + (i) => i.name === 'NavigationBarCommonProps', + ) if (propsIface) { - output += formatPropsSection(propsIface, typeAliases) + const section = formatPropsSection(propsIface, typeAliases) + const inheritsLine = '- Inherits `ViewProps` (except `children`)\n' + const variantProps = + "- `variant?: 'docked' | 'floating'` — Default: `'docked'`. " + + "`'docked'` is the MD3 navigation bar, an 80dp band along the bottom " + + "edge. `'floating'` is a 64dp capsule over the content, at most 480 " + + 'wide, 16dp from the window edges, with an icon for each destination ' + + 'and no visible label; the indicator fills the destination. It ' + + 'places itself at the bottom of its parent and reads the insets from ' + + 'a `SafeAreaProvider`: it floats over a bottom inset of 40 or less ' + + 'and stays 16dp above a taller one.\n' + + "- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: " + + "`'always'`. When destination labels are shown. Docked only.\n" + + '- `insetBottom?: boolean` — Default: `false`. Adds the bottom ' + + "safe-area inset below the bar's 80dp. Leave it off when an ancestor " + + 'already handles the edge. Docked only.\n' + + '- `labelStyle?: StyleProp` — Style for every label `Text`. ' + + 'Docked only.\n' + output += section.includes(inheritsLine) + ? section.replace(inheritsLine, variantProps + inheritsLine) + : section + variantProps output += '\n' }