diff --git a/docs/6.x/docs/components/Toolbar/Toolbar.mdx b/docs/6.x/docs/components/Toolbar/Toolbar.mdx new file mode 100644 index 0000000000..9f58039b48 --- /dev/null +++ b/docs/6.x/docs/components/Toolbar/Toolbar.mdx @@ -0,0 +1,163 @@ +--- +title: Toolbar +--- + +import PropTable from '@docs/components/PropTable.tsx'; +import ExtendsLink from '@docs/components/ExtendsLink.tsx'; +import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx'; +import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx'; +import ExtendedExample from '@docs/components/ExtendedExample.tsx'; + +A toolbar groups icon actions behind a shared surface. + +It comes in two `variant`s: `floating`, a self-positioned pill anchored wherever you place it +(similar to a `FAB`), and `docked`, a full-width bar pinned to the bottom edge that extends +into the safe-area insets automatically. A floating toolbar can also be laid out vertically +via `orientation`. + +The `colorScheme` prop controls how contained `IconButton`/`Button` children are colored. By +default: `standard` keeps them neutral against a surface-colored container, while `vibrant` +gives the toolbar itself a bold, primary-tinted container and switches selected/unselected +children to matching vibrant colors, making the toolbar stand out as a focal point on the +screen. + + + + + + + +## Usage +```js +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Toolbar, IconButton } from 'react-native-paper'; + +const MyComponent = () => ( + + + {}} /> + {}} /> + {}} /> + + +); + +const styles = StyleSheet.create({ + anchor: { + position: 'absolute', + left: 0, + right: 0, + bottom: 24, + alignItems: 'center', + }, +}); + +export default MyComponent; +``` + + + ## Props + + + + +
+ +### children (required) + +
+ + + +
+ +### variant + +
+ + + +
+ +### orientation + +
+ + + +
+ +### colorScheme + +
+ + + +
+ +### containerColor + +
+ + + +
+ +### style + +
+ + + +
+ +### contentContainerStyle + +
+ + + +
+ +### testID + +
+ + + +
+ +### aria-label + +
+ + + +
+ +### theme + +
+ + + +
+ +### ref + +
+ + + + + + + + + ## Theme colors + + + + + diff --git a/docs/6.x/docs/components/Toolbar/_meta.json b/docs/6.x/docs/components/Toolbar/_meta.json new file mode 100644 index 0000000000..26b5bf88f7 --- /dev/null +++ b/docs/6.x/docs/components/Toolbar/_meta.json @@ -0,0 +1,3 @@ +[ + "Toolbar" +] diff --git a/docs/6.x/docs/components/_meta.json b/docs/6.x/docs/components/_meta.json index 6f793d41a3..b272f66237 100644 --- a/docs/6.x/docs/components/_meta.json +++ b/docs/6.x/docs/components/_meta.json @@ -149,6 +149,13 @@ "collapsible": true, "collapsed": false }, + { + "type": "dir", + "name": "Toolbar", + "label": "Toolbar", + "collapsible": true, + "collapsed": false + }, { "type": "dir", "name": "Tooltip", diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index bad25d4eec..0bf5518f60 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -133,6 +133,9 @@ const pages = { ToggleButtonGroup: 'ToggleButton/ToggleButtonGroup', ToggleButtonRow: 'ToggleButton/ToggleButtonRow', }, + Toolbar: { + Toolbar: 'Toolbar/Toolbar', + }, Tooltip: { Tooltip: 'Tooltip/Tooltip', }, diff --git a/docs/public/screenshots/toolbar_docked_standard.png b/docs/public/screenshots/toolbar_docked_standard.png new file mode 100644 index 0000000000..e4b4b677a5 Binary files /dev/null and b/docs/public/screenshots/toolbar_docked_standard.png differ diff --git a/docs/public/screenshots/toolbar_docked_vibrant.png b/docs/public/screenshots/toolbar_docked_vibrant.png new file mode 100644 index 0000000000..cefdd29492 Binary files /dev/null and b/docs/public/screenshots/toolbar_docked_vibrant.png differ diff --git a/docs/public/screenshots/toolbar_floating_horizontal_standard.png b/docs/public/screenshots/toolbar_floating_horizontal_standard.png new file mode 100644 index 0000000000..c6404c8f3e Binary files /dev/null and b/docs/public/screenshots/toolbar_floating_horizontal_standard.png differ diff --git a/docs/public/screenshots/toolbar_floating_horizontal_vibrant.png b/docs/public/screenshots/toolbar_floating_horizontal_vibrant.png new file mode 100644 index 0000000000..5e9458bbb9 Binary files /dev/null and b/docs/public/screenshots/toolbar_floating_horizontal_vibrant.png differ diff --git a/docs/public/screenshots/toolbar_floating_vertical_standard.png b/docs/public/screenshots/toolbar_floating_vertical_standard.png new file mode 100644 index 0000000000..eaf5e3b11b Binary files /dev/null and b/docs/public/screenshots/toolbar_floating_vertical_standard.png differ diff --git a/docs/public/screenshots/toolbar_floating_vertical_vibrant.png b/docs/public/screenshots/toolbar_floating_vertical_vibrant.png new file mode 100644 index 0000000000..e0a18bac97 Binary files /dev/null and b/docs/public/screenshots/toolbar_floating_vertical_vibrant.png differ diff --git a/docs/scripts/generate-rspress-content.ts b/docs/scripts/generate-rspress-content.ts index 1c9accd93a..2e273307d6 100644 --- a/docs/scripts/generate-rspress-content.ts +++ b/docs/scripts/generate-rspress-content.ts @@ -128,8 +128,20 @@ const getVersionComponentOrder = (version: string): MetaEntry[] => { return fromConfig; } + // `componentDocsConfig.pages` is shared across versions, but components + // added there for 6.x-only features (e.g. `Toolbar`) have no docs source + // under 5.x keep them out of the 5.x nav rather than linking to an + // empty/missing page. + const componentsDir = path.join(getVersionDocsDir(version), 'components'); + const existsForVersion = (name: string) => + fs.existsSync(path.join(componentsDir, name)) || + fs.existsSync(path.join(componentsDir, `${name}.mdx`)) || + fs.existsSync(path.join(componentsDir, `${name}.md`)); + return [ - ...fromConfig, + ...fromConfig.filter((entry) => + existsForVersion(typeof entry === 'string' ? entry : entry.name) + ), { type: 'dir', name: 'HelperText', diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..63b6df4099 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -11955,6 +11955,136 @@ ], "group": "ToggleButton" }, + "Toolbar/Toolbar": { + "filepath": "Toolbar/Toolbar.tsx", + "title": "Toolbar", + "description": "A toolbar groups icon actions behind a shared surface.\n\nIt comes in two `variant`s: `floating`, a self-positioned pill anchored wherever you place it\n(similar to a `FAB`), and `docked`, a full-width bar pinned to the bottom edge that extends\ninto the safe-area insets automatically. A floating toolbar can also be laid out vertically\nvia `orientation`.\n\nThe `colorScheme` prop controls how contained `IconButton`/`Button` children are colored. By\ndefault: `standard` keeps them neutral against a surface-colored container, while `vibrant`\ngives the toolbar itself a bold, primary-tinted container and switches selected/unselected\nchildren to matching vibrant colors, making the toolbar stand out as a focal point on the\nscreen.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { StyleSheet, View } from 'react-native';\nimport { Toolbar, IconButton } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n \n {}} />\n {}} />\n {}} />\n \n \n);\n\nconst styles = StyleSheet.create({\n anchor: {\n position: 'absolute',\n left: 0,\n right: 0,\n bottom: 24,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", + "link": "toolbar", + "data": { + "description": "A toolbar groups icon actions behind a shared surface.\n\nIt comes in two `variant`s: `floating`, a self-positioned pill anchored wherever you place it\n(similar to a `FAB`), and `docked`, a full-width bar pinned to the bottom edge that extends\ninto the safe-area insets automatically. A floating toolbar can also be laid out vertically\nvia `orientation`.\n\nThe `colorScheme` prop controls how contained `IconButton`/`Button` children are colored. By\ndefault: `standard` keeps them neutral against a surface-colored container, while `vibrant`\ngives the toolbar itself a bold, primary-tinted container and switches selected/unselected\nchildren to matching vibrant colors, making the toolbar stand out as a focal point on the\nscreen.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { StyleSheet, View } from 'react-native';\nimport { Toolbar, IconButton } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n \n {}} />\n {}} />\n {}} />\n \n \n);\n\nconst styles = StyleSheet.create({\n anchor: {\n position: 'absolute',\n left: 0,\n right: 0,\n bottom: 24,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```", + "displayName": "Toolbar", + "methods": [], + "statics": [], + "props": { + "children": { + "required": true, + "tsType": { + "name": "ReactReactNode", + "raw": "React.ReactNode" + }, + "description": "Content of the toolbar, typically a row of `IconButton`s." + }, + "variant": { + "required": false, + "tsType": { + "name": "Variant" + }, + "description": "`floating` is a self-positioned pill (like a `FAB`); `docked` is a\nfull-width bar anchored to the bottom edge, extending into safe-area\ninsets automatically. Defaults to `floating`.", + "defaultValue": { + "value": "'floating'", + "computed": false + } + }, + "orientation": { + "required": false, + "tsType": { + "name": "Orientation" + }, + "description": "Layout axis for `floating` (`docked` is always horizontal, per spec).\nDefaults to `horizontal`.", + "defaultValue": { + "value": "'horizontal'", + "computed": false + } + }, + "colorScheme": { + "required": false, + "tsType": { + "name": "ColorScheme" + }, + "description": "Role-color preset. Sets default colors on direct, mode-less `IconButton`/`Button`\nchildren, unless they already set their own. Defaults to `standard`.", + "defaultValue": { + "value": "'standard'", + "computed": false + } + }, + "containerColor": { + "required": false, + "tsType": { + "name": "ColorValue" + }, + "description": "Override the container (background) color." + }, + "style": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for positioning `floating`'s pill, or overriding `docked`'s\ndefault anchoring." + }, + "contentContainerStyle": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for the row/column wrapping `children`. Overrides the default\npadding/gap." + }, + "testID": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "TestID used for testing purposes.", + "defaultValue": { + "value": "'toolbar'", + "computed": false + } + }, + "aria-label": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "Accessibility label for the toolbar group. `children` still need\ntheir own `aria-label`s." + }, + "theme": { + "required": false, + "tsType": { + "name": "ThemeProp" + }, + "description": "" + }, + "ref": { + "required": false, + "tsType": { + "name": "ReactRefObject", + "raw": "React.RefObject", + "elements": [ + { + "name": "View" + } + ] + }, + "description": "" + } + } + }, + "type": "component", + "dependencies": [ + "src/components/Toolbar/Toolbar.tsx" + ] + }, "Tooltip/Tooltip": { "filepath": "Tooltip/Tooltip.tsx", "title": "Tooltip", diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..a5afa8ffe3 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -152,6 +152,17 @@ export const screenshots = { ToggleButton: 'screenshots/toggle-button.png', 'ToggleButton.Group': 'screenshots/toggle-button-group.gif', 'ToggleButton.Row': 'screenshots/toggle-button-row.gif', + Toolbar: { + 'floating horizontal': + 'screenshots/toolbar_floating_horizontal_standard.png', + 'floating horizontal - vibrant': + 'screenshots/toolbar_floating_horizontal_vibrant.png', + 'floating vertical': 'screenshots/toolbar_floating_vertical_standard.png', + 'floating vertical - vibrant': + 'screenshots/toolbar_floating_vertical_vibrant.png', + docked: 'screenshots/toolbar_docked_standard.png', + 'docked - vibrant': 'screenshots/toolbar_docked_vibrant.png', + }, Tooltip: 'screenshots/tooltip.png', TouchableRipple: 'screenshots/touchable-ripple.gif', }; diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..c895d9cb89 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -320,6 +320,33 @@ export const themeColors = { textColor: 'theme.colors.onSurface', }, }, + Toolbar: { + standard: { + unselected: { + backgroundColor: 'theme.colors.surfaceContainer', + iconColor: 'theme.colors.onSurfaceVariant', + textColor: 'theme.colors.onSurfaceVariant', + }, + selected: { + backgroundColor: 'theme.colors.secondaryContainer', + iconColor: + 'theme.colors.onSecondaryFixedVariant (light) / theme.colors.onSecondaryContainer (dark)', + }, + }, + vibrant: { + unselected: { + backgroundColor: 'theme.colors.primaryContainer', + iconColor: + 'theme.colors.onPrimaryFixedVariant (light) / theme.colors.onPrimaryContainer (dark)', + textColor: + 'theme.colors.onPrimaryFixedVariant (light) / theme.colors.onPrimaryContainer (dark)', + }, + selected: { + backgroundColor: 'theme.colors.surfaceContainer', + iconColor: 'theme.colors.onSurface', + }, + }, + }, Tooltip: { '-': { backgroundColor: 'theme.colors.onSurface', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 53132e299f..6e703a6f4a 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -45,6 +45,7 @@ import TextInputExample from './Examples/TextInputExample'; import ThemeExample from './Examples/ThemeExample'; import ThemingWithReactNavigation from './Examples/ThemingWithReactNavigation'; import ToggleButtonExample from './Examples/ToggleButtonExample'; +import ToolbarExample from './Examples/ToolbarExample'; import TooltipExample from './Examples/TooltipExample'; import TouchableRippleExample from './Examples/TouchableRippleExample'; @@ -84,6 +85,7 @@ export const mainExamples = { Text: TextExample, TextInput: TextInputExample, ToggleButton: ToggleButtonExample, + Toolbar: ToolbarExample, TooltipExample, TouchableRipple: TouchableRippleExample, Theme: ThemeExample, diff --git a/example/src/Examples/ToolbarExample.tsx b/example/src/Examples/ToolbarExample.tsx new file mode 100644 index 0000000000..abaf00eccc --- /dev/null +++ b/example/src/Examples/ToolbarExample.tsx @@ -0,0 +1,239 @@ +import * as React from 'react'; +import { FlatList, ScrollView, StyleSheet, View } from 'react-native'; + +import { + Chip, + Divider, + IconButton, + Text, + Toolbar, + useTheme, +} from 'react-native-paper'; +import type { + ToolbarColorScheme, + ToolbarOrientation, + ToolbarVariant, +} from 'react-native-paper'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const variants: ToolbarVariant[] = ['floating', 'docked']; +const orientations: ToolbarOrientation[] = ['horizontal', 'vertical']; +const colorSchemes: ToolbarColorScheme[] = ['standard', 'vibrant']; + +const toolbarItems = [ + { icon: 'format-bold', label: 'Bold' }, + { icon: 'format-italic', label: 'Italic' }, + { icon: 'format-underline', label: 'Underline' }, +] as const; + +// Dummy list content, purely to give the screen something to scroll behind +// the toolbar. +const rows = Array.from({ length: 40 }, (_, i) => ({ + id: String(i + 1), + text: `Item ${i + 1}`, +})); + +type ChipRowProps = { + label: string; + options: readonly T[]; + value: T; + onChange: (value: T) => void; + // Controls that don't apply to the current variant stay visible but + // greyed out and non-interactive, per MD3's disabled-state guidance— + // hiding them outright would shift the layout and lose the user's place. + disabled?: boolean; +}; + +const ChipRow = ({ + label, + options, + value, + onChange, + disabled = false, +}: ChipRowProps) => ( + + + {label} + + + {options.map((option) => ( + onChange(option)} + > + {option} + + ))} + + +); + +const ToolbarExample = () => { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + + const [variant, setVariant] = React.useState('floating'); + const [orientation, setOrientation] = + React.useState('horizontal'); + const [colorScheme, setColorScheme] = + React.useState('standard'); + + const isFloating = variant === 'floating'; + const isVertical = isFloating && orientation === 'vertical'; + + const renderItem = React.useCallback( + ({ item }: { item: (typeof rows)[number] }) => ( + + {item.text} + + ), + [] + ); + + const toolbarChildren = ( + <> + {toolbarItems.map(({ icon, label }) => ( + {}} + /> + ))} + + ); + + return ( + + + + + + + + item.id} + contentContainerStyle={[ + styles.listContent, + !isFloating + ? { paddingBottom: insets.bottom + 64 } + : isVertical + ? { paddingBottom: insets.bottom + 24 } + : { paddingBottom: insets.bottom + 96 }, + ]} + /> + {isFloating ? ( + // `floating` doesn't anchor itself (same as `FAB`)—position it with + // a wrapping `View`, same as the component's own doc example. + + + {toolbarChildren} + + + ) : ( + // `docked` anchors itself, flush to the bottom edge, on its own. + + {toolbarChildren} + + )} + + ); +}; + +ToolbarExample.title = 'Toolbar'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + horizontalAnchor: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + verticalAnchor: { + position: 'absolute', + top: 0, + bottom: 0, + justifyContent: 'center', + }, + controls: { + paddingTop: 8, + paddingBottom: 8, + }, + divider: { + marginTop: 8, + }, + chipRow: { + paddingVertical: 4, + }, + chipRowLabel: { + paddingHorizontal: 16, + paddingBottom: 6, + }, + chipRowContent: { + paddingHorizontal: 16, + gap: 8, + }, + disabled: { + opacity: 0.38, + }, + list: { + flex: 1, + }, + listContent: { + paddingHorizontal: 16, + }, + listItem: { + paddingVertical: 12, + }, +}); + +export default ToolbarExample; diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx new file mode 100644 index 0000000000..a0b5fec14f --- /dev/null +++ b/src/components/Toolbar/Toolbar.tsx @@ -0,0 +1,267 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; + +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ToolbarTokens } from './tokens'; +import type { ColorScheme, Orientation, Variant } from './tokens'; +import { + getSpacing, + resolveContainerColor, + resolveElevation, + withToolbarChildColors, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import type { ThemeProp } from '../../types'; +import Surface from '../Surface'; + +export type Props = { + /** + * Content of the toolbar, typically a row of `IconButton`s. + */ + children: React.ReactNode; + /** + * `floating` is a self-positioned pill (like a `FAB`); `docked` is a + * full-width bar anchored to the bottom edge, extending into safe-area + * insets automatically. Defaults to `floating`. + */ + variant?: Variant; + /** + * Layout axis for `floating` (`docked` is always horizontal, per spec). + * Defaults to `horizontal`. + */ + orientation?: Orientation; + /** + * Role-color preset. Sets default colors on direct, mode-less `IconButton`/`Button` + * children, unless they already set their own. Defaults to `standard`. + */ + colorScheme?: ColorScheme; + /** + * Override the container (background) color. + */ + containerColor?: ColorValue; + /** + * Style for positioning `floating`'s pill, or overriding `docked`'s + * default anchoring. + */ + style?: StyleProp; + /** + * Style for the row/column wrapping `children`. Overrides the default + * padding/gap. + */ + contentContainerStyle?: StyleProp; + /** + * TestID used for testing purposes. + */ + testID?: string; + /** + * Accessibility label for the toolbar group. `children` still need + * their own `aria-label`s. + */ + 'aria-label'?: string; + /** + * @optional + */ + theme?: ThemeProp; + ref?: React.RefObject; +}; + +/** + * A toolbar groups icon actions behind a shared surface. + * + * It comes in two `variant`s: `floating`, a self-positioned pill anchored wherever you place it + * (similar to a `FAB`), and `docked`, a full-width bar pinned to the bottom edge that extends + * into the safe-area insets automatically. A floating toolbar can also be laid out vertically + * via `orientation`. + * + * The `colorScheme` prop controls how contained `IconButton`/`Button` children are colored. By + * default: `standard` keeps them neutral against a surface-colored container, while `vibrant` + * gives the toolbar itself a bold, primary-tinted container and switches selected/unselected + * children to matching vibrant colors, making the toolbar stand out as a focal point on the + * screen. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { StyleSheet, View } from 'react-native'; + * import { Toolbar, IconButton } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * + * {}} /> + * {}} /> + * {}} /> + * + * + * ); + * + * const styles = StyleSheet.create({ + * anchor: { + * position: 'absolute', + * left: 0, + * right: 0, + * bottom: 24, + * alignItems: 'center', + * }, + * }); + * + * export default MyComponent; + * ``` + */ +const Toolbar = ({ + children, + variant = 'floating', + orientation = 'horizontal', + colorScheme = 'standard', + containerColor, + style, + contentContainerStyle, + testID = 'toolbar', + 'aria-label': ariaLabel, + theme: themeOverrides, + ref, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const insets = useSafeAreaInsets(); + + const isDocked = variant === 'docked'; + const isVertical = !isDocked && orientation === 'vertical'; + + const backgroundColor = resolveContainerColor({ + theme, + colorScheme, + containerColor, + }); + const borderRadius = resolveCornerRadius( + theme, + isDocked + ? ToolbarTokens.docked.containerShape + : ToolbarTokens.floating.containerShape + ); + const elevation = resolveElevation({ isDocked }); + + // Cross-axis thickness is always the spec value (64dp); insets are + // never mixed in, so the icon band never grows/shrinks with the safe + // area (`docked` extends into insets separately, see + // `dockedInsetMargin` below). + const thickness = isDocked + ? ToolbarTokens.docked.containerHeight + : ToolbarTokens.floating.containerHeight; + const { paddingLeading, paddingTrailing, gap } = getSpacing({ variant }); + + // `docked`'s content row is a fixed 64dp band (see `thickness` above), + // so top/bottom padding would clip taller children (e.g. a `Button` + // label). `floating` has no fixed-height row, so it pads every side. + const contentPadding = isDocked + ? { paddingLeft: paddingLeading, paddingRight: paddingTrailing } + : { + paddingTop: paddingLeading, + paddingBottom: paddingLeading, + paddingLeft: paddingLeading, + paddingRight: paddingTrailing, + }; + // `docked`'s background extends into the bottom/left/right insets while + // its content stays clear of them, via margin outside `Surface`'s own + // fixed-size box (so `Surface` grows to wrap it, keeping the icon row's + // 64dp band untouched). `floating` doesn't self-anchor, so it has no + // insets to account for. + const dockedInsetMargin = isDocked + ? { + marginBottom: insets.bottom, + marginLeft: insets.left, + marginRight: insets.right, + } + : null; + + const pill = ( + + + {withToolbarChildColors({ children, theme, colorScheme })} + + + ); + + // `floating` is positioned directly via `style` (like `FAB`'s `Shell`), + // no wrapper needed. `docked` anchors to its nearest positioned + // ancestor, which needs the wrapping `View` below. + if (!isDocked) { + return pill; + } + + return ( + + {pill} + + ); +}; + +const styles = StyleSheet.create({ + content: { + alignItems: 'center', + justifyContent: 'center', + }, + row: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + column: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + }, + dockedFill: { + width: '100%', + }, + // `docked` anchors absolutely rather than reserving layout space, so + // consumers pad their own content to avoid it, same as `floating`. + dockedContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + }, +}); + +export default Toolbar; diff --git a/src/components/Toolbar/tokens.ts b/src/components/Toolbar/tokens.ts new file mode 100644 index 0000000000..88f75f5204 --- /dev/null +++ b/src/components/Toolbar/tokens.ts @@ -0,0 +1,86 @@ +import type { ColorRole, Elevation } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +/** + * MD3 Toolbar spec dimensions, shape, and color-role tokens. + * @see https://m3.material.io/components/toolbars/specs + */ +export type Variant = 'floating' | 'docked'; + +export type ColorScheme = 'standard' | 'vibrant'; + +export type Orientation = 'horizontal' | 'vertical'; + +const docked = { + containerHeight: 64, + containerShape: 'none' as ShapeToken, + containerLeadingSpace: 16, + containerTrailingSpace: 16, + defaultSpacing: 32, +} as const; + +const floating = { + containerHeight: 64, + containerShape: 'full' as ShapeToken, + containerLeadingSpace: 8, + containerTrailingSpace: 8, + defaultSpacing: 4, +} as const; + +const elevation = { + docked: 0, + floating: 3, +} as const satisfies Record; + +// A role that resolves differently per theme mode. Most roles below +// happen to use the same one in `light`/`dark` (they're already +// theme-aware on their own), but a couple of MD3's "Fixed" roles +// (`on*FixedVariant`) are the wrong pick for `dark`: those are +// *intentionally* the same tone in both modes, whereas the spec's actual +// color for that slot isn't, so `light`/`dark` need to name different +// roles there. +type ToneRole = { light: ColorRole; dark: ColorRole }; + +// Per https://m3.material.io/components/toolbars/specs—color roles for +// the toolbar itself (`container`) and its mode-less children: `IconButton` +// (`icon`, `buttonContainer`) and `Button` (`label`); a `mode` on either +// (filled, outlined, etc.) opts it out in favor of its own mode-based +// coloring instead (see `withToolbarChildColors`). `selected*` roles apply +// only to `IconButton`—`Button` has no `selected` state, and an +// unselected `IconButton` gets no `buttonContainer` override at all, since +// it's the same role as the toolbar's own `container` (i.e. no visible +// pill, it just blends in). +const standardColors = { + container: { light: 'surfaceContainer', dark: 'surfaceContainer' }, + buttonContainer: { light: 'surfaceContainer', dark: 'surfaceContainer' }, + selectedButtonContainer: { + light: 'secondaryContainer', + dark: 'secondaryContainer', + }, + icon: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' }, + selectedIcon: { + light: 'onSecondaryFixedVariant', + dark: 'onSecondaryContainer', + }, + label: { light: 'onSurfaceVariant', dark: 'onSurfaceVariant' }, +} as const satisfies Record; + +const vibrantColors = { + container: { light: 'primaryContainer', dark: 'primaryContainer' }, + buttonContainer: { light: 'primaryContainer', dark: 'primaryContainer' }, + selectedButtonContainer: { + light: 'surfaceContainer', + dark: 'surfaceContainer', + }, + icon: { light: 'onPrimaryFixedVariant', dark: 'onPrimaryContainer' }, + selectedIcon: { light: 'onSurface', dark: 'onSurface' }, + label: { light: 'onPrimaryFixedVariant', dark: 'onPrimaryContainer' }, +} as const satisfies Record; + +export const ToolbarTokens = { + floating, + docked, + elevation, + standardColors, + vibrantColors, +}; diff --git a/src/components/Toolbar/utils.ts b/src/components/Toolbar/utils.ts new file mode 100644 index 0000000000..3a13433a64 --- /dev/null +++ b/src/components/Toolbar/utils.ts @@ -0,0 +1,181 @@ +import * as React from 'react'; +import type { ColorValue } from 'react-native'; + +import { ToolbarTokens } from './tokens'; +import type { ColorScheme, Variant } from './tokens'; +import type { ColorRole, Elevation } from '../../theme/types'; +import type { InternalTheme } from '../../types'; +import Button from '../Button/Button'; +import IconButton from '../IconButton/IconButton'; + +const resolveColors = (colorScheme: ColorScheme) => + colorScheme === 'vibrant' + ? ToolbarTokens.vibrantColors + : ToolbarTokens.standardColors; + +/** Picks the role that applies for `theme`'s own light/dark mode. */ +const resolveTone = ( + theme: InternalTheme, + role: { light: ColorRole; dark: ColorRole } +): ColorValue => theme.colors[theme.dark ? role.dark : role.light]; + +/** Resolve the container (background) color; an explicit `containerColor` wins over `colorScheme`. */ +export const resolveContainerColor = ({ + theme, + colorScheme, + containerColor, +}: { + theme: InternalTheme; + colorScheme: ColorScheme; + containerColor?: ColorValue; +}): ColorValue => { + if (containerColor != null) { + return containerColor; + } + + return resolveTone(theme, resolveColors(colorScheme).container); +}; + +/** + * Resolve an `IconButton`'s icon color and, if `selected`, its container + * color. Unselected has no container override, it's the same role as the + * toolbar's own background, so it just blends in. + */ +export const resolveIconColors = ({ + theme, + colorScheme, + selected, +}: { + theme: InternalTheme; + colorScheme: ColorScheme; + selected: boolean; +}): { iconColor: ColorValue; containerColor?: ColorValue } => { + const roles = resolveColors(colorScheme); + + return selected + ? { + iconColor: resolveTone(theme, roles.selectedIcon), + containerColor: resolveTone(theme, roles.selectedButtonContainer), + } + : { iconColor: resolveTone(theme, roles.icon) }; +}; + +/** Resolve a `Button` child's label color (`Button` has no `selected` state, so there's just one). */ +export const resolveLabelColor = ({ + theme, + colorScheme, +}: { + theme: InternalTheme; + colorScheme: ColorScheme; +}): ColorValue => resolveTone(theme, resolveColors(colorScheme).label); + +type RecolorableProps = { + children?: React.ReactNode; + mode?: unknown; + selected?: boolean; + iconColor?: ColorValue; + containerColor?: ColorValue; + textColor?: ColorValue; + buttonColor?: ColorValue; +}; + +const recolorChildren = ( + children: React.ReactNode, + theme: InternalTheme, + colorScheme: ColorScheme +): React.ReactNode => + React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + // `React.Children.map` doesn't flatten a `Fragment`, so recurse into + // it manually. + if (child.type === React.Fragment) { + return React.cloneElement( + child, + undefined, + recolorChildren(child.props.children, theme, colorScheme) + ); + } + + if (child.type === IconButton) { + // A `mode` or explicit color prop means it already has its own + // spec-defined coloring. + if ( + child.props.mode != null || + child.props.iconColor != null || + child.props.containerColor != null + ) { + return child; + } + + const { iconColor, containerColor } = resolveIconColors({ + theme, + colorScheme, + selected: child.props.selected ?? false, + }); + return React.cloneElement(child, { iconColor, containerColor }); + } + + if (child.type === Button) { + // `text` is `Button`'s mode-less default; any other mode (its own + // spec-defined coloring) or an explicit color prop opts it out. + if ( + (child.props.mode != null && child.props.mode !== 'text') || + child.props.textColor != null || + child.props.buttonColor != null + ) { + return child; + } + + const textColor = resolveLabelColor({ theme, colorScheme }); + return React.cloneElement(child, { textColor }); + } + + return child; + }); + +/** + * Recolors every direct (including Fragment-nested), mode-less + * `IconButton`/`Button` child per `colorScheme`. + */ +export const withToolbarChildColors = ({ + children, + theme, + colorScheme, +}: { + children: React.ReactNode; + theme: InternalTheme; + colorScheme: ColorScheme; +}): React.ReactNode => recolorChildren(children, theme, colorScheme); + +export const resolveElevation = ({ + isDocked, +}: { + isDocked: boolean; +}): Elevation => + isDocked ? ToolbarTokens.elevation.docked : ToolbarTokens.elevation.floating; + +/** + * Leading/trailing padding + inter-item gap for the content row/column, + * from spec defaults (`contentContainerStyle` can override). `docked`'s + * content row is a fixed 64dp band (see `Toolbar.tsx`'s `thickness`), so + * it only pads horizontally to leave room for taller children like a + * `Button` label; `floating` has no such fixed height, so it pads every + * side. + */ +export const getSpacing = ({ + variant, +}: { + variant: Variant; +}): { paddingLeading: number; paddingTrailing: number; gap: number } => { + const tokens = + variant === 'docked' ? ToolbarTokens.docked : ToolbarTokens.floating; + + return { + paddingLeading: tokens.containerLeadingSpace, + paddingTrailing: tokens.containerTrailingSpace, + gap: tokens.defaultSpacing, + }; +}; diff --git a/src/components/__tests__/Toolbar.test.tsx b/src/components/__tests__/Toolbar.test.tsx new file mode 100644 index 0000000000..28b9434810 --- /dev/null +++ b/src/components/__tests__/Toolbar.test.tsx @@ -0,0 +1,384 @@ +import { Platform } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; +import { SafeAreaInsetsContext } from 'react-native-safe-area-context'; + +import { getTheme } from '../../core/theming'; +import { render, screen } from '../../test-utils'; +import Button from '../Button/Button'; +import IconButton from '../IconButton/IconButton'; +import type { ColorScheme } from '../Toolbar/tokens'; +import Toolbar from '../Toolbar/Toolbar'; +import { + resolveContainerColor, + resolveIconColors, + resolveLabelColor, +} from '../Toolbar/utils'; + +type Field = + | 'container' + | 'label' + | 'icon' + | 'selectedIcon' + | 'selectedContainer'; + +const ToolbarChildren = () => ( + <> + {}} /> + {}} /> + +); + +it('renders Toolbar with default props', async () => { + const tree = ( + await render( + + + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders Toolbar with docked variant', async () => { + const tree = ( + await render( + + + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('extends the docked container into the bottom safe-area inset', async () => { + const tree = ( + await render( + + + + + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders Toolbar with vertical orientation', async () => { + const tree = ( + await render( + + + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it("applies `style` directly to the floating pill's outer (position) layer", async () => { + const originalPlatform = Platform.OS; + Platform.OS = 'ios'; + + await render( + + + + ); + + expect(screen.getByTestId('floating-outer-layer')).toHaveStyle({ + position: 'absolute', + top: 5, + }); + + Platform.OS = originalPlatform; +}); + +it("lets `style` override the floating pill's internal container styles", async () => { + await render( + + + + ); + + expect(screen.getByTestId('floating-outer-layer')).toHaveStyle({ + height: 200, + }); +}); + +it("applies `style` to the docked variant's self-anchoring container", async () => { + await render( + + + + ); + + expect(screen.getByTestId('docked-container')).toHaveStyle({ bottom: 10 }); +}); + +it('renders floating Toolbar with vibrant colorScheme', async () => { + const tree = ( + await render( + + + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('renders docked Toolbar with vibrant colorScheme', async () => { + await render( + <> + + + + + + + + ); + + const theme = getTheme(); + expect(screen.getByTestId('toolbar-vibrant')).toHaveStyle({ + backgroundColor: theme.colors.primaryContainer, + }); + expect(screen.getByTestId('toolbar-standard')).toHaveStyle({ + backgroundColor: theme.colors.surfaceContainer, + }); +}); + +it('keeps an explicit iconColor instead of the vibrant default', async () => { + const tree = ( + await render( + + {}} /> + {}} /> + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it("defaults a mode-less IconButton child's iconColor to the toolbar's content color", async () => { + const tree = ( + await render( + + {}} /> + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('leaves an IconButton child with an explicit mode uncolored', async () => { + const tree = ( + await render( + + {}} /> + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('recolors IconButtons through a Fragment held in a variable, not just direct children', async () => { + const fragmentChildren = ( + <> + {}} /> + {}} /> + + ); + const tree = ( + await render({fragmentChildren}) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it('gives a selected IconButton child the selected container color', async () => { + await render( + <> + + {}} + /> + + + {}} + /> + + + ); + + const theme = getTheme(); + expect(screen.getByTestId('standard-selected-container')).toHaveStyle({ + backgroundColor: theme.colors.secondaryContainer, + }); + expect(screen.getByTestId('vibrant-selected-container')).toHaveStyle({ + backgroundColor: theme.colors.surfaceContainer, + }); +}); + +it("leaves a selected IconButton child's explicit containerColor untouched", async () => { + await render( + + {}} + /> + + ); + + expect(screen.getByTestId('explicit-container')).toHaveStyle({ + backgroundColor: 'red', + }); +}); + +it("defaults a mode-less Button child's textColor to the toolbar's content color", async () => { + await render( + <> + + + + + + + + ); + + const theme = getTheme(); + expect(screen.getByTestId('standard-text')).toHaveStyle({ + color: theme.colors.onSurfaceVariant, + }); + expect(screen.getByTestId('vibrant-text')).toHaveStyle({ + color: theme.colors.onPrimaryFixedVariant, + }); +}); + +it('recolors a Button child with an explicit mode="text", same as no mode', async () => { + await render( + + + + ); + + const theme = getTheme(); + expect(screen.getByTestId('text-mode-text')).toHaveStyle({ + color: theme.colors.onPrimaryFixedVariant, + }); +}); + +it('leaves a Button child with a more opinionated mode uncolored', async () => { + const tree = ( + await render( + + + + ) + ).toJSON(); + expect(tree).toMatchSnapshot(); +}); + +it("leaves a Button child's explicit textColor/buttonColor untouched", async () => { + await render( + + + + + ); + + expect(screen.getByTestId('explicit-text-text')).toHaveStyle({ + color: 'red', + }); + expect(screen.getByTestId('explicit-button-color-container')).toHaveStyle({ + backgroundColor: 'blue', + }); +}); + +// `theme.colors` values are `rgba(r, g, b, 1)` strings convert to hex to +// compare against the design spec's hex values directly. +const toHex = (rgba: unknown) => { + const [r, g, b] = String(rgba).match(/\d+/g)!.map(Number); + return ( + '#' + + [r, g, b] + .map((n) => n.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + ); +}; + +describe('color resolution across light/dark themes', () => { + const light = getTheme(false); + const dark = getTheme(true); + + it.each([ + ['standard', 'light', 'container', '#F3EDF7'], + ['standard', 'dark', 'container', '#211F26'], + ['standard', 'light', 'icon', '#49454F'], + ['standard', 'dark', 'icon', '#CAC4D0'], + ['standard', 'light', 'label', '#49454F'], + ['standard', 'dark', 'label', '#CAC4D0'], + ['standard', 'light', 'selectedContainer', '#E8DEF8'], + ['standard', 'dark', 'selectedContainer', '#4A4458'], + ['standard', 'light', 'selectedIcon', '#4A4458'], + ['standard', 'dark', 'selectedIcon', '#E8DEF8'], + ['vibrant', 'light', 'container', '#EADDFF'], + ['vibrant', 'dark', 'container', '#4F378B'], + ['vibrant', 'light', 'icon', '#4F378B'], + ['vibrant', 'dark', 'icon', '#EADDFF'], + ['vibrant', 'light', 'label', '#4F378B'], + ['vibrant', 'dark', 'label', '#EADDFF'], + ['vibrant', 'light', 'selectedContainer', '#F3EDF7'], + ['vibrant', 'dark', 'selectedContainer', '#211F26'], + ['vibrant', 'light', 'selectedIcon', '#1D1B20'], + ['vibrant', 'dark', 'selectedIcon', '#E6E0E9'], + ] as Array<[ColorScheme, 'light' | 'dark', Field, string]>)( + 'resolves %s %s %s to %s', + (colorScheme, mode, field, expectedHex) => { + const theme = mode === 'dark' ? dark : light; + const resolved = + field === 'container' + ? resolveContainerColor({ theme, colorScheme }) + : field === 'label' + ? resolveLabelColor({ theme, colorScheme }) + : field === 'selectedContainer' + ? resolveIconColors({ theme, colorScheme, selected: true }) + .containerColor + : field === 'selectedIcon' + ? resolveIconColors({ theme, colorScheme, selected: true }) + .iconColor + : resolveIconColors({ theme, colorScheme, selected: false }) + .iconColor; + + expect(toHex(resolved)).toBe(expectedHex); + } + ); +}); diff --git a/src/components/__tests__/__snapshots__/Toolbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Toolbar.test.tsx.snap new file mode 100644 index 0000000000..a78639150f --- /dev/null +++ b/src/components/__tests__/__snapshots__/Toolbar.test.tsx.snap @@ -0,0 +1,3115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`defaults a mode-less IconButton child's iconColor to the toolbar's content color 1`] = ` + + + + + + + + + format-bold + + + + + + + + +`; + +exports[`extends the docked container into the bottom safe-area inset 1`] = ` + + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + + +`; + +exports[`keeps an explicit iconColor instead of the vibrant default 1`] = ` + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + +`; + +exports[`leaves a Button child with a more opinionated mode uncolored 1`] = ` + + + + + + + + + Done + + + + + + + + +`; + +exports[`leaves an IconButton child with an explicit mode uncolored 1`] = ` + + + + + + + + + format-bold + + + + + + + + +`; + +exports[`recolors IconButtons through a Fragment held in a variable, not just direct children 1`] = ` + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + +`; + +exports[`renders Toolbar with default props 1`] = ` + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + +`; + +exports[`renders Toolbar with docked variant 1`] = ` + + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + + +`; + +exports[`renders Toolbar with vertical orientation 1`] = ` + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + +`; + +exports[`renders floating Toolbar with vibrant colorScheme 1`] = ` + + + + + + + + + format-bold + + + + + + + + + + + format-italic + + + + + + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..017bf9564f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -46,6 +46,7 @@ export { default as Surface } from './components/Surface'; export { default as Switch } from './components/Switch/Switch'; export { default as Appbar } from './components/Appbar'; export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple'; +export { default as Toolbar } from './components/Toolbar/Toolbar'; export { default as TextInput } from './components/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; @@ -145,6 +146,12 @@ export type { Props as TouchableRippleProps } from './components/TouchableRipple export type { Props as TextProps } from './components/Typography/Text'; export type { Props as SegmentedButtonsProps } from './components/SegmentedButtons/SegmentedButtons'; export type { Props as ListImageProps } from './components/List/ListImage'; +export type { Props as ToolbarProps } from './components/Toolbar/Toolbar'; +export type { + Variant as ToolbarVariant, + ColorScheme as ToolbarColorScheme, + Orientation as ToolbarOrientation, +} from './components/Toolbar/tokens'; export type { Props as TooltipProps } from './components/Tooltip/Tooltip'; export { type TypescaleKey, type Theme, type Elevation } from './types';