diff --git a/docs/documentation/docs/assets/FilterBar.png b/docs/documentation/docs/assets/FilterBar.png new file mode 100644 index 000000000..895972435 Binary files /dev/null and b/docs/documentation/docs/assets/FilterBar.png differ diff --git a/docs/documentation/docs/controls/FilterBar.md b/docs/documentation/docs/controls/FilterBar.md new file mode 100644 index 000000000..6fcbeb005 --- /dev/null +++ b/docs/documentation/docs/controls/FilterBar.md @@ -0,0 +1,88 @@ +# FilterBar + +This control allows you to render a bar of filters that looks exactly the same as in modern lists. + +Here is an example of the control in action: + +![FilterBar control](../assets/FilterBar.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. +- In your component file, import the `FilterBar` control as follows: + +```TypeScript +import { FilterBar } from "@pnp/spfx-controls-react/lib/FilterBar"; +``` + +- Use the `FilterBar` control in your code as follows: + +```TypeScript +// Initial state +this.state = { + filters: [{ + label: "Title", + value: "title 1" + }, + { + label: "Field1", + value: "value 1" + }, + { + label: "Title", + value: "title 2" + } + ] +} +... +... +// Events +private onClearFilters = () => { + console.log("Cleared all filters"); + this.setState({ filters: []}); +} + +private onRemoveFilter = (label: string, value: string) => { + console.log(`Cleared ${label} ${value}`); + const itm = this.state.filters.find(i => i.label === label && i.value === value); + if (itm) { + const index = this.state.filters.indexOf(itm); + this.state.filters.splice(index, 1) + + this.setState({ + filters: [...this.state.filters] + }); + } +} + +... +... + +//Render the filter bar + + +``` + +## Implementation + +The `FilterBar` control can be configured with the following properties: + +| Property | Type | Required | Description | Default | +| ---- | ---- | ---- | ---- | ---- | +| items | [IFilterBarItem[]](#ifilterbaritem) | yes | Filters to be displayed. Multiple filters with the same label are grouped together | | +| inlineItemCount | number | no | Number of filters, after which filters start showing as overflow | 5 | +| onClearFilters | () => void | no | Callback function called after the next item button is clicked. Not used when triggerPageEvent is specified. | | +| onRemoveFilter | (label: string, value: string) => void | no | Callback function called after clicking a singular filter pill | | + +## IFilterBarItem +| Property | Type | Required | Description | Default | +| ---- | ---- | ---- | ---- | ---- | +| label | string | yes | Filter label | | +| value | string | yes | Filter value | | + + +![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/FilterBar) diff --git a/docs/documentation/docs/index.md b/docs/documentation/docs/index.md index c9d2a6e14..dd9368830 100644 --- a/docs/documentation/docs/index.md +++ b/docs/documentation/docs/index.md @@ -79,6 +79,7 @@ The following controls are currently available: - [FieldPicker](./controls/FieldPicker) (control to pick one or multiple fields from a list or a site) - [FilePicker](./controls/FilePicker) (control that allows to browse and select a file from various places) - [FileTypeIcon](./controls/FileTypeIcon) (Control that shows the icon of a specified file path or application) +- [FilterBar](./controls/FilterBar) (Control that renders filters in a similar way to modern lists) - [FolderExplorer](./controls/FolderExplorer) (Control that allows to browse the folders and sub-folders from a root folder) - [FolderPicker](./controls/FolderPicker) (Control that allows to browse and select a folder) - [GridLayout](./controls/GridLayout) (control that renders a responsive grid layout for your web parts) diff --git a/docs/documentation/mkdocs.yml b/docs/documentation/mkdocs.yml index 97d24f4a8..b21e2b1df 100644 --- a/docs/documentation/mkdocs.yml +++ b/docs/documentation/mkdocs.yml @@ -36,6 +36,7 @@ nav: - FieldPicker: 'controls/FieldPicker.md' - FilePicker: 'controls/FilePicker.md' - FileTypeIcon: 'controls/FileTypeIcon.md' + - FilterBar: 'controls/FilterBar.md' - FolderExplorer: 'controls/FolderExplorer.md' - FolderPicker: 'controls/FolderPicker.md' - GridLayout: 'controls/GridLayout.md' diff --git a/src/FilterBar.ts b/src/FilterBar.ts new file mode 100644 index 000000000..17939ae03 --- /dev/null +++ b/src/FilterBar.ts @@ -0,0 +1 @@ +export * from './controls/filterBar'; diff --git a/src/controls/filterBar/FilterBar.module.scss b/src/controls/filterBar/FilterBar.module.scss new file mode 100644 index 000000000..84e31b122 --- /dev/null +++ b/src/controls/filterBar/FilterBar.module.scss @@ -0,0 +1,109 @@ +@import '~@fluentui/react/dist/sass/References.scss'; + +$--ms-semanticColors-listBackground: #ffffff; +$--ms-effects-roundedCorner6: 6px; +$--ms-effects-elevation4: 0 1.6px 3.6px 0 rgba(0, 0, 0, 0.132), 0 0.3px 0.9px 0 rgba(0, 0, 0, 0.108); + +.container { + padding: 0 16px; + align-items: center; + background: $--ms-semanticColors-listBackground; + position: relative; + height: 42px; + white-space: pre; + display: flex; + color: "[theme:neutralSecondary, default:#{$ms-color-neutralSecondary}]"; + overflow: hidden; + border-radius: $--ms-effects-roundedCorner6 $--ms-effects-roundedCorner6 0 0; + box-shadow: $--ms-effects-elevation4; +} + +.pillGroup { + display: flex; + align-items: center; +} + +html[dir=ltr] { + .label { + margin-right: 4px; + // @extend .overflow; + // margin-left: 0; + } + .overflow { + .label { + margin-left: 0; + } + } + .pill { + padding-left: 8px; + } + + .icon { + margin-right: 0; + } + .clearAll { + margin-right: 0; + } + .clearAll { + margin-left: auto; + } +} +html[dir=ltr] { + .label { + margin-left: 8px; + } + + + .pill { + padding-right: 8px; + } + .icon { + margin-left: 10px; + } +} + +.pill { + + background: "[theme:neutralLighter, default:#{$ms-color-neutralLighter}]"; + border-radius: 12px; + border: none; + padding: 0; + display: flex; + align-items: center; + height: 24px; + margin: 0 4px; + cursor: pointer; + z-index: 2; + color: inherit; +} + +.pillText { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + padding-bottom: 2px; +} + +.icon { + font-size: 12px; +} +.overflow { + padding: 2px 8px; + + .pillGroup { + flex-direction: column; + align-items: flex-start; + } + + .pill { + margin: 6px 0; + } +} + +.clearAll { + background: 0 0; + border: 1px solid "[theme:neutralSecondary, default:#{$ms-color-neutralSecondary}]"; + height: 22px; +} + + diff --git a/src/controls/filterBar/FilterlBar.tsx b/src/controls/filterBar/FilterlBar.tsx new file mode 100644 index 000000000..9c64e53c0 --- /dev/null +++ b/src/controls/filterBar/FilterlBar.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import styles from "./FilterBar.module.scss"; +import { PillGroup } from './PillGroup'; +import { Pill } from './Pill'; +import { OverflowPill } from './OverflowPill'; +import { IFilterBarItem } from './IFilterBarItem'; +import { IFilterBarItemGroup } from './IFilterBarItemGroup'; +import * as strings from "ControlStrings"; +import { findLastIndex, uniq} from "lodash"; +import { ThemeProvider } from '@fluentui/react/lib/Theme'; +import { getTheme } from '@fluentui/react/lib/Styling'; + +export interface IFilterPillBarProps { + /** + Filters to be displayed. Multiple filters with the same label are grouped together + */ + items: IFilterBarItem[]; + /** + Number of filters, after which filters start showing as overflow + */ + inlineItemCount?: number; + /** + Callback function called after clicking 'Clear filters' pill. + */ + onClearFilters?: () => void; + /** + Callback function called after clicking a singular filter pill + */ + onRemoveFilter?: (label: string, value: string) => void; +} + +export const FilterBar: React.FunctionComponent = (props: IFilterPillBarProps) => { + + const orderedArray = (arr: IFilterBarItem[]) => { + const ret: IFilterBarItem[] = []; + arr.map(i => { + const index = findLastIndex(ret, r => r.label === i.label); + if (index > -1) + { + ret.splice(index + 1, 0, i); + } + else { + ret.push(i); + } + }); + return ret; + } + + const groupItems = (itms: IFilterBarItem[]): IFilterBarItemGroup[] => itms.reduce((acc: IFilterBarItemGroup[], itm: IFilterBarItem) => { + const label = itm.label; + let obj = acc.find(i => i.label === label); + if (!obj) { + obj = { + label: label, + values: [itm.value] + }; + acc.push(obj); + } + else { + if (!obj.values.find(v => v === itm.value)) { + obj.values.push(itm.value); + } + } + + return acc; + }, []); + + const clearAll = () => { + + if (props.onClearFilters) { + props.onClearFilters(); + } + } + + const pillClick = (label?: string, value?: string) => { + console.log(label, value); + if (props.onRemoveFilter) { + props.onRemoveFilter(label as string, value as string); + } + + } + //const [items, setItems] = React.useState()); + const defaultInlineItemCount = 5; + const [inlineCount, setInlineCount] = React.useState(defaultInlineItemCount); + + const groupedItems: IFilterBarItemGroup[] = React.useMemo(() => groupItems(orderedArray(uniq(props.items))), [props.items]); + + React.useEffect(() => { + setInlineCount(props.inlineItemCount ?? defaultInlineItemCount); + }, [props.inlineItemCount]) + + return ( + <> + + { + groupedItems && groupedItems.length > 0 && ( +
+ { + groupedItems.slice(0, inlineCount).map((i, index) => ) + } + { + groupedItems.length > inlineCount && ( + + ) + } + +
+ ) + } +
+ + ); +}; \ No newline at end of file diff --git a/src/controls/filterBar/IFilterBarItem.ts b/src/controls/filterBar/IFilterBarItem.ts new file mode 100644 index 000000000..a473396cf --- /dev/null +++ b/src/controls/filterBar/IFilterBarItem.ts @@ -0,0 +1,14 @@ +/** + * Public properties of the FilterBarItem + * + */ +export interface IFilterBarItem { + /** + Label of the filter + */ + label: string; + /** + Value of the filter + */ + value: string; +} \ No newline at end of file diff --git a/src/controls/filterBar/IFilterBarItemGroup.ts b/src/controls/filterBar/IFilterBarItemGroup.ts new file mode 100644 index 000000000..28020e13c --- /dev/null +++ b/src/controls/filterBar/IFilterBarItemGroup.ts @@ -0,0 +1,4 @@ +export interface IFilterBarItemGroup { + label: string; + values: string[]; +} \ No newline at end of file diff --git a/src/controls/filterBar/OverflowPill.tsx b/src/controls/filterBar/OverflowPill.tsx new file mode 100644 index 000000000..ce0eda3aa --- /dev/null +++ b/src/controls/filterBar/OverflowPill.tsx @@ -0,0 +1,48 @@ + +import React from "react"; +import { Callout } from "@fluentui/react/lib/Callout"; +import { useBoolean, useId } from "@fluentui/react-hooks"; +import styles from "./FilterBar.module.scss" +import { PillGroup } from "./PillGroup"; +import { IFilterBarItemGroup } from "./IFilterBarItemGroup"; +import * as strings from "ControlStrings"; + +export interface IOverflowPillProps { + onClick: (label?: string, value?: string) => void; + items: IFilterBarItemGroup[]; +} + +export const OverflowPill = (props: IOverflowPillProps) => { + const [overlayVisible, {toggle: toggleOverlayVisible}] = useBoolean(false); + const divId = useId('callout-div'); + + const onClick: React.MouseEventHandler = (event) => { + toggleOverlayVisible(); + } + + const pillClick = (label, value) => { + if (props.onClick) + { + props.onClick(label, value); + } + } + + return ( + <> +
+ +{props.items.length} +
+ { + overlayVisible && ( + +
+ { + props.items.map((i, index) => ) + } +
+
+ ) + } + + ) +} \ No newline at end of file diff --git a/src/controls/filterBar/Pill.tsx b/src/controls/filterBar/Pill.tsx new file mode 100644 index 000000000..cb58606ac --- /dev/null +++ b/src/controls/filterBar/Pill.tsx @@ -0,0 +1,43 @@ + +import { Icon } from "@fluentui/react"; +import styles from "./FilterBar.module.scss"; +import * as strings from "ControlStrings"; +import React from "react"; + +export interface IPillProps { + onClick: (label?: string, value?: string) => void; + clearAll?: boolean; + value?: string; + field?: string; +} +export const Pill = (props: IPillProps) => { + + const onClick = (event) => { + if (props.onClick) + { + props.onClick(props.field, props.value); + } + } + + const buttonProps: any = { + title: props.clearAll ? strings.ClearAllFiltersTitle: strings.ClearFilterTitle, + className: `${styles.pill} ${props.clearAll ? `${styles.pill} ${styles.clearAll}` : ""}`, + "data-automationid": props.clearAll ? "clearfiltersPill": "filterPill", + "data-field": props.field + } + if (props.clearAll) { + buttonProps.tabIndex = 0; + } + else { + buttonProps["data-is-focusable"] = true; + buttonProps["data-value"] = props.value; + buttonProps["aria-disabled"] = false; + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/controls/filterBar/PillGroup.tsx b/src/controls/filterBar/PillGroup.tsx new file mode 100644 index 000000000..381d9cee6 --- /dev/null +++ b/src/controls/filterBar/PillGroup.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import styles from "./FilterBar.module.scss"; +import { Pill } from "./Pill"; +import { IFilterBarItemGroup } from "./IFilterBarItemGroup"; + +export interface IPillGroupProps { + item: IFilterBarItemGroup; + onRemoveFilter?: (label: string, value: string) => void; +} + +export const PillGroup: React.FunctionComponent = (props) => { + + const onClick = (label, value) => { + if (props.onRemoveFilter) + { + props.onRemoveFilter(label, value); + } + } + + return ( +
+
+ { props.item.label }: +
+ { + props.item.values.map((v, index) => ) + } +
+ ) +} \ No newline at end of file diff --git a/src/controls/filterBar/index.ts b/src/controls/filterBar/index.ts new file mode 100644 index 000000000..14b302e6e --- /dev/null +++ b/src/controls/filterBar/index.ts @@ -0,0 +1,2 @@ +export * from './FilterlBar'; +export * from './IFilterBarItem'; \ No newline at end of file diff --git a/src/loc/en-us.ts b/src/loc/en-us.ts index 1ed9bd525..8d61dc05a 100644 --- a/src/loc/en-us.ts +++ b/src/loc/en-us.ts @@ -445,5 +445,10 @@ define([], () => { ImagePickerRetryButtonLabel: "Retry", ImagePickerDeleteLabel: "Delete", + AppliedFiltersAriaLabel: "Applied filters", + ClearAllFiltersTitle: "Clear all filters", + ClearFilterTitle: "Clear filter", + ClearAllFiltersText: "Clear filters", + FilterOverflowAriaLabel: "More" }; }); diff --git a/src/loc/mystrings.d.ts b/src/loc/mystrings.d.ts index 7ea13b2fb..0514c9a85 100644 --- a/src/loc/mystrings.d.ts +++ b/src/loc/mystrings.d.ts @@ -424,6 +424,13 @@ declare interface IControlStrings { TermSertNaviagtionErrorMessage: string; HoverReactionBarSearchEmojiPlaceholder: string; + + // Filter Bar + AppliedFiltersAriaLabel: string; + ClearAllFiltersTitle: string; + ClearFilterTitle: string; + ClearAllFiltersText: string; + FilterOverflowAriaLabel: string; } declare interface IDateTimeStrings { diff --git a/src/webparts/controlsTest/IControlsTestWebPartProps.ts b/src/webparts/controlsTest/IControlsTestWebPartProps.ts index 6ddb4a42c..4dc661bc7 100644 --- a/src/webparts/controlsTest/IControlsTestWebPartProps.ts +++ b/src/webparts/controlsTest/IControlsTestWebPartProps.ts @@ -4,7 +4,7 @@ export type ValidControls = "all" | "ComboBoxListItemPicker" | "Dashboard" | "DateTimePicker" | "DragDropFiles" | "DynamicForm" | "EnhancedThemeProvider" | "FieldCollectionData" | "FieldPicker" | "FilePicker" | - "FileTypeIcon" | "FolderExplorer" | "FolderPicker" | + "FileTypeIcon" | "FilterBar" | "FolderExplorer" | "FolderPicker" | "GridLayout" | "IconPicker" | "IFrameDialog" | "IFramePanel" | "ListPicker" | "ListItemPicker" | "ListItemComments" | "ViewPicker" | "ListView" | diff --git a/src/webparts/controlsTest/components/ControlsTest.tsx b/src/webparts/controlsTest/components/ControlsTest.tsx index 908d3848c..14ffcd56e 100644 --- a/src/webparts/controlsTest/components/ControlsTest.tsx +++ b/src/webparts/controlsTest/components/ControlsTest.tsx @@ -219,6 +219,7 @@ import { import { IControlsTestProps } from './IControlsTestProps'; import { IControlsTestState } from './IControlsTestState'; import { TestControl } from './TestControl'; +import { FilterBar } from '../../../FilterBar'; // Used to render document card /** @@ -303,6 +304,34 @@ const toolbarFilters = [{ title: "filter2" }]; +const filterBarFilters = [{ + label: "Title", + value: "title 1" +}, +{ + label: "Field1", + value: "value 1" +}, +{ + label: "Title", + value: "title 2" +}, +{ + label: "Field2", + value: "Field 2" +}, +{ + label: "Field3", + value: "Field 3" +}, +{ + label: "Field4", + value: "Field 4-1" +}, +{ + label: "Field4", + value: "Field 4-2" +}]; /** * Component that can be used to test out the React controls from this project */ @@ -532,7 +561,8 @@ export default class ControlsTest extends React.Component { + const number = Math.floor(Math.random()*10) + const i = { label: `Field${number}`, value: `Field ${number}`}; + this.setState({ + filters: [...this.state.filters, i] + }); + } + + private onClearFilters = () => { + console.log("Cleared all filters"); + this.setState({ filters: []}); + } + + private onRemoveFilter = (label: string, value: string) => { + console.log(`Cleared ${label} ${value}`); + const itm = this.state.filters.find(i => i.label === label && i.value === value); + if (itm) { + const index = this.state.filters.indexOf(itm); + this.state.filters.splice(index, 1) + + this.setState({ + filters: [...this.state.filters] + }); + } + } + private _onFileClick = (file: IFileInfo): void => { console.log('file click', file); } @@ -1760,6 +1816,10 @@ export default class ControlsTest extends React.Component +