diff --git a/docs/documentation/docs/assets/ListToolBarMobile.png b/docs/documentation/docs/assets/ListToolBarMobile.png new file mode 100644 index 000000000..388ad4640 Binary files /dev/null and b/docs/documentation/docs/assets/ListToolBarMobile.png differ diff --git a/docs/documentation/docs/assets/ListToolBarMultiple.png b/docs/documentation/docs/assets/ListToolBarMultiple.png new file mode 100644 index 000000000..c4e565868 Binary files /dev/null and b/docs/documentation/docs/assets/ListToolBarMultiple.png differ diff --git a/docs/documentation/docs/assets/ListToolBarSingleSelect.png b/docs/documentation/docs/assets/ListToolBarSingleSelect.png new file mode 100644 index 000000000..9577490cb Binary files /dev/null and b/docs/documentation/docs/assets/ListToolBarSingleSelect.png differ diff --git a/docs/documentation/docs/assets/ListToolbar.png b/docs/documentation/docs/assets/ListToolbar.png new file mode 100644 index 000000000..3fe7d72d3 Binary files /dev/null and b/docs/documentation/docs/assets/ListToolbar.png differ diff --git a/docs/documentation/docs/assets/ListToolbar0.png b/docs/documentation/docs/assets/ListToolbar0.png new file mode 100644 index 000000000..a8a5760f1 Binary files /dev/null and b/docs/documentation/docs/assets/ListToolbar0.png differ diff --git a/docs/documentation/docs/controls/ListToolbar.md b/docs/documentation/docs/controls/ListToolbar.md new file mode 100644 index 000000000..311e0f0fc --- /dev/null +++ b/docs/documentation/docs/controls/ListToolbar.md @@ -0,0 +1,142 @@ +# ListToolbar control + +This control renders a flexible toolbar for building list and data grid command bars. It is built with Fluent UI 9 components and supports item grouping, left/right aligned items, dividers, tooltips, automatic overflow menu, responsive design, loading states, and custom rendering. + +Here is an example of the control in action: + +![ListToolbar control](../assets/ListToolbar.png) +![ListToolbar control1](../assets/ListToolbar0.png) +![ListToolbar control](../assets/ListToolBarMultiple.png) +![ListToolbar control](../assets/ListToolBarMobile.png) + +## How to use this control in your solutions + +- Check that you installed the `@pnp/spfx-controls-react` dependency. Check out the [getting started](../../#getting-started) page for more information about installing the dependency. +- Import the following modules to your component: + +```TypeScript +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { IToolbarItem } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +``` + +- Use the `ListToolbar` control in your code as follows: + +```TypeScript +, onClick: () => console.log('New') }, + { key: 'edit', label: 'Edit', icon: , onClick: () => console.log('Edit') }, + { key: 'delete', label: 'Delete', icon: , onClick: () => console.log('Delete') }, + ]} + context={this.props.context} + ariaLabel="Document toolbar" +/> +``` + +- With the `farItems` property you can add items that are aligned to the right side of the toolbar: + +```TypeScript +, onClick: () => {} }, + ]} + farItems={[ + { key: 'filter', label: 'Filter', icon: , onClick: () => {} }, + { key: 'settings', icon: , tooltip: 'Settings', onClick: () => {} }, + ]} + context={this.props.context} +/> +``` + +- Use the `group` property on items to visually group them with dividers: + +```TypeScript +, group: 'file', onClick: () => {} }, + { key: 'edit', label: 'Edit', icon: , group: 'file', onClick: () => {} }, + { key: 'copy', label: 'Copy', icon: , group: 'clipboard', onClick: () => {} }, + { key: 'paste', label: 'Paste', icon: , group: 'clipboard', onClick: () => {} }, + ]} + showGroupDividers={true} + context={this.props.context} +/> +``` + +- Use the `totalCount` property to display a count badge in the toolbar: + +```TypeScript + +``` + +- Use the `isLoading` property to disable all toolbar items during a loading state: + +```TypeScript + +``` + +- Use the `onRender` property on an item for complete custom rendering: + +```TypeScript +const farItems: IToolbarItem[] = [ + { + key: 'search', + onRender: () => ( + onSearch(data.value)} /> + ), + }, +]; + + +``` + +### Overflow & responsive behavior + +When the toolbar is too narrow to show all left-side items, they automatically collapse into a **"..."** overflow menu. The overflow menu appears right next to the last visible item and lists every hidden action. + +On small screens (≤ 768 px), far-item **labels are hidden** and only their icons are shown. The total-count badge is also hidden at that breakpoint. + +## Implementation + +The `ListToolbar` control can be configured with the following properties: + +| Property | Type | Required | Description | Default | +| ---- | ---- | ---- | ---- | ---- | +| items | IToolbarItem[] | yes | Array of toolbar items to display on the left side. | | +| farItems | IToolbarItem[] | no | Items that appear on the right side of the toolbar. | `[]` | +| isLoading | boolean | no | When `true`, all items are disabled. | `false` | +| ariaLabel | string | no | Accessibility label for the toolbar. | `'Toolbar'` | +| totalCount | number | no | Displays a count badge in the toolbar. | | +| className | string | no | Additional CSS class name to apply to the toolbar. | | +| showGroupDividers | boolean | no | Whether to show dividers between item groups. | `true` | +| theme | Theme | no | Fluent UI v8 theme (auto-converted to v9 via `createV9Theme`). | | +| context | BaseComponentContext | no | SPFx component context. Enables automatic Teams theme detection (dark, high-contrast). | | + +### IToolbarItem + +| Property | Type | Required | Description | Default | +| ---- | ---- | ---- | ---- | ---- | +| key | string | yes | Unique identifier for the item. | | +| label | string | no | Button text label. | | +| tooltip | string | no | Tooltip content shown on hover. | | +| icon | ReactElement | no | Icon element to display. | | +| onClick | () => void | no | Click handler for the item. | | +| disabled | boolean | no | Whether the item is disabled. | `false` | +| visible | boolean | no | Whether to show or hide the item. | `true` | +| group | string | no | Group name — items with the same group are grouped together with dividers between groups. | `'default'` | +| isFarItem | boolean | no | Place the item on the right side of the toolbar. | `false` | +| appearance | ToolbarButtonProps['appearance'] | no | Button appearance style. | | +| onRender | () => ReactElement | no | Custom render function for complete control over item rendering. | | +| dividerAfter | boolean | no | Add a divider after this item. | `false` | +| dividerBefore | boolean | no | Add a divider before this item. | `false` | +| ariaLabel | string | no | Accessibility label override. | | + +![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/ListToolbar) diff --git a/src/controls/ListToolbar/FlatItem.ts b/src/controls/ListToolbar/FlatItem.ts new file mode 100644 index 000000000..03843d2c2 --- /dev/null +++ b/src/controls/ListToolbar/FlatItem.ts @@ -0,0 +1,11 @@ +import { IToolbarItem } from './IToolbarItem'; + +/* ------------------------------------------------------------------ */ +/* FlatItem — a union type for rendered toolbar elements */ +/* ------------------------------------------------------------------ */ + +export interface FlatItem { + type: 'item' | 'divider'; + item?: IToolbarItem; + key: string; +} diff --git a/src/controls/ListToolbar/IListToolbarProps.tsx b/src/controls/ListToolbar/IListToolbarProps.tsx new file mode 100644 index 000000000..379fec8ca --- /dev/null +++ b/src/controls/ListToolbar/IListToolbarProps.tsx @@ -0,0 +1,28 @@ +import { BaseComponentContext } from "@microsoft/sp-component-base"; +import { IToolbarItem } from "./IToolbarItem"; +import { Theme } from "@fluentui/react"; + +/** + * Props for the ListToolbar component + */ + +export interface IListToolbarProps { + /** Array of toolbar items */ + items: IToolbarItem[]; + /** Far items that appear on the right side of the toolbar */ + farItems?: IToolbarItem[]; + /** Whether the toolbar is in a loading state */ + isLoading?: boolean; + /** Aria label for the toolbar */ + ariaLabel?: string; + /** Total count to display (optional) */ + totalCount?: number; + /** Custom class name */ + className?: string; + /** Whether to show dividers between groups */ + showGroupDividers?: boolean; + /** Theme for the toolbar */ + theme?: Theme; + /** Context for the web part */ + context?: BaseComponentContext; +} diff --git a/src/controls/ListToolbar/IToolbarItem.tsx b/src/controls/ListToolbar/IToolbarItem.tsx new file mode 100644 index 000000000..a4829df03 --- /dev/null +++ b/src/controls/ListToolbar/IToolbarItem.tsx @@ -0,0 +1,37 @@ +import { ToolbarButtonProps } from "@fluentui/react-components"; +import * as React from "react"; + +/** + * Extended toolbar item interface + */ + +export interface IToolbarItem { + /** Unique key for the item */ + key: string; + /** Label text for the button */ + label?: string; + /** Tooltip content */ + tooltip?: string; + /** Icon element */ + icon?: React.ReactElement; + /** Click handler */ + onClick?: () => void; + /** Whether the item is disabled */ + disabled?: boolean; + /** Whether to show the item */ + visible?: boolean; + /** Group name - items with the same group will be grouped together */ + group?: string; + /** Whether this is a far item (appears on the right side) */ + isFarItem?: boolean; + /** Button appearance */ + appearance?: ToolbarButtonProps["appearance"]; + /** Custom render function for complete control */ + onRender?: () => React.ReactElement; + /** Whether to add a divider after this item */ + dividerAfter?: boolean; + /** Whether to add a divider before this item */ + dividerBefore?: boolean; + /** Aria label override */ + ariaLabel?: string; +} diff --git a/src/controls/ListToolbar/IToolbarItemRendererProps.tsx b/src/controls/ListToolbar/IToolbarItemRendererProps.tsx new file mode 100644 index 000000000..1880cf016 --- /dev/null +++ b/src/controls/ListToolbar/IToolbarItemRendererProps.tsx @@ -0,0 +1,13 @@ +import { IToolbarItem } from './IToolbarItem'; + + +export interface IToolbarItemRendererProps { + /** The toolbar item to render */ + item: IToolbarItem; + /** Whether the toolbar is in a loading state */ + isLoading?: boolean; + /** CSS class applied to the button root */ + itemClass?: string; + /** CSS class applied to the label text (e.g. to hide on mobile) */ + labelClass?: string; +} diff --git a/src/controls/ListToolbar/ListToolbar.tsx b/src/controls/ListToolbar/ListToolbar.tsx new file mode 100644 index 000000000..5bef80cae --- /dev/null +++ b/src/controls/ListToolbar/ListToolbar.tsx @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as React from "react"; + +import { Provider } from "jotai"; +import { has } from "lodash"; + +import { + FluentProvider, + IdPrefixProvider, + teamsDarkTheme, + teamsHighContrastTheme, + teamsLightTheme, + Theme, +} from "@fluentui/react-components"; +import { createV9Theme } from "@fluentui/react-migration-v8-v9"; +import { useTheme } from "@fluentui/react-theme-provider"; +import { WebPartContext } from "@microsoft/sp-webpart-base"; + + +import { IListToolbarProps } from "./IListToolbarProps"; +import ListToolbarControl from "./ListToolbarControl"; + +export const ListToolbar: React.FunctionComponent = ( + props: React.PropsWithChildren +) => { + const { theme: themeV8, context } = props; + const [theme, setTheme] = React.useState(); + const currentSPTheme = useTheme(); + const [isInitialized, setIsInitialized] = React.useState(false); + + + React.useEffect(() => { + (async () => { + try { + if (has(context, "sdks.microsoftTeams.teamsJs.app.getContext")) { + const teamsContext = await (context as WebPartContext).sdks.microsoftTeams?.teamsJs.app.getContext(); + const teamsTheme = teamsContext.app.theme || "default"; + switch (teamsTheme) { + case "dark": + setTheme(teamsDarkTheme); + break; + case "contrast": + setTheme(teamsHighContrastTheme); + break; + case "default": + setTheme(teamsLightTheme); + break; + default: + setTheme(teamsLightTheme); + break; + } + } else if (themeV8 || currentSPTheme) { + setTheme(createV9Theme(themeV8 ?? currentSPTheme)); + } else { + setTheme(teamsLightTheme); + } + } catch (error) { + console.warn("ListToolbar: Failed to resolve theme, using default", error); + setTheme(teamsLightTheme); + } + setIsInitialized(true); + })(); + }, [context, currentSPTheme, themeV8]); + + if (!isInitialized) return <>; + + return ( + <> + + + + + + + + + ); +}; diff --git a/src/controls/ListToolbar/ListToolbarControl.tsx b/src/controls/ListToolbar/ListToolbarControl.tsx new file mode 100644 index 000000000..f21e31b9a --- /dev/null +++ b/src/controls/ListToolbar/ListToolbarControl.tsx @@ -0,0 +1,227 @@ +import * as React from 'react'; +import { + ToolbarButton, + ToolbarDivider, + Tooltip, + Badge, + Text, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, +} from '@fluentui/react-components'; +import { MoreHorizontalRegular } from '@fluentui/react-icons'; +import { useListToolbarStyles } from './useListToolbarStyles'; +import { IToolbarItem } from './IToolbarItem'; +import { IListToolbarProps } from './IListToolbarProps'; +import { useOverflowIndex } from './useOverflowIndex'; +import { ToolbarItemRenderer } from './ToolbarItemRenderer'; +import { groupItems, flattenWithDividers } from './helpers'; + +/* ------------------------------------------------------------------ */ +/* ListToolbar — main component */ +/* ------------------------------------------------------------------ */ + +export const ListToolbar: React.FunctionComponent = ({ + items, + farItems = [], + isLoading = false, + ariaLabel = 'Toolbar', + totalCount, + className, + showGroupDividers = true, +}) => { + const styles = useListToolbarStyles(); + const toolbarRef = React.useRef(null); + const leftRef = React.useRef(null); + const rightRef = React.useRef(null); + const measureRef = React.useRef(null); + + // ---- Separate regular vs far items ---- + const regularItems = React.useMemo( + () => items.filter((i) => !i.isFarItem && i.visible !== false), + [items], + ); + + const allFarItems = React.useMemo(() => { + const fromItems = items.filter( + (i) => i.isFarItem && i.visible !== false, + ); + const fromFar = farItems.filter((i) => i.visible !== false); + return [...fromItems, ...fromFar]; + }, [items, farItems]); + + const groupedFarItems = React.useMemo( + () => groupItems(allFarItems), + [allFarItems], + ); + + // ---- Build flat list of regular items (items + dividers) ---- + const flatItems = React.useMemo( + () => flattenWithDividers(regularItems, showGroupDividers), + [regularItems, showGroupDividers], + ); + + // ---- Custom overflow detection ---- + const overflowIndex = useOverflowIndex( + toolbarRef, rightRef, measureRef, flatItems.length, + ); + + // Determine which actual items (not dividers) are hidden + const visibleItemKeys = React.useMemo(() => { + const keys = new Set(); + for (let i = 0; i < overflowIndex && i < flatItems.length; i++) { + if (flatItems[i].type === 'item') { + keys.add(flatItems[i].key); + } + } + return keys; + }, [flatItems, overflowIndex]); + + const hiddenItems = React.useMemo( + () => regularItems.filter((item) => !visibleItemKeys.has(item.key)), + [regularItems, visibleItemKeys], + ); + + const hasOverflow = hiddenItems.length > 0; + + // ---- Render far items ---- + const renderFarGroup = ( + groupItemsList: IToolbarItem[], + groupIndex: number, + isLastGroup: boolean, + ): React.ReactNode[] => { + const elements: React.ReactNode[] = []; + + for (const item of groupItemsList) { + if (item.dividerBefore) { + elements.push(); + } + elements.push( + , + ); + if (item.dividerAfter) { + elements.push(); + } + } + + if (showGroupDividers && !isLastGroup) { + elements.push(); + } + + return elements; + }; + + return ( +
+ {/* Hidden mirror — renders ALL items for measurement (never clipped) */} + + + {/* Visible container — only renders items that fit + overflow menu */} +
+ {flatItems.map((flat, idx) => { + if (idx >= overflowIndex) return null; + + if (flat.type === 'divider') { + return ; + } + return ( + + ); + })} + + {/* "..." overflow menu — right next to the last visible item */} + {hasOverflow && ( + + + + } + aria-label={`${hiddenItems.length} more actions`} + /> + + + + + {hiddenItems.map((item) => ( + + {item.label || item.tooltip || item.key} + + ))} + + + + )} + + {/* Total count badge — inside left section */} + {totalCount !== undefined && totalCount > 0 && ( + + {totalCount} items + + )} +
+ + {/* Right side — far items (always visible) */} +
+ {allFarItems.length > 0 && ( +
+ {Array.from(groupedFarItems.entries()).map( + ([_groupName, groupItemsList], groupIndex) => + renderFarGroup( + groupItemsList, + groupIndex, + groupIndex === groupedFarItems.size - 1, + ), + )} +
+ )} +
+
+ ); +}; + +export default ListToolbar; diff --git a/src/controls/ListToolbar/README.md b/src/controls/ListToolbar/README.md new file mode 100644 index 000000000..5f85fab0b --- /dev/null +++ b/src/controls/ListToolbar/README.md @@ -0,0 +1,410 @@ +# ListToolbar + +A flexible toolbar component for building list and data grid command bars with support for grouping, far items, and custom rendering. + +## Description + +The `ListToolbar` component provides a powerful and flexible toolbar built with Fluent UI 9 components. It supports item grouping, left/right aligned items, dividers, tooltips, automatic overflow menu, responsive design, loading states, and custom rendering - making it ideal for list views, data grids, and any interface that needs a command bar. + +## Features + +- ✅ Left and right (far) item alignment +- ✅ Item grouping with automatic dividers +- ✅ Individual item dividers (before/after) +- ✅ Tooltip support +- ✅ Loading state with disabled items +- ✅ Custom item rendering +- ✅ Total count badge display +- ✅ Visibility control per item +- ✅ Accessibility support with aria labels +- ✅ Overflow menu — left items collapse into a "..." menu when space is limited +- ✅ Responsive design — far item labels auto-hide on small screens +- ✅ SharePoint & Teams theme support via `theme` and `context` props +- ✅ Fluent UI 9 theming with automatic Teams dark/high-contrast detection + +## Installation + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import type { IListToolbarProps, IToolbarItem } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +``` + +## Props + +### IListToolbarProps + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `items` | `IToolbarItem[]` | Yes | - | Array of toolbar items | +| `farItems` | `IToolbarItem[]` | No | `[]` | Items on the right side | +| `isLoading` | `boolean` | No | `false` | Loading state (disables items) | +| `ariaLabel` | `string` | No | `'Toolbar'` | Accessibility label | +| `totalCount` | `number` | No | - | Display item count badge | +| `className` | `string` | No | - | Additional CSS class | +| `showGroupDividers` | `boolean` | No | `true` | Show dividers between groups | +| `theme` | `Theme` | No | - | Fluent UI v8 theme (auto-converted to v9) | +| `context` | `BaseComponentContext` | No | - | SPFx component context (enables Teams theme detection) | + +### IToolbarItem + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `key` | `string` | Yes | - | Unique identifier | +| `label` | `string` | No | - | Button text | +| `tooltip` | `string` | No | - | Tooltip content | +| `icon` | `ReactElement` | No | - | Icon element | +| `onClick` | `() => void` | No | - | Click handler | +| `disabled` | `boolean` | No | `false` | Disable the item | +| `visible` | `boolean` | No | `true` | Show/hide the item | +| `group` | `string` | No | `'default'` | Group name for grouping | +| `isFarItem` | `boolean` | No | `false` | Place on right side | +| `appearance` | `ToolbarButtonProps['appearance']` | No | - | Button appearance | +| `onRender` | `() => ReactElement` | No | - | Custom render function | +| `dividerAfter` | `boolean` | No | `false` | Add divider after item | +| `dividerBefore` | `boolean` | No | `false` | Add divider before item | +| `ariaLabel` | `string` | No | - | Accessibility label override | + +## Usage Examples + +### Basic Toolbar + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { AddRegular, EditRegular, DeleteRegular } from '@fluentui/react-icons'; + +const BasicToolbar = () => { + const items = [ + { + key: 'new', + label: 'New', + icon: , + onClick: () => console.log('New clicked') + }, + { + key: 'edit', + label: 'Edit', + icon: , + onClick: () => console.log('Edit clicked') + }, + { + key: 'delete', + label: 'Delete', + icon: , + onClick: () => console.log('Delete clicked') + }, + ]; + + return ; +}; +``` + +### With Far Items + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { + AddRegular, + FilterRegular, + ArrowSortRegular, + SettingsRegular +} from '@fluentui/react-icons'; + +const ToolbarWithFarItems = () => { + const items = [ + { key: 'new', label: 'New Item', icon: , onClick: () => {} }, + ]; + + const farItems = [ + { key: 'filter', label: 'Filter', icon: , onClick: () => {} }, + { key: 'sort', label: 'Sort', icon: , onClick: () => {} }, + { key: 'settings', icon: , tooltip: 'Settings', onClick: () => {} }, + ]; + + return ; +}; +``` + +### With Grouping + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { + AddRegular, + EditRegular, + CopyRegular, + CutRegular, + ClipboardPasteRegular, + DeleteRegular +} from '@fluentui/react-icons'; + +const GroupedToolbar = () => { + const items = [ + // File operations group + { key: 'new', label: 'New', icon: , group: 'file', onClick: () => {} }, + { key: 'edit', label: 'Edit', icon: , group: 'file', onClick: () => {} }, + + // Clipboard group + { key: 'copy', label: 'Copy', icon: , group: 'clipboard', onClick: () => {} }, + { key: 'cut', label: 'Cut', icon: , group: 'clipboard', onClick: () => {} }, + { key: 'paste', label: 'Paste', icon: , group: 'clipboard', onClick: () => {} }, + + // Danger group + { key: 'delete', label: 'Delete', icon: , group: 'danger', onClick: () => {} }, + ]; + + return ; +}; +``` + +### With Total Count + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { AddRegular, ArrowSyncRegular } from '@fluentui/react-icons'; + +const ToolbarWithCount = ({ items, totalCount }) => { + const toolbarItems = [ + { key: 'new', label: 'New', icon: , onClick: () => {} }, + { key: 'refresh', label: 'Refresh', icon: , onClick: () => {} }, + ]; + + return ( + + ); +}; +``` + +### Loading State + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { AddRegular, DeleteRegular } from '@fluentui/react-icons'; +import { useState } from 'react'; + +const LoadingToolbar = () => { + const [isLoading, setIsLoading] = useState(false); + + const handleAction = async () => { + setIsLoading(true); + await performAction(); + setIsLoading(false); + }; + + const items = [ + { key: 'new', label: 'New', icon: , onClick: handleAction }, + { key: 'delete', label: 'Delete', icon: , onClick: handleAction }, + ]; + + return ; +}; +``` + +### Conditional Visibility + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { EditRegular, DeleteRegular, ShareRegular } from '@fluentui/react-icons'; + +const ConditionalToolbar = ({ selectedItems, canEdit, canDelete }) => { + const items = [ + { + key: 'edit', + label: 'Edit', + icon: , + visible: selectedItems.length === 1 && canEdit, + onClick: () => {} + }, + { + key: 'delete', + label: 'Delete', + icon: , + visible: selectedItems.length > 0 && canDelete, + onClick: () => {} + }, + { + key: 'share', + label: 'Share', + icon: , + visible: selectedItems.length > 0, + onClick: () => {} + }, + ]; + + return ; +}; +``` + +### With Custom Rendering + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { SearchBox, Dropdown, Option } from '@fluentui/react-components'; +import { AddRegular } from '@fluentui/react-icons'; + +const CustomRenderToolbar = ({ onSearch, onFilterChange }) => { + const items = [ + { key: 'new', label: 'New', icon: , onClick: () => {} }, + ]; + + const farItems = [ + { + key: 'filter', + onRender: () => ( + + + + + + ), + }, + { + key: 'search', + onRender: () => ( + onSearch(data.value)} + /> + ), + }, + ]; + + return ; +}; +``` + +### Document Library Toolbar + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { + AddRegular, + ArrowUploadRegular, + FolderAddRegular, + EditRegular, + DeleteRegular, + ShareRegular, + DownloadRegular, + GridRegular, + ListRegular +} from '@fluentui/react-icons'; + +const DocumentLibraryToolbar = ({ selectedCount, onViewChange, view }) => { + const items = [ + { key: 'new', label: 'New', icon: , group: 'create', onClick: () => {} }, + { key: 'upload', label: 'Upload', icon: , group: 'create', onClick: () => {} }, + { key: 'newFolder', label: 'New Folder', icon: , group: 'create', onClick: () => {} }, + + // Context-sensitive items + { key: 'edit', label: 'Edit', icon: , group: 'actions', visible: selectedCount === 1, onClick: () => {} }, + { key: 'share', label: 'Share', icon: , group: 'actions', visible: selectedCount > 0, onClick: () => {} }, + { key: 'download', label: 'Download', icon: , group: 'actions', visible: selectedCount > 0, onClick: () => {} }, + { key: 'delete', label: 'Delete', icon: , group: 'danger', visible: selectedCount > 0, dividerBefore: true, onClick: () => {} }, + ]; + + const farItems = [ + { + key: 'gridView', + icon: , + tooltip: 'Grid view', + appearance: view === 'grid' ? 'primary' : undefined, + onClick: () => onViewChange('grid') + }, + { + key: 'listView', + icon: , + tooltip: 'List view', + appearance: view === 'list' ? 'primary' : undefined, + onClick: () => onViewChange('list') + }, + ]; + + return ( + 0 ? selectedCount : undefined} + ariaLabel="Document library toolbar" + /> + ); +}; +``` + +### With Dividers + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { + SaveRegular, + ArrowUndoRegular, + ArrowRedoRegular, + TextBoldRegular, + TextItalicRegular, + TextUnderlineRegular +} from '@fluentui/react-icons'; + +const EditorToolbar = () => { + const items = [ + { key: 'save', icon: , tooltip: 'Save', onClick: () => {}, dividerAfter: true }, + { key: 'undo', icon: , tooltip: 'Undo', onClick: () => {} }, + { key: 'redo', icon: , tooltip: 'Redo', onClick: () => {}, dividerAfter: true }, + { key: 'bold', icon: , tooltip: 'Bold', onClick: () => {} }, + { key: 'italic', icon: , tooltip: 'Italic', onClick: () => {} }, + { key: 'underline', icon: , tooltip: 'Underline', onClick: () => {} }, + ]; + + return ; +}; +``` + +### SPFx Web Part Integration + +When used inside a SharePoint Framework web part, pass `context` and `theme` to enable automatic theming (including Teams dark and high-contrast modes): + +```tsx +import { ListToolbar } from '@pnp/spfx-controls-react/lib/controls/ListToolbar'; +import { AddRegular, EditRegular } from '@fluentui/react-icons'; +import { BaseComponentContext } from '@microsoft/sp-component-base'; + +interface IMyWebPartProps { + context: BaseComponentContext; +} + +const MyWebPartToolbar: React.FC = ({ context }) => { + const items = [ + { key: 'new', label: 'New', icon: , onClick: () => {} }, + { key: 'edit', label: 'Edit', icon: , onClick: () => {} }, + ]; + + return ( + + ); +}; +``` + +> **Note:** When running inside Microsoft Teams, the component automatically detects the Teams theme (default, dark, high-contrast) and applies the matching Fluent UI 9 theme. In SharePoint, it converts the current v8 theme to a v9 theme using `createV9Theme`. + +## Notes + +- Items with the same `group` value are grouped together +- Use `isFarItem: true` or the `farItems` prop for right-aligned items +- `visible: false` completely hides an item from the toolbar +- `disabled: true` or `isLoading: true` disables item interaction +- Use `onRender` for complete control over item rendering +- Tooltips are automatically applied when `tooltip` is provided +- When space is limited, left-side items automatically collapse into a **"..." overflow menu** +- Far item labels are **hidden on small screens** (< 768px), showing only icons +- Pass `context` from your SPFx web part to enable **Teams theme detection** +- The component wraps content in a `FluentProvider` — no need to add your own + +## Related Components + +- `ButtonMenu` - For dropdown menu buttons +- `IconButton` - For icon-only buttons +- `GenericOverflowMenu` - For overflow menus diff --git a/src/controls/ListToolbar/ToolbarItemRenderer.tsx b/src/controls/ListToolbar/ToolbarItemRenderer.tsx new file mode 100644 index 000000000..8ecf499b2 --- /dev/null +++ b/src/controls/ListToolbar/ToolbarItemRenderer.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { + ToolbarButton, + Tooltip, +} from '@fluentui/react-components'; +import { IToolbarItemRendererProps } from './IToolbarItemRendererProps'; + +/** + * Renders a single toolbar button with optional tooltip and label. + */ +export const ToolbarItemRenderer: React.FC = ({ + item, + isLoading, + itemClass, + labelClass, +}) => { + if (item.visible === false) return null; + if (item.onRender) return item.onRender(); + + const labelContent = labelClass ? ( + {item.label} + ) : ( + item.label + ); + + const button = item.label ? ( + + {labelContent} + + ) : ( + + ); + + if (item.tooltip) { + return ( + + {button} + + ); + } + + return button; +}; diff --git a/src/controls/ListToolbar/helpers.ts b/src/controls/ListToolbar/helpers.ts new file mode 100644 index 000000000..f325ad107 --- /dev/null +++ b/src/controls/ListToolbar/helpers.ts @@ -0,0 +1,56 @@ +import { FlatItem } from './FlatItem'; +import { IToolbarItem } from './IToolbarItem'; + +/* ------------------------------------------------------------------ */ +/* groupItems — groups items by their `group` property */ +/* ------------------------------------------------------------------ */ + +export function groupItems( + items: IToolbarItem[], +): Map { + const groups = new Map(); + + for (const item of items) { + const groupName = item.group || 'default'; + if (!groups.has(groupName)) { + groups.set(groupName, []); + } + groups.get(groupName)!.push(item); + } + + return groups; +} + +/* ------------------------------------------------------------------ */ +/* flattenWithDividers — builds a flat render-list from grouped items */ +/* ------------------------------------------------------------------ */ + +export function flattenWithDividers( + items: IToolbarItem[], + showGroupDividers: boolean, +): FlatItem[] { + const result: FlatItem[] = []; + const grouped = groupItems(items); + const groupEntries = Array.from(grouped.entries()); + + for (let gi = 0; gi < groupEntries.length; gi++) { + const [, groupItemsList] = groupEntries[gi]; + const isLastGroup = gi === groupEntries.length - 1; + + for (const item of groupItemsList) { + if (item.dividerBefore) { + result.push({ type: 'divider', key: `${item.key}-div-before` }); + } + result.push({ type: 'item', item, key: item.key }); + if (item.dividerAfter) { + result.push({ type: 'divider', key: `${item.key}-div-after` }); + } + } + + if (showGroupDividers && !isLastGroup) { + result.push({ type: 'divider', key: `group-div-${gi}` }); + } + } + + return result; +} diff --git a/src/controls/ListToolbar/index.ts b/src/controls/ListToolbar/index.ts new file mode 100644 index 000000000..7ddc37870 --- /dev/null +++ b/src/controls/ListToolbar/index.ts @@ -0,0 +1,4 @@ +export { ListToolbar } from "./ListToolbar"; +export type { IListToolbarProps } from "./IListToolbarProps"; +export type { IToolbarItem } from "./IToolbarItem"; + diff --git a/src/controls/ListToolbar/useListToolbarStyles.ts b/src/controls/ListToolbar/useListToolbarStyles.ts new file mode 100644 index 000000000..0e39fe6f7 --- /dev/null +++ b/src/controls/ListToolbar/useListToolbarStyles.ts @@ -0,0 +1,123 @@ +import { css } from "@emotion/css"; +import { tokens } from "@fluentui/react-components"; + +export interface IListToolbarStyles { + toolbar: string; + leftSection: string; + measureSection: string; + rightGroup: string; + farItemsContainer: string; + countBadge: string; + divider: string; + toolbarItem: string; + toolbarButtonBase: string; + overflowButton: string; + farItemButton: string; + farItemLabel: string; +} + +export const useListToolbarStyles = (): IListToolbarStyles => { + return { + /** Outer toolbar container */ + toolbar: css` + display: flex !important; + flex-wrap: nowrap !important; + align-items: center !important; + padding: ${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}; + background-color: ${tokens.colorNeutralBackground1}; + border-bottom: 1px solid ${tokens.colorNeutralStroke2}; + min-height: 48px; + gap: ${tokens.spacingHorizontalXS}; + white-space: nowrap; + position: relative; + `, + + /** Visible container for regular (left-side) items */ + leftSection: css` + display: flex !important; + flex-wrap: nowrap !important; + align-items: center !important; + gap: ${tokens.spacingHorizontalXS}; + flex: 0 0 auto !important; + min-width: 0; + `, + + /** Hidden mirror — renders ALL items for unclipped measurement */ + measureSection: css` + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: ${tokens.spacingHorizontalXS}; + position: absolute; + top: 0; + left: 0; + visibility: hidden; + pointer-events: none; + height: 0; + overflow: visible; + z-index: -1; + `, + + /** Far-items group — pushed to the right edge */ + rightGroup: css` + display: flex !important; + flex-wrap: nowrap !important; + align-items: center !important; + gap: ${tokens.spacingHorizontalXS}; + flex: 0 0 auto !important; + margin-left: auto; + white-space: nowrap; + `, + + /** Inner wrapper for far-items inside rightGroup */ + farItemsContainer: css` + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: inherit; + `, + + /** Base style for all ToolbarButton instances */ + toolbarButtonBase: css` + min-width: auto; + `, + + /** "..." overflow menu trigger button */ + overflowButton: css` + flex-shrink: 0; + `, + + /** Count badge — hidden on mobile */ + countBadge: css` + flex-shrink: 0; + @media (max-width: 768px) { + display: none; + } + `, + + /** Divider between groups */ + divider: css` + height: 24px; + margin: 0 ${tokens.spacingHorizontalXS}; + `, + + /** Regular toolbar item */ + toolbarItem: css` + flex-shrink: 0; + width: fit-content; + white-space: nowrap; + `, + + /** Far-item button */ + farItemButton: css` + flex-shrink: 0; + `, + + /** Label text inside far-item buttons — hidden on mobile */ + farItemLabel: css` + @media (max-width: 768px) { + display: none; + } + `, + }; +}; diff --git a/src/controls/ListToolbar/useOverflowIndex.ts b/src/controls/ListToolbar/useOverflowIndex.ts new file mode 100644 index 000000000..f61481459 --- /dev/null +++ b/src/controls/ListToolbar/useOverflowIndex.ts @@ -0,0 +1,123 @@ +import * as React from 'react'; + +/** + * Minimum gap breakpoints based on the toolbar's own pixel width. + * Wider toolbars get a larger minimum gap between regular and far items. + */ +const GAP_BREAKPOINTS: { minWidth: number; gap: number }[] = [ + { minWidth: 1280, gap: 100 }, + { minWidth: 1024, gap: 60 }, + { minWidth: 768, gap: 40 }, + { minWidth: 480, gap: 24 }, + { minWidth: 0, gap: 12 }, +]; + +function getMinGap(toolbarWidth: number): number { + for (const bp of GAP_BREAKPOINTS) { + if (toolbarWidth > bp.minWidth) return bp.gap; + } + return GAP_BREAKPOINTS[GAP_BREAKPOINTS.length - 1].gap; +} + +/** + * Measures how many flat-list children fit inside the available width. + * + * Uses three refs: + * - `toolbarRef` → the outer toolbar; a ResizeObserver watches this + * for width changes. + * - `rightRef` → the far-items group; its width is subtracted from + * the toolbar width to get the available space for + * regular items. + * - `measureRef` → a hidden mirror (`visibility:hidden; overflow:visible`) + * that always renders ALL items so each child's true + * unclipped width can be read. + * + * @returns The index in the flat item list at which items start to overflow. + */ +export function useOverflowIndex( + toolbarRef: React.RefObject, + rightRef: React.RefObject, + measureRef: React.RefObject, + itemCount: number, + moreButtonWidth: number = 48, +): number { + const [overflowIndex, setOverflowIndex] = React.useState(itemCount); + + React.useEffect(() => { + const toolbarEl = toolbarRef.current; + const rightEl = rightRef.current; + const measureEl = measureRef.current; + if (!toolbarEl || !measureEl) return; + + const calculate = (): void => { + const toolbarWidth = toolbarEl.offsetWidth; + if (toolbarWidth === 0) return; + + const toolbarStyle = getComputedStyle(toolbarEl); + const paddingLeft = parseFloat(toolbarStyle.paddingLeft || '0'); + const paddingRight = parseFloat(toolbarStyle.paddingRight || '0'); + const toolbarGap = parseFloat( + toolbarStyle.columnGap || toolbarStyle.gap || '0', + ); + + const minGap = getMinGap(toolbarWidth); + + const rightWidth = rightEl ? rightEl.offsetWidth : 0; + const rightReserved = + rightWidth > 0 ? rightWidth + minGap + toolbarGap : 0; + + const availableWidth = + toolbarWidth - paddingLeft - paddingRight - rightReserved; + if (availableWidth <= 0) return; + + const children = Array.from(measureEl.children) as HTMLElement[]; + if (children.length === 0) { + setOverflowIndex(itemCount); + return; + } + + const gap = parseFloat(getComputedStyle(measureEl).columnGap || '0'); + + // Total width of all items in the unclipped mirror + let totalWidth = 0; + for (const child of children) { + totalWidth += child.offsetWidth; + } + totalWidth += Math.max(0, children.length - 1) * gap; + + // If everything fits, show all + if (totalWidth <= availableWidth) { + setOverflowIndex(children.length); + return; + } + + // Otherwise, find how many fit leaving room for the "..." button + const budgetWidth = availableWidth - moreButtonWidth; + let usedWidth = 0; + let fitCount = 0; + + for (let i = 0; i < children.length; i++) { + const childWidth = children[i].offsetWidth; + const withGap = i > 0 ? gap : 0; + + if (usedWidth + withGap + childWidth > budgetWidth) break; + usedWidth += withGap + childWidth; + fitCount++; + } + + setOverflowIndex(Math.max(1, fitCount)); + }; + + const rafId = requestAnimationFrame(calculate); + + const ro = new ResizeObserver(calculate); + ro.observe(toolbarEl); + + return () => { + cancelAnimationFrame(rafId); + ro.disconnect(); + }; + }, [toolbarRef, rightRef, measureRef, itemCount, moreButtonWidth]); + + return overflowIndex; +} diff --git a/src/webparts/controlsTest/components/ControlsTest.tsx b/src/webparts/controlsTest/components/ControlsTest.tsx index 091d4860c..ac87c9b92 100644 --- a/src/webparts/controlsTest/components/ControlsTest.tsx +++ b/src/webparts/controlsTest/components/ControlsTest.tsx @@ -244,6 +244,8 @@ const ListItemComments = React.lazy(() => import('../../../ListItemComments').th const ListItemPicker = React.lazy(() => import('../../../ListItemPicker').then(module => ({ default: module.ListItemPicker }))); const ListPicker = React.lazy(() => import('../../../ListPicker').then(module => ({ default: module.ListPicker }))); +const ListToolbar = React.lazy(() => import('../../../controls/ListToolbar').then(module => ({ default: module.ListToolbar }))); + const ListView = React.lazy(() => import('../../../ListView').then(module => ({ default: module.ListView }))); const Map = React.lazy(() => import('../../../Map').then(module => ({ default: module.Map }))); @@ -2942,6 +2944,27 @@ export default class ControlsTest extends React.Component } + {controlVisibility.ListToolbar && +
+

ListToolbar

+ +, onClick: () => console.log('New clicked'), group: 'actions' }, + { key: 'edit', label: 'Edit', icon: , onClick: () => console.log('Edit clicked'), group: 'actions' }, + { key: 'delete', label: 'Delete', icon: 🗑, onClick: () => console.log('Delete clicked'), appearance: 'subtle', group: 'danger' }, + ]} + farItems={[ + { key: 'filter', label: 'Filter', icon: 🔍, onClick: () => console.log('Filter clicked'), isFarItem: true }, + { key: 'settings', label: 'Settings', icon: , onClick: () => console.log('Settings clicked'), isFarItem: true }, + ]} + totalCount={10} + showGroupDividers={true} + ariaLabel="List Toolbar" + /> +
+ } {controlVisibility.TestControl &&
diff --git a/src/webparts/controlsTest/components/TestControl.tsx b/src/webparts/controlsTest/components/TestControl.tsx index f9bfa5ff0..0b18c1a4d 100644 --- a/src/webparts/controlsTest/components/TestControl.tsx +++ b/src/webparts/controlsTest/components/TestControl.tsx @@ -9,37 +9,48 @@ import { Title3, makeStyles, shorthands, + Button, + Text, } from '@fluentui/react-components'; -import { IUserInfo } from '../../../controls/userPicker/models/IUserInfo'; +import { + AddRegular, + EditRegular, + DeleteRegular, + CopyRegular, + ArrowUploadRegular, + FilterRegular, + ArrowSortRegular, + SettingsRegular, + GridRegular, + ListRegular, + ShareRegular, + ArrowDownloadRegular, + PinRegular, + FolderAddRegular, + RenameRegular, + InfoRegular, + ArchiveRegular, + TagRegular, +} from '@fluentui/react-icons'; import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ListToolbar } from '../../../controls/ListToolbar'; +import { IToolbarItem } from '../../../controls/ListToolbar'; + +import { IUserInfo } from '../../../controls/userPicker/models/IUserInfo'; + import { createV9Theme } from '@fluentui/react-migration-v8-v9'; import { Kpis } from '../../../controls/KPIControl'; const useStyles = makeStyles({ root: { display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - ...shorthands.gap('10px'), - marginLeft: '50%', - marginRight: '50%', + flexDirection: 'column', + gap:'10px', height: 'fit-content', - width: 'fit-content', - }, - image: { - width: '20px', - height: '20px', - }, - title: { - marginBottom: '30px', - display: 'flex', - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', + width: '100%', }, }); @@ -49,21 +60,281 @@ export interface ITestControlProps { } export const TestControl: React.FunctionComponent = ( - props: React.PropsWithChildren + props: React.PropsWithChildren, ) => { const { themeVariant, context } = props; const styles = useStyles(); - const setTheme = React.useCallback((): Partial => { - return createV9Theme(themeVariant); - }, [themeVariant]); + const [selectedCount, setSelectedCount] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(false); + const [view, setView] = React.useState<'grid' | 'list'>('list'); - const onSelectedUsers = (users: IUserInfo[]) => { - console.log('selected users', users); - }; + // Callback handler for toolbar item clicks + const onToolbarItemClick = React.useCallback( + (action: string) => { + console.log(`Toolbar action selected: ${action}`); + + switch (action) { + case 'new': + console.log('Creating new item...'); + break; + case 'newFolder': + console.log('Creating new folder...'); + break; + case 'upload': + console.log('Opening upload dialog...'); + break; + case 'edit': + console.log('Editing selected item...'); + break; + case 'rename': + console.log('Renaming selected item...'); + break; + case 'copy': + console.log(`Copying ${selectedCount} item(s)...`); + break; + case 'share': + console.log(`Sharing ${selectedCount} item(s)...`); + break; + case 'download': + console.log(`Downloading ${selectedCount} item(s)...`); + break; + case 'pin': + console.log(`Pinning ${selectedCount} item(s)...`); + break; + case 'tag': + console.log(`Tagging ${selectedCount} item(s)...`); + break; + case 'archive': + console.log(`Archiving ${selectedCount} item(s)...`); + break; + case 'details': + console.log('Opening details panel...'); + break; + case 'delete': + console.log(`Deleting ${selectedCount} item(s)...`); + setSelectedCount(0); + break; + case 'filter': + console.log('Opening filter panel...'); + break; + case 'sort': + console.log('Opening sort options...'); + break; + case 'settings': + console.log('Opening settings...'); + break; + default: + break; + } + }, + [selectedCount], + ); + + // Regular items (left side) with grouping + const items: IToolbarItem[] = React.useMemo( + () => [ + // Create group + { + key: 'new', + label: 'New', + icon: , + appearance: 'primary', + group: 'create', + onClick: () => onToolbarItemClick('new'), + }, + { + key: 'newFolder', + label: 'New Folder', + icon: , + appearance: 'subtle', + group: 'create', + onClick: () => onToolbarItemClick('newFolder'), + }, + { + key: 'upload', + label: 'Upload', + icon: , + appearance: 'subtle', + group: 'create', + onClick: () => onToolbarItemClick('upload'), + }, + // Edit group + { + key: 'edit', + label: 'Edit', + icon: , + group: 'edit', + appearance: 'subtle', + visible: selectedCount === 1, + onClick: () => onToolbarItemClick('edit'), + }, + { + key: 'rename', + label: 'Rename', + icon: , + group: 'edit', + appearance: 'subtle', + visible: selectedCount === 1, + onClick: () => onToolbarItemClick('rename'), + }, + { + key: 'copy', + label: 'Copy', + icon: , + group: 'edit', + appearance: 'subtle', + visible: selectedCount > 0, + onClick: () => onToolbarItemClick('copy'), + }, + // Share & Download group + { + key: 'share', + label: 'Share', + icon: , + group: 'share', + appearance: 'subtle', + visible: selectedCount > 0, + onClick: () => onToolbarItemClick('share'), + }, + { + key: 'download', + label: 'Download', + icon: , + group: 'share', + appearance: 'subtle', + visible: selectedCount > 0, + onClick: () => onToolbarItemClick('download'), + }, + // Organize group + { + key: 'pin', + label: 'Pin to top', + icon: , + group: 'organize', + appearance: 'subtle', + visible: selectedCount > 0, + onClick: () => onToolbarItemClick('pin'), + }, + { + key: 'tag', + label: 'Tag', + icon: , + group: 'organize', + appearance: 'subtle', + visible: selectedCount > 0, + onClick: () => onToolbarItemClick('tag'), + }, + { + key: 'archive', + label: 'Archive', + icon: , + group: 'organize', + appearance: 'subtle', + visible: selectedCount > 0, + onClick: () => onToolbarItemClick('archive'), + }, + // Info group (always visible) + { + key: 'details', + label: 'Details', + icon: , + group: 'info', + appearance: 'subtle', + visible: selectedCount === 1, + onClick: () => onToolbarItemClick('details'), + }, + // Danger group + { + key: 'delete', + label: 'Delete', + icon: , + group: 'danger', + appearance: 'subtle', + visible: selectedCount > 0, + dividerBefore: true, + onClick: () => onToolbarItemClick('delete'), + }, + ], + [selectedCount, onToolbarItemClick], + ); + + // Far items (right side) + const farItems: IToolbarItem[] = React.useMemo( + () => [ + { + key: 'filter', + label: 'Filter', + icon: , + appearance: 'transparent', + onClick: () => onToolbarItemClick('filter'), + }, + { + key: 'sort', + label: 'Sort', + icon: , + appearance: 'transparent', + onClick: () => onToolbarItemClick('sort'), + dividerAfter: true, + }, + { + key: 'gridView', + icon: , + tooltip: 'Grid view', + appearance: view === 'grid' ? 'primary' : 'transparent', + onClick: () => { + setView('grid'); + onToolbarItemClick('gridView'); + }, + }, + { + key: 'listView', + icon: , + tooltip: 'List view', + appearance: view === 'list' ? 'primary' : 'transparent', + onClick: () => { + setView('list'); + onToolbarItemClick('listView'); + }, + }, + { + key: 'settings', + icon: , + tooltip: 'Settings', + appearance: 'transparent', + onClick: () => onToolbarItemClick('settings'), + }, + ], + [view, onToolbarItemClick], + ); return ( +
+
+ Simulate selection: + + + + Current: {selectedCount} selected +
+ 0 ? selectedCount : undefined} + showGroupDividers={true} + ariaLabel="Document library toolbar" + /> +
<>