diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c01f6ac9eeddf..23bc7ea4802ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -165,3 +165,4 @@ packages/react-next/src/components/Toggle/ @xugao packages/react-toggle/ @xugao packages/office-ui-fabric-react/src/components/Tooltip/ @micahgodbolt packages/experiments/src/components/Toggle/ @khmakoto +packages/react-spinbutton/ @dzearing @ecraig12345 diff --git a/apps/a11y-tests/package.json b/apps/a11y-tests/package.json index 5423310c5a5d2..2caed9aeb63cb 100644 --- a/apps/a11y-tests/package.json +++ b/apps/a11y-tests/package.json @@ -13,13 +13,13 @@ "update-snapshots": "just-scripts jest -u" }, "dependencies": { - "@uifabric/fabric-website-resources": "^7.8.10", + "@uifabric/fabric-website-resources": "^7.8.12", "axe-core": "^3.2.2", "axe-puppeteer": "^1.0.0", "axe-sarif-converter": "^2.0.1", "glob": "^7.1.2", "mkdirp": "^0.5.1", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "puppeteer": "^1.13.0", "tslib": "^1.10.0", "react": "16.8.6", @@ -36,6 +36,6 @@ "@types/react-dom": "16.8.4", "@types/sarif": "^2.1.1", "@uifabric/build": "^7.0.0", - "@uifabric/icons": "^7.5.2" + "@uifabric/icons": "^7.5.4" } } diff --git a/apps/a11y-tests/src/tests/__snapshots__/ComponentExamples.test.tsx.snap b/apps/a11y-tests/src/tests/__snapshots__/ComponentExamples.test.tsx.snap index 7997437b393a7..5f6466872394a 100644 --- a/apps/a11y-tests/src/tests/__snapshots__/ComponentExamples.test.tsx.snap +++ b/apps/a11y-tests/src/tests/__snapshots__/ComponentExamples.test.tsx.snap @@ -4,11 +4,9 @@ exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Basic.Exam exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Compact.Example) 1`] = `Array []`; -exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Persona.Example) 1`] = `Array []`; - -exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Persona.Example) 2`] = `Array []`; +exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Compact.Example) 2`] = `Array []`; -exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Persona.Example) 3`] = `Array []`; +exports[`a11y test checks accessibility of ActivityItem (ActivityItem.Persona.Example) 1`] = `Array []`; exports[`a11y test checks accessibility of Announced (Announced.BulkOperations.Example) 1`] = `Array []`; @@ -232,7 +230,7 @@ Array [ }, "region": Object { "snippet": Object { - "text": "", + "text": "", }, }, }, diff --git a/apps/codesandbox-react-next-template/package.json b/apps/codesandbox-react-next-template/package.json index c16d3c7e06349..49fe7b718561f 100644 --- a/apps/codesandbox-react-next-template/package.json +++ b/apps/codesandbox-react-next-template/package.json @@ -19,8 +19,8 @@ }, "dependencies": { "@microsoft/load-themed-styles": "^1.10.26", - "@fluentui/react-next": "^8.0.0-alpha.108", - "@uifabric/theme-samples": "^7.1.14", + "@fluentui/react-next": "^8.0.0-alpha.110", + "@uifabric/theme-samples": "^7.1.16", "react": "16.8.6", "react-dom": "16.8.6", "tslib": "^1.10.0" diff --git a/apps/codesandbox-react-template/package.json b/apps/codesandbox-react-template/package.json index f34502e8c8d7d..fdab80f394abf 100644 --- a/apps/codesandbox-react-template/package.json +++ b/apps/codesandbox-react-template/package.json @@ -20,7 +20,7 @@ "dependencies": { "@uifabric/set-version": "^7.0.23", "@microsoft/load-themed-styles": "^1.10.26", - "@fluentui/react": "^7.137.3", + "@fluentui/react": "^7.137.5", "react": "16.8.6", "react-dom": "16.8.6", "tslib": "^1.10.0" diff --git a/apps/dom-tests/package.json b/apps/dom-tests/package.json index be64607d79784..66f120929b520 100644 --- a/apps/dom-tests/package.json +++ b/apps/dom-tests/package.json @@ -20,12 +20,12 @@ "@types/react": "16.8.25", "@types/webpack-env": "1.15.1", "@uifabric/build": "^7.0.0", - "@uifabric/example-app-base": "^7.15.10", + "@uifabric/example-app-base": "^7.15.12", "expect-puppeteer": "4.1.0", "jest-environment-node": "~24.9.0", "jest-environment-puppeteer": "^4.1.0", "jest-puppeteer": "^4.0.0", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "puppeteer": "^1.13.0", "react-dom": "16.8.6", "react": "16.8.6", diff --git a/apps/fabric-website-resources/package.json b/apps/fabric-website-resources/package.json index 71b3ec52ae386..ee9a592dfb0bb 100644 --- a/apps/fabric-website-resources/package.json +++ b/apps/fabric-website-resources/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/fabric-website-resources", - "version": "7.8.10", + "version": "7.8.12", "description": "Fluent UI React local demo app", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -37,22 +37,22 @@ }, "dependencies": { "@microsoft/load-themed-styles": "^1.10.26", - "@uifabric/api-docs": "^7.5.10", - "@uifabric/azure-themes": "^7.5.10", - "@uifabric/example-app-base": "^7.15.10", - "@uifabric/fluent-theme": "^7.3.9", - "@uifabric/icons": "^7.5.2", - "@uifabric/mdl2-theme": "^0.3.9", + "@uifabric/api-docs": "^7.5.12", + "@uifabric/azure-themes": "^7.5.12", + "@uifabric/example-app-base": "^7.15.12", + "@uifabric/fluent-theme": "^7.3.11", + "@uifabric/icons": "^7.5.4", + "@uifabric/mdl2-theme": "^0.3.11", "@uifabric/merge-styles": "^7.19.1", - "@uifabric/react-cards": "^0.112.10", + "@uifabric/react-cards": "^0.112.12", "@uifabric/set-version": "^7.0.23", - "@uifabric/styling": "^7.16.2", - "@uifabric/theme-samples": "^7.1.14", - "@uifabric/tsx-editor": "^0.13.10", - "@uifabric/utilities": "^7.32.0", - "@uifabric/variants": "^7.2.14", + "@uifabric/styling": "^7.16.4", + "@uifabric/theme-samples": "^7.1.16", + "@uifabric/tsx-editor": "^0.13.12", + "@uifabric/utilities": "^7.32.1", + "@uifabric/variants": "^7.2.16", "office-ui-fabric-core": "^11.0.0", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" }, "peerDependencies": { diff --git a/apps/fabric-website/package.json b/apps/fabric-website/package.json index ce8b03f2122f1..a89219e1c3f53 100644 --- a/apps/fabric-website/package.json +++ b/apps/fabric-website/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/fabric-website", - "version": "7.15.10", + "version": "7.15.12", "description": "The official website for the Fluent UI project.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -33,7 +33,7 @@ "@types/react-dom": "16.8.4", "@types/webpack-env": "1.15.1", "@uifabric/build": "^7.0.0", - "@uifabric/tsx-editor": "^0.13.10", + "@uifabric/tsx-editor": "^0.13.12", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6", @@ -41,19 +41,19 @@ }, "dependencies": { "@microsoft/load-themed-styles": "^1.10.26", - "@uifabric/api-docs": "^7.5.10", - "@uifabric/example-app-base": "^7.15.10", - "@uifabric/experiments": "^7.32.11", - "@uifabric/fabric-website-resources": "^7.8.10", - "@uifabric/file-type-icons": "^7.6.3", - "@uifabric/fluent-theme": "^7.3.9", - "@uifabric/icons": "^7.5.2", - "@uifabric/react-cards": "^0.112.10", + "@uifabric/api-docs": "^7.5.12", + "@uifabric/example-app-base": "^7.15.12", + "@uifabric/experiments": "^7.32.13", + "@uifabric/fabric-website-resources": "^7.8.12", + "@uifabric/file-type-icons": "^7.6.5", + "@uifabric/fluent-theme": "^7.3.11", + "@uifabric/icons": "^7.5.4", + "@uifabric/react-cards": "^0.112.12", "@uifabric/set-version": "^7.0.23", - "@uifabric/theme-samples": "^7.1.14", + "@uifabric/theme-samples": "^7.1.16", "json-loader": "^0.5.7", "office-ui-fabric-core": "^11.0.0", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0", "whatwg-fetch": "2.0.4" }, diff --git a/apps/perf-test/package.json b/apps/perf-test/package.json index c54f9ca5e4e5b..7ad41bae352bb 100644 --- a/apps/perf-test/package.json +++ b/apps/perf-test/package.json @@ -24,13 +24,13 @@ "lodash": "^4.17.15" }, "dependencies": { - "@fluentui/react-button": "^0.12.6", - "@fluentui/react-next": "^8.0.0-alpha.108", + "@fluentui/react-button": "^0.13.1", + "@fluentui/react-next": "^8.0.0-alpha.110", "@uifabric/set-version": "^7.0.23", - "@uifabric/example-app-base": "^7.15.10", - "@uifabric/experiments": "^7.32.11", + "@uifabric/example-app-base": "^7.15.12", + "@uifabric/experiments": "^7.32.13", "@microsoft/load-themed-styles": "^1.10.26", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "querystring": "^0.2.0", "react": "16.8.6", "react-app-polyfill": "~1.0.1", diff --git a/apps/perf-test/src/scenarios/Breadcrumb.tsx b/apps/perf-test/src/scenarios/Breadcrumb.tsx new file mode 100644 index 0000000000000..186c5bd173600 --- /dev/null +++ b/apps/perf-test/src/scenarios/Breadcrumb.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { Breadcrumb } from 'office-ui-fabric-react'; + +const items = [{ text: 'test', key: 'f1' }]; +const Scenario = () => ; + +export default Scenario; diff --git a/apps/perf-test/src/scenarios/BreadcrumbNext.tsx b/apps/perf-test/src/scenarios/BreadcrumbNext.tsx new file mode 100644 index 0000000000000..5700f6ee78109 --- /dev/null +++ b/apps/perf-test/src/scenarios/BreadcrumbNext.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { Breadcrumb } from '@fluentui/react-next'; + +const items = [{ text: 'test', key: 'f1' }]; +const Scenario = () => ; + +export default Scenario; diff --git a/apps/server-rendered-app/package.json b/apps/server-rendered-app/package.json index 6d16e565b321b..b8ac7b8287577 100644 --- a/apps/server-rendered-app/package.json +++ b/apps/server-rendered-app/package.json @@ -27,10 +27,10 @@ }, "dependencies": { "@microsoft/load-themed-styles": "^1.10.26", - "@uifabric/icons": "^7.5.2", + "@uifabric/icons": "^7.5.4", "@uifabric/merge-styles": "^7.19.1", "@uifabric/set-version": "^7.0.23", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6", diff --git a/apps/ssr-tests/package.json b/apps/ssr-tests/package.json index 74d4fbf23130e..958abf17a5195 100644 --- a/apps/ssr-tests/package.json +++ b/apps/ssr-tests/package.json @@ -17,10 +17,10 @@ "@types/mocha": "^7.0.2", "@types/webpack-env": "1.15.1", "@uifabric/build": "^7.0.0", - "@uifabric/fabric-website-resources": "^7.8.10", + "@uifabric/fabric-website-resources": "^7.8.12", "just-scripts-utils": "^0.8.3", "mocha": "^3.3.0", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "raw-loader": "^0.5.1", "react": "16.8.6", "react-app-polyfill": "~1.0.1", diff --git a/apps/test-bundles/package.json b/apps/test-bundles/package.json index dab2cdcbd2f13..af0f125366ae3 100644 --- a/apps/test-bundles/package.json +++ b/apps/test-bundles/package.json @@ -30,13 +30,13 @@ }, "dependencies": { "@fluentui/keyboard-key": "^0.2.12", - "@fluentui/react-button": "^0.12.6", - "@fluentui/react-compose": "^0.19.1", - "@fluentui/react-next": "^8.0.0-alpha.108", - "@uifabric/experiments": "^7.32.11", + "@fluentui/react-button": "^0.13.1", + "@fluentui/react-compose": "^0.19.2", + "@fluentui/react-next": "^8.0.0-alpha.110", + "@uifabric/experiments": "^7.32.13", "@uifabric/set-version": "^7.0.23", - "@uifabric/styling": "^7.16.2", - "office-ui-fabric-react": "^7.137.3", + "@uifabric/styling": "^7.16.4", + "office-ui-fabric-react": "^7.137.5", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6", diff --git a/apps/theming-designer/package.json b/apps/theming-designer/package.json index fac238f4af3bc..4529269d6db83 100644 --- a/apps/theming-designer/package.json +++ b/apps/theming-designer/package.json @@ -21,14 +21,14 @@ "@uifabric/build": "^7.0.0" }, "dependencies": { - "@uifabric/react-cards": "^0.112.10", + "@uifabric/react-cards": "^0.112.12", "@uifabric/merge-styles": "^7.19.1", - "@uifabric/example-app-base": "^7.15.10", - "@uifabric/variants": "^7.2.14", + "@uifabric/example-app-base": "^7.15.12", + "@uifabric/variants": "^7.2.16", "@uifabric/set-version": "^7.0.23", - "@uifabric/icons": "^7.5.2", + "@uifabric/icons": "^7.5.4", "@microsoft/load-themed-styles": "^1.10.26", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6", diff --git a/apps/todo-app/package.json b/apps/todo-app/package.json index a2af5dd440e17..5e8e2c42dc08f 100644 --- a/apps/todo-app/package.json +++ b/apps/todo-app/package.json @@ -24,7 +24,7 @@ "@microsoft/load-themed-styles": "^1.10.26", "es6-promise": "^4.1.0", "immutability-helper": "~2.8.1", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6", diff --git a/apps/vr-tests/package.json b/apps/vr-tests/package.json index c166e5c606689..893dfad08e0d9 100644 --- a/apps/vr-tests/package.json +++ b/apps/vr-tests/package.json @@ -14,17 +14,17 @@ "test": "just-scripts test" }, "dependencies": { - "@fluentui/react-button": "^0.12.6", - "@fluentui/react-icons": "^0.3.2", - "@fluentui/react-next": "^8.0.0-alpha.108", - "@fluentui/react-theme-provider": "^0.12.1", - "@fluentui/storybook": "^0.4.13", + "@fluentui/react-button": "^0.13.1", + "@fluentui/react-icons": "^0.3.3", + "@fluentui/react-next": "^8.0.0-alpha.110", + "@fluentui/react-theme-provider": "^0.13.1", + "@fluentui/storybook": "^0.4.15", "@uifabric/example-data": "^7.1.4", - "@uifabric/experiments": "^7.32.11", - "@uifabric/react-cards": "^0.112.10", - "@uifabric/react-hooks": "^7.13.2", + "@uifabric/experiments": "^7.32.13", + "@uifabric/react-cards": "^0.112.12", + "@uifabric/react-hooks": "^7.13.3", "@uifabric/set-version": "^7.0.23", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "react": "16.8.6", "react-app-polyfill": "~1.0.1", "react-dom": "16.8.6", @@ -42,7 +42,7 @@ "@types/react-dom": "16.8.4", "@types/webpack-env": "1.15.1", "@uifabric/build": "^7.0.0", - "@uifabric/date-time": "^7.17.8", + "@uifabric/date-time": "^7.17.10", "awesome-typescript-loader": "^3.2.3", "babel-loader": "^8.0.6", "css-loader": "^3.5.3", diff --git a/apps/vr-tests/src/stories/BreadcrumbNext.stories.tsx b/apps/vr-tests/src/stories/BreadcrumbNext.stories.tsx new file mode 100644 index 0000000000000..69d7942cb64ea --- /dev/null +++ b/apps/vr-tests/src/stories/BreadcrumbNext.stories.tsx @@ -0,0 +1,112 @@ +/*! Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. */ +import * as React from 'react'; +import Screener from 'screener-storybook/src/screener'; +import { storiesOf } from '@storybook/react'; +import { FabricDecoratorTall } from '../utilities'; +import { Breadcrumb } from '@fluentui/react-next'; + +const noOp = () => undefined; + +storiesOf('Breadcrumb', module) + .addDecorator(FabricDecoratorTall) + .addDecorator(story => ( + + {story()} + + )) + .addStory( + 'Root', + () => ( + + ), + { rtl: true }, + ) + .addStory( + 'Button', + () => ( + + ), + { rtl: true }, + ); + +// Stories for hovering over actionable and non-actionable items +storiesOf('Breadcrumb', module) + .addDecorator(FabricDecoratorTall) + .addDecorator(story => ( + + {story()} + + )) + .addStory('Hovering items', () => ( + + )); diff --git a/apps/vr-tests/src/stories/ButtonNext.stories.tsx b/apps/vr-tests/src/stories/ButtonNext.stories.tsx index 3d79f8cb249c5..4ee3d4d198f52 100644 --- a/apps/vr-tests/src/stories/ButtonNext.stories.tsx +++ b/apps/vr-tests/src/stories/ButtonNext.stories.tsx @@ -554,13 +554,13 @@ storiesOf('Button Next - With styled icon from react-icons via tokens', module) {story()} )) - .addStory('Default', () => + +  + + +
  • + +
    + TestText3 +
    +
    + +  + +
  • +
  • + +
    + TestText4 +
    +
    +
  • + + + + + + +`; + +exports[`Breadcrumb rendering renders correctly 1`] = ` +
    +
    +
    +
    +
    +
      +
    1. + +
      + TestText1 +
      +
      + +  + +
    2. +
    3. + +
      + TestText2 +
      +
      + +  + +
    4. +
    5. + +
      + TestText3 +
      +
      + +  + +
    6. +
    7. + +
      + TestText4 +
      +
      +
    8. +
    +
    +
    +
    +
    +
    +`; + +exports[`Breadcrumb rendering renders correctly with custom divider 1`] = ` +
    +
    +
    +
    +
    +
      +
    1. + +
      + TestText1 +
      +
      + + * + +
    2. +
    3. + +
      + TestText2 +
      +
      + + * + +
    4. +
    5. + +
      + TestText3 +
      +
      + + * + +
    6. +
    7. + +
      + TestText4 +
      +
      +
    8. +
    +
    +
    +
    +
    +
    +`; + +exports[`Breadcrumb rendering renders correctly with maxDisplayedItems and overflowIndex 1`] = ` +
    +
    +
    +
    +
    +
      +
    1. + +
      + TestText1 +
      +
      + +  + +
    2. +
    3. + +
    4. +
    +
    +
    +
    +
    +
    +`; + +exports[`Breadcrumb rendering renders correctly with maxDisplayedItems and overflowIndex as 0 1`] = ` +
    +
    +
    +
    +
    +
      +
    1. + +
    2. +
    +
    +
    +
    +
    +
    +`; + +exports[`Breadcrumb rendering renders correctly with overflow 1`] = ` +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + +
      + TestText3 +
      +
      + +  + +
    4. +
    5. + +
      + TestText4 +
      +
      +
    6. +
    +
    +
    +
    +
    +
    +`; + +exports[`Breadcrumb rendering renders correctly with overflow and overflowIndex 1`] = ` +
    +
    +
    +
    +
    +
      +
    1. + +
      + TestText1 +
      +
      + +  + +
    2. +
    3. + + +  + +
    4. +
    5. + +
      + TestText4 +
      +
      +
    6. +
    +
    +
    +
    +
    +
    +`; + +exports[`Breadcrumb renders empty breadcrumb 1`] = ` +
    +
    +
    +
    +
    +
      +
    +
    +
    +
    +
    +`; diff --git a/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbBestPractices.md b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbBestPractices.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbDonts.md b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbDonts.md new file mode 100644 index 0000000000000..349bfe88d7693 --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbDonts.md @@ -0,0 +1 @@ +- Don't use Breadcrumbs as a primary way to navigate an app or site. diff --git a/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbDos.md b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbDos.md new file mode 100644 index 0000000000000..1aef69fc0c9a1 --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbDos.md @@ -0,0 +1 @@ +- Place Breadcrumbs at the top of a page, above a list of items, or above the main content of a page. diff --git a/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbOverview.md b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbOverview.md new file mode 100644 index 0000000000000..cdc7c8407f1ba --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/docs/BreadcrumbOverview.md @@ -0,0 +1,3 @@ +Breadcrumbs should be used as a navigational aid in your app or site. They indicate the current page’s location within a hierarchy and help the user understand where they are in relation to the rest of that hierarchy. They also afford one-click access to higher levels of that hierarchy. + +Breadcrumbs are typically placed, in horizontal form, under the masthead or navigation of an experience, above the primary content area. diff --git a/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Basic.Example.tsx b/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Basic.Example.tsx new file mode 100644 index 0000000000000..37d373be2e1dd --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Basic.Example.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { Breadcrumb, IBreadcrumbItem, IDividerAsProps } from '@fluentui/react-next/lib/Breadcrumb'; +import { Label, ILabelStyles } from '@fluentui/react-next/lib/Label'; +import { TooltipHost } from '@fluentui/react-next/lib/Tooltip'; +import { Icon } from '@fluentui/react-next/lib/Icon'; + +const labelStyles: Partial = { + root: { margin: '10px 0', selectors: { '&:not(:first-child)': { marginTop: 24 } } }, +}; + +const items: IBreadcrumbItem[] = [ + { text: 'Files', key: 'Files', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 1', key: 'f1', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 2', key: 'f2', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 3', key: 'f3', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 4 (non-clickable)', key: 'f4' }, + { text: 'Folder 5', key: 'f5', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 6', key: 'f6', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 7', key: 'f7', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 8', key: 'f8', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 9', key: 'f9', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 10', key: 'f10', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 11', key: 'f11', onClick: _onBreadcrumbItemClicked, isCurrentItem: true }, +]; +const itemsWithHref: IBreadcrumbItem[] = [ + // Normally each breadcrumb would have a unique href, but to make the navigation less disruptive + // in the example, it uses the breadcrumb page as the href for all the items + { text: 'Files', key: 'Files', href: '#/controls/web/breadcrumb' }, + { text: 'Folder 1', key: 'f1', href: '#/controls/web/breadcrumb' }, + { text: 'Folder 2', key: 'f2', href: '#/controls/web/breadcrumb' }, + { text: 'Folder 3', key: 'f3', href: '#/controls/web/breadcrumb' }, + { text: 'Folder 4 (non-clickable)', key: 'f4' }, + { text: 'Folder 5', key: 'f5', href: '#/controls/web/breadcrumb', isCurrentItem: true }, +]; +const itemsWithHeading: IBreadcrumbItem[] = [ + { text: 'Files', key: 'Files', onClick: _onBreadcrumbItemClicked }, + { text: 'Folder 1', key: 'd1', onClick: _onBreadcrumbItemClicked }, + // Generally, only the last item should ever be a heading. + // It would typically be h1 or h2, but we're using h4 here to better fit the structure of the page. + { text: 'Folder 2', key: 'd2', isCurrentItem: true, as: 'h4' }, +]; + +export const BreadcrumbBasicExample: React.FunctionComponent = () => { + return ( +
    + + + + + + + + + + + +
    + ); +}; + +function _onBreadcrumbItemClicked(ev: React.MouseEvent, item: IBreadcrumbItem): void { + console.log(`Breadcrumb item with key "${item.key}" has been clicked.`); +} + +function _getCustomDivider(dividerProps: IDividerAsProps): JSX.Element { + const tooltipText = dividerProps.item ? dividerProps.item.text : ''; + return ( + + + + ); +} + +function _getCustomOverflowIcon(): JSX.Element { + return ; +} diff --git a/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Collapsing.Example.tsx b/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Collapsing.Example.tsx new file mode 100644 index 0000000000000..e6ab2355f394f --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Collapsing.Example.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Breadcrumb, IBreadcrumbItem } from '@fluentui/react-next/lib/Breadcrumb'; +import { Label, ILabelStyles } from '@fluentui/react-next/lib/Label'; + +const labelStyles: Partial = { + root: { margin: '10px 0', selectors: { '&:not(:first-child)': { marginTop: 24 } } }, +}; + +const items: IBreadcrumbItem[] = [ + { text: 'Files', key: 'Files', onClick: _onBreadcrumbItemClicked }, + { text: 'This is folder 1', key: 'f1', onClick: _onBreadcrumbItemClicked }, + { text: 'This is folder 2 with a long name', key: 'f2', onClick: _onBreadcrumbItemClicked }, + { text: 'This is folder 3 long', key: 'f3', onClick: _onBreadcrumbItemClicked }, + { text: 'This is non-clickable folder 4', key: 'f4' }, + { text: 'This is folder 5', key: 'f5', onClick: _onBreadcrumbItemClicked, isCurrentItem: true }, +]; + +export const BreadcrumbCollapsingExample: React.FunctionComponent = () => { + return ( +
    + + + + + + + + +
    + ); +}; + +function _onBreadcrumbItemClicked(ev: React.MouseEvent, item: IBreadcrumbItem): void { + console.log(`Breadcrumb item with key "${item.key}" has been clicked.`); +} diff --git a/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Static.Example.tsx b/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Static.Example.tsx new file mode 100644 index 0000000000000..f3e4e908169f6 --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/examples/Breadcrumb.Static.Example.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Breadcrumb, IBreadcrumbItem } from '@fluentui/react-next/lib/Breadcrumb'; + +const items: IBreadcrumbItem[] = [ + { text: 'Files', key: 'Files', onClick: _onBreadcrumbItemClicked }, + { text: 'This is folder 1', key: 'f1', onClick: _onBreadcrumbItemClicked }, + { text: 'This is folder 2 with a long name', key: 'f2', onClick: _onBreadcrumbItemClicked }, + { text: 'This is folder 3 long', key: 'f3', onClick: _onBreadcrumbItemClicked }, + { text: 'This is non-clickable folder 4', key: 'f4' }, + { text: 'This is folder 5', key: 'f5', onClick: _onBreadcrumbItemClicked, isCurrentItem: true }, +]; + +export const BreadcrumbStaticExample: React.FunctionComponent = () => { + return ( +
    + +
    + ); +}; + +function _onBreadcrumbItemClicked(ev: React.MouseEvent, item: IBreadcrumbItem): void { + console.log(`Breadcrumb item with key "${item.key}" has been clicked.`); +} + +const _returnUndefined = () => undefined; diff --git a/packages/react-next/src/components/Breadcrumb/index.ts b/packages/react-next/src/components/Breadcrumb/index.ts new file mode 100644 index 0000000000000..e68724112b54a --- /dev/null +++ b/packages/react-next/src/components/Breadcrumb/index.ts @@ -0,0 +1,3 @@ +export * from './Breadcrumb'; +export * from './Breadcrumb.base'; +export * from './Breadcrumb.types'; diff --git a/packages/react-next/src/components/ComboBox/ComboBox.test.tsx b/packages/react-next/src/components/ComboBox/ComboBox.test.tsx index b34dfb2914892..54d0b7d25a036 100644 --- a/packages/react-next/src/components/ComboBox/ComboBox.test.tsx +++ b/packages/react-next/src/components/ComboBox/ComboBox.test.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import * as ReactTestUtils from 'react-dom/test-utils'; -import { mount, ReactWrapper } from 'enzyme'; import * as renderer from 'react-test-renderer'; import { KeyCodes } from '../../Utilities'; -import { ComboBox, IComboBoxState } from './ComboBox'; -import { IComboBox, IComboBoxOption, IComboBoxProps } from './ComboBox.types'; +import { ComboBox } from './ComboBox'; +import { IComboBox, IComboBoxOption } from './ComboBox.types'; import { SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/utilities/selectableOption/SelectableOption.types'; -import { expectOne, expectMissing, renderIntoDocument } from '../../common/testUtilities'; +import { renderIntoDocument } from '../../common/testUtilities'; +import { safeCreate } from '@uifabric/test-utilities'; const DEFAULT_OPTIONS: IComboBoxOption[] = [ { key: '1', text: '1' }, @@ -37,11 +37,6 @@ const RUSSIAN_OPTIONS: IComboBoxOption[] = [ const returnUndefined = () => undefined; -type InputElementWrapper = ReactWrapper, unknown>; - -let wrapper: ReactWrapper | undefined; -let domNode: HTMLElement | undefined; - const createNodeMock = (el: React.ReactElement<{}>) => { return { __events__: {}, @@ -49,20 +44,10 @@ const createNodeMock = (el: React.ReactElement<{}>) => { }; describe('ComboBox', () => { - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = undefined; - } - if (domNode) { - try { - ReactDOM.unmountComponentAtNode(domNode.parentElement!); - domNode.parentElement!.removeChild(domNode); - } catch (ex) { - // ignore - } - domNode = undefined; - } + beforeEach(() => { + spyOn(ReactDOM, 'createPortal').and.callFake(element => { + return element; + }); }); it('Renders correctly', () => { @@ -84,37 +69,49 @@ describe('ComboBox', () => { }); it('Can flip between enabled and disabled.', () => { - wrapper = mount(); + safeCreate(, container => { + expect(container.root.findAll(node => node.props.className?.split?.(' ')?.includes?.('is-disabled')).length).toBe( + 0, + ); - expectMissing(wrapper, '.ms-ComboBox.is-disabled'); - expectOne(wrapper, '[data-is-interactable=true]'); + expect( + container.root.findAll(node => typeof node.type === 'string' && node.props['data-is-interactable'] === true) + .length, + ).toBe(1); - wrapper.setProps({ disabled: true }); + renderer.act(() => { + container.update(); + }); - expectOne(wrapper, '.ms-ComboBox.is-disabled'); - expectOne(wrapper, '[data-is-interactable=false]'); + expect(container.root.findAll(node => node.props.className?.split?.(' ')?.includes?.('is-disabled')).length).toBe( + 2, + ); + expect( + container.root.findAll(node => typeof node.type === 'string' && node.props['data-is-interactable'] === false) + .length, + ).toBe(1); + }); }); it('Renders no selected item in default case', () => { - wrapper = mount(); - - expect(wrapper.find('input[role="combobox"]').text()).toEqual(''); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual(''); + }); }); it('Renders a selected item in uncontrolled case', () => { - wrapper = mount(); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement: InputElementWrapper = comboBoxRoot.find('input'); - - expect(inputElement.props().value).toEqual('1'); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual('1'); + }); }); it('Renders a selected item in controlled case', () => { - wrapper = mount(); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement: InputElementWrapper = comboBoxRoot.find('input'); - - expect(inputElement.props().value).toEqual('1'); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual('1'); + }); }); it('Renders a selected item with zero key', () => { @@ -122,10 +119,10 @@ describe('ComboBox', () => { { key: 0, text: 'zero' }, { key: 1, text: 'one' }, ]; - wrapper = mount(); - - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - expect(inputElement.props().value).toEqual('zero'); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual('zero'); + }); }); it('changes to a selected key change the input', () => { @@ -133,13 +130,14 @@ describe('ComboBox', () => { { key: 0, text: 'zero' }, { key: 1, text: 'one' }, ]; - wrapper = mount(); - - expect(wrapper.find('input').props().value).toEqual('zero'); - - wrapper.setProps({ selectedKey: 1 }); - - expect(wrapper.find('input').props().value).toEqual('one'); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual('zero'); + renderer.act(() => { + container.update(); + }); + expect(input.props.value).toEqual('one'); + }); }); it('changes to a selected item on key change', () => { @@ -147,196 +145,230 @@ describe('ComboBox', () => { { key: 0, text: 'zero' }, { key: 1, text: 'one' }, ]; - wrapper = mount(); - - expect(wrapper.find('input').props().value).toEqual('zero'); - - wrapper.setProps({ selectedKey: null }); - - expect(wrapper.find('input').props().value).toEqual(''); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual('zero'); + renderer.act(() => { + container.update(); + }); + expect(input.props.value).toEqual(''); + }); }); it('Renders a placeholder', () => { const placeholder = 'Select an option'; - wrapper = mount(); - - const inputElement = wrapper.find('.ms-ComboBox input').getDOMNode() as HTMLInputElement; - expect(inputElement.placeholder).toEqual(placeholder); - expect(inputElement.value).toEqual(''); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + expect(inputElement.props.placeholder).toEqual(placeholder); + expect(inputElement.props.value).toEqual(''); + }); }); it('Does not automatically add new options when allowFreeform is on in controlled case', () => { - const componentRef = React.createRef(); - wrapper = mount( - , - ); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + + simulateInputEvent(inputElement, 'f'); + simulateKeydown(inputElement, KeyCodes.enter); + + // open combobox + const buttonElement = container.root.findByType('button'); + ReactTestUtils.act(() => { + buttonElement?.props?.onClick(); + }); + + const options = container.root.findAll( + node => + node.props.className && + node.props.className.split(' ').includes('ms-ComboBox-option') && + typeof node.type === 'string', + ); - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - inputElement.simulate('input', { target: { value: 'f' } }); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - expect(((componentRef.current as unknown) as ComboBox).state.currentOptions.length).toEqual(DEFAULT_OPTIONS.length); + expect(options.length).toBe(DEFAULT_OPTIONS.length); + }); }); it('Automatically adds new options when allowFreeform is on in uncontrolled case', () => { - const componentRef = React.createRef(); - wrapper = mount(); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + + simulateInputEvent(inputElement, 'f'); + simulateKeydown(inputElement, KeyCodes.enter); + + // open combobox + const buttonElement = container.root.findByType('button'); + ReactTestUtils.act(() => { + buttonElement?.props?.onClick(); + }); + + const options = container.root.findAll( + node => + node.props.className && + node.props.className.split(' ').includes('ms-ComboBox-option') && + typeof node.type === 'string', + ); - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - inputElement.simulate('input', { target: { value: 'f' } }); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - const currentOptions = ((componentRef.current as unknown) as ComboBox).state.currentOptions; - expect(currentOptions.length).toEqual(DEFAULT_OPTIONS.length + 1); - expect(currentOptions[currentOptions.length - 1].text).toEqual('f'); + expect(options.length).toBe(DEFAULT_OPTIONS.length + 1); + expect(options[options.length - 1].props.title).toEqual('f'); + }); }); it('Renders a default value with options', () => { - wrapper = mount(); - - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - expect(inputElement.props().value).toEqual('1'); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + expect(inputElement.props.value).toEqual('1'); + }); }); it('Renders a default value with no options', () => { - wrapper = mount(); - - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - expect(inputElement.props().value).toEqual('1'); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + expect(inputElement.props.value).toEqual('1'); + }); }); it('Can change items in uncontrolled case', () => { - domNode = renderIntoDocument(); + const ref = React.createRef(); + renderIntoDocument(); - const buttonElement = domNode.querySelector('.ms-ComboBox button')!; + const buttonElement = ref.current?.querySelector('.ms-ComboBox button')!; ReactTestUtils.Simulate.click(buttonElement); - const secondItemElement = document.querySelector('.ms-ComboBox-option[data-index="1"]')!; + const secondItemElement = ref.current?.querySelector('.ms-ComboBox-option[data-index="1"]')!; ReactTestUtils.Simulate.click(secondItemElement); - const inputElement = domNode.querySelector('.ms-ComboBox input') as HTMLInputElement; + const inputElement = ref.current?.querySelector('.ms-ComboBox input') as HTMLInputElement; expect(inputElement.value).toEqual('2'); }); it('Does not automatically change items in controlled case', () => { - domNode = renderIntoDocument(); + const ref = React.createRef(); + renderIntoDocument(); - const buttonElement = domNode.querySelector('.ms-ComboBox button')!; + const buttonElement = ref.current?.querySelector('.ms-ComboBox button')!; ReactTestUtils.Simulate.click(buttonElement); - const secondItemElement = document.querySelector('.ms-ComboBox-option[data-index="1"]')!; + const secondItemElement = ref.current?.querySelector('.ms-ComboBox-option[data-index="1"]')!; ReactTestUtils.Simulate.click(secondItemElement); - const inputElement = domNode.querySelector('.ms-ComboBox input') as HTMLInputElement; + const inputElement = ref.current?.querySelector('.ms-ComboBox input') as HTMLInputElement; expect(inputElement.value).toEqual('1'); }); it('Multiselect does not mutate props', () => { - domNode = renderIntoDocument(); + const ref = React.createRef(); + renderIntoDocument(); - const buttonElement = domNode.querySelector('.ms-ComboBox button')!; + const buttonElement = ref.current?.querySelector('.ms-ComboBox button')!; ReactTestUtils.Simulate.click(buttonElement); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - ReactTestUtils.Simulate.change(buttons[1]); + const buttons = ref.current?.querySelectorAll('.ms-ComboBox-option > input'); + ReactTestUtils.Simulate.change(buttons![1]); expect(!!DEFAULT_OPTIONS[1].selected).toEqual(false); }); it('Can insert text in uncontrolled case with autoComplete and allowFreeform on', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + expect(input.props.value).toBe('Foo'); + }, ); - - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('Foo'); }); it('Can insert text in uncontrolled case with autoComplete on and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + expect(input.props.value).toBe('Foo'); + }, ); - - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('Foo'); }); it('Can insert non latin text in uncontrolled case with autoComplete on and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'п'); + expect(input.props.value).toBe('папа'); + }, ); - - wrapper.find('input').simulate('input', { target: { value: 'п' } }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('папа'); }); it('Can insert text in uncontrolled case with autoComplete off and allowFreeform on', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + expect(input.props.value).toBe('f'); + }, ); - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('f'); }); it('Can insert text in uncontrolled case with autoComplete and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + expect(input.props.value).toBe('One'); + }, ); - wrapper.find('input').simulate('keydown', { which: 'f' }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); }); it('Can insert an empty string in uncontrolled case with autoComplete and allowFreeform on', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe(''); + }, ); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual(''); }); it('Cannot insert an empty string in uncontrolled case with autoComplete on and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe('One'); + }, ); - - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); }); it('Can insert an empty string in uncontrolled case with autoComplete off and allowFreeform on', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe(''); + }, ); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual(''); }); it('Cannot insert an empty string in uncontrolled case with autoComplete and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe('One'); + }, ); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); }); // jeremy @@ -345,17 +377,16 @@ describe('ComboBox', () => { 'Can insert an empty string after removing a pending value in uncontrolled case ' + 'with autoComplete and allowFreeform on', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe(''); + }, ); - - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = 'f'; - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual(''); }, ); @@ -363,17 +394,16 @@ describe('ComboBox', () => { 'Cannot insert an empty string after removing a pending value in uncontrolled case ' + 'with autoComplete on and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe('Foo'); + }, ); - - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = 'f'; - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('Foo'); }, ); @@ -381,17 +411,16 @@ describe('ComboBox', () => { 'Can insert an empty string after removing a pending value in uncontrolled case ' + 'with autoComplete off and allowFreeform on', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe(''); + }, ); - - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = 'f'; - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual(''); }, ); @@ -399,111 +428,127 @@ describe('ComboBox', () => { 'Cannot insert an empty string after removing a pending value in uncontrolled case ' + 'with autoComplete and allowFreeform off', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'f'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); + expect(input.props.value).toBe('One'); + }, ); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = 'f'; - wrapper.find('input').simulate('input', { target: { value: 'f' } }); - ((wrapper.find('input').instance() as unknown) as HTMLInputElement).value = ''; - wrapper.find('input').simulate('input', { target: { value: '' } }); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); }, ); it('Can change selected option with keyboard', () => { - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.down }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('Foo'); + safeCreate(, container => { + const input = container.root.findByType('input'); + simulateKeydown(input, KeyCodes.down); + expect(input.props.value).toEqual('Foo'); + }); }); it('Can change selected option with keyboard, looping from top to bottom', () => { - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.up }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('Bar'); + safeCreate(, container => { + const input = container.root.findByType('input'); + simulateKeydown(input, KeyCodes.up); + expect(input.props.value).toEqual('Bar'); + }); }); it('Can change selected option with keyboard, looping from bottom to top', () => { - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.down }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); + safeCreate(, container => { + const input = container.root.findByType('input'); + simulateKeydown(input, KeyCodes.down); + expect(input.props.value).toEqual('One'); + }); }); it('Can change selected option with keyboard, looping from top to bottom', () => { - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.up }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('Bar'); + safeCreate(, container => { + const input = container.root.findByType('input'); + simulateKeydown(input, KeyCodes.up); + expect(input.props.value).toEqual('Bar'); + }); }); it('Cannot insert text while disabled', () => { - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.a }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); + safeCreate(, container => { + const input = container.root.findByType('input'); + simulateKeydown(input, KeyCodes.a); + expect(input.props.value).toEqual('One'); + }); }); it('Cannot change selected option with keyboard while disabled', () => { - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.down }); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('One'); + safeCreate(, container => { + const input = container.root.findByType('input'); + simulateKeydown(input, KeyCodes.down); + expect(input.props.value).toEqual('One'); + }); }); it('Cannot expand the menu when clicking on the input while disabled', () => { - wrapper = mount(); - wrapper.find('input').simulate('click'); - expect(wrapper.find('.is-opened').length).toEqual(0); + safeCreate(, container => { + const input = container.root.findByType('input'); + input.props.onClick({}); + expect(findNodeWithClass(container, 'is-opened', true).length).toEqual(0); + }); }); it('Cannot expand the menu when clicking on the button while disabled', () => { - wrapper = mount(); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const buttonElement = wrapper.find('button'); - buttonElement.simulate('click'); - expect(comboBoxRoot.find('.is-opened').length).toEqual(0); + safeCreate(, container => { + const buttonElement = container.root.findByType('button'); + buttonElement.props.onClick({}); + expect(findNodeWithClass(container, 'is-opened', true).length).toEqual(0); + }); }); it('Call onMenuOpened when clicking on the button', () => { const onMenuOpenMock = jest.fn(); - wrapper = mount(); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const buttonElement = comboBoxRoot.find('button'); - buttonElement.simulate('click'); - expect(onMenuOpenMock.mock.calls.length).toBe(1); + safeCreate( + , + container => { + const buttonElement = container.root.findByType('button'); + buttonElement.props.onClick({}); + expect(onMenuOpenMock.mock.calls.length).toBe(1); + }, + ); }); it('Opens on focus when openOnKeyboardFocus is true', () => { const onMenuOpenMock = jest.fn(); - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + + input.props.onFocus?.(); + input.props.onKeyUp?.({}); + expect(onMenuOpenMock.mock.calls.length).toBe(1); + }, ); - const comboBoxRoot = wrapper.find('.ms-ComboBox-Input').find('input'); - comboBoxRoot.simulate('focus'); - comboBoxRoot.simulate('keyup'); - expect(onMenuOpenMock.mock.calls.length).toBe(1); }); it('Call onMenuOpened when touch start on the input', () => { - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + + input.props.onTouchStart?.(); + input.props.onClick?.(); + + expect(findNodeWithClass(container, 'is-open', true).length).toEqual(1); + }, + { + createNodeMock: element => + element.type === 'div' ? { addEventListener: jest.fn(), removeEventListener: jest.fn() } : undefined, + }, ); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input'); - - // in a normal scenario, when we do a touchstart we would also cause a - // click event to fire. This doesn't happen in the simulator so we're - // manually adding this in. - inputElement.simulate('touchstart'); - inputElement.simulate('click'); - - expect(wrapper.find('.is-open').length).toEqual(1); }); it('onPendingValueChanged triggers for all indexes', () => { @@ -513,20 +558,23 @@ describe('ComboBox', () => { indexSeen.push(index); } }; - wrapper = mount( + safeCreate( , + container => { + const input = container.root.findByType('input'); + + simulateInputEvent(input, 'f'); + simulateKeydown(input, KeyCodes.down); + simulateKeydown(input, KeyCodes.up); + expect(indexSeen).toContain(0); + expect(indexSeen).toContain(1); + }, ); - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - inputElement.simulate('input', { target: { value: 'f' } }); - inputElement.simulate('keydown', { which: KeyCodes.down }); - inputElement.simulate('keydown', { which: KeyCodes.up }); - expect(indexSeen).toContain(0); - expect(indexSeen).toContain(1); }); it('onPendingValueChanged is called with an empty string when the input is cleared', () => { @@ -535,89 +583,83 @@ describe('ComboBox', () => { changedValue = value; }; - const baseNode = document.createElement('div'); - document.body.appendChild(baseNode); - - const component = ReactDOM.render( + safeCreate( , - baseNode, - ); + container => { + const input = container.root.findByType('input'); - const input = (ReactDOM.findDOMNode((component as unknown) as React.ReactInstance) as Element).querySelector( - 'input', - ) as HTMLInputElement; - if (input === null) { - throw new Error('ComboBox input element is null'); - } + simulateInputEvent(input, 'a'); + expect(changedValue).toEqual('a'); - // Simulate typing one character into the ComboBox input - input.value = 'a'; - ReactTestUtils.Simulate.input(input); - expect(changedValue).toEqual('a'); - - // Simulate clearing the ComboBox input - input.value = ''; - ReactTestUtils.Simulate.input(input); - expect(changedValue).toEqual(''); + simulateInputEvent(input, ''); + expect(changedValue).toEqual(''); + }, + ); }); it('suggestedDisplayValue is called undefined when the selected input is cleared', () => { - const componentRef = React.createRef(); - wrapper = mount(); - - // SelectedKey is still the same - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - expect(inputElement.props().value).toEqual('1'); - - // SelectedKey is set to null - wrapper.setProps({ selectedKey: null }); - expect(wrapper.find('input').props().value).toEqual(''); - - const suggestedDisplay = ((componentRef.current as unknown) as ComboBox).state.suggestedDisplayValue; - expect(suggestedDisplay).toEqual(undefined); + safeCreate(, container => { + const input = container.root.findByType('input'); + expect(input.props.value).toEqual('1'); + + renderer.act(() => { + container.update(); + }); + expect(input.props.value).toEqual(''); + }); }); it('Can type a complete option with autocomplete and allowFreeform on and submit it', () => { - let updatedOption; - let updatedIndex; + let updatedOption: IComboBoxOption | undefined; + let updatedIndex: number | undefined; const onChange = jest.fn((event: React.FormEvent, option?: IComboBoxOption, index?: number) => { updatedOption = option; updatedIndex = index; }); const initialOption = { key: '1', text: 'Text' }; - wrapper = mount(); - const inputElement: InputElementWrapper = wrapper.find('input'); - inputElement.simulate('input', { target: { value: 't' } }); - inputElement.simulate('input', { target: { value: 'e' } }); - inputElement.simulate('input', { target: { value: 'x' } }); - inputElement.simulate('input', { target: { value: 't' } }); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - expect(onChange).toHaveBeenCalledTimes(1); - expect(updatedOption).toEqual(initialOption); - expect(updatedIndex).toEqual(0); - - wrapper.update(); - expect(wrapper.find('.ms-ComboBox input').props().value).toEqual('Text'); + safeCreate( + , + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 't'); + simulateInputEvent(input, 'te'); + simulateInputEvent(input, 'tex'); + simulateInputEvent(input, 'text'); + simulateKeydown(input, KeyCodes.enter); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(updatedOption).toEqual(initialOption); + expect(updatedIndex).toEqual(0); + + expect(input.props.value).toEqual('Text'); + }, + ); }); it('merges callout classNames', () => { - domNode = renderIntoDocument( + safeCreate( , + container => { + const buttonElement = container.root.find( + node => node.type === 'button' && node.props?.className?.includes?.('ms-ComboBox'), + ); + + ReactTestUtils.act(() => { + buttonElement?.props?.onClick?.(); + }); + + const callout = container.root.find(node => node.props?.className?.split?.(' ').includes?.('ms-Callout')); + expect(callout).toBeDefined(); + expect(callout.props.className.includes('ms-ComboBox-callout')).toBeTruthy(); + expect(callout.props.className.includes('foo')).toBeTruthy(); + }, ); - - const buttonElement = domNode.querySelector('.ms-ComboBox button')!; - ReactTestUtils.Simulate.click(buttonElement); - - const callout = document.querySelector('.ms-Callout')!; - expect(callout).toBeDefined(); - expect(callout.classList.contains('ms-ComboBox-callout')).toBeTruthy(); - expect(callout.classList.contains('foo')).toBeTruthy(); }); it('Can clear text in controlled case with autoComplete off and allowFreeform on', () => { - let updatedText; - wrapper = mount( + let updatedText: string | undefined; + safeCreate( { updatedText = value; }} />, - ); + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, ''); + simulateKeydown(input, KeyCodes.enter); - const input = wrapper.find('input'); - ((input.instance() as unknown) as HTMLInputElement).value = ''; - input.simulate('input', { target: { value: '' } }); - input.simulate('keydown', { which: KeyCodes.enter }); - wrapper.update(); - - expect(updatedText).toEqual(''); + expect(updatedText).toEqual(''); + }, + ); }); it('Can clear text in controlled case with autoComplete off and allowFreeform on', () => { - let updatedText; - wrapper = mount( + let updatedText: string | undefined; + safeCreate( { updatedText = value; }} />, + container => { + const input = container.root.findByType('input'); + simulateInputEvent(input, 'ab'); + simulateKeydown(input, KeyCodes.backspace); + simulateInputEvent(input, 'a'); + simulateKeydown(input, KeyCodes.backspace); + simulateInputEvent(input, ''); + expect(input.props.value).toEqual(''); + simulateKeydown(input, KeyCodes.enter); + expect(updatedText).toEqual(''); + }, ); - - const input = wrapper.find('input'); - ((input.instance() as unknown) as HTMLInputElement).value = 'ab'; - input.simulate('input', { target: { value: 'ab' } }); - input.simulate('keydown', { which: KeyCodes.backspace }); - input.simulate('input', { target: { value: 'a' } }); - input.simulate('keydown', { which: KeyCodes.backspace }); - wrapper.update(); - - ((input.instance() as unknown) as HTMLInputElement).value = ''; - input.simulate('input', { target: { value: '' } }); - wrapper.update(); - expect(((input.instance() as unknown) as HTMLInputElement).value).toEqual(''); - input.simulate('keydown', { which: KeyCodes.enter }); - - expect(updatedText).toEqual(''); }); - it('in multiSelect mode, selectedIndices are correct after performing multiple selections using mouse click', () => { - const comboBoxRef = React.createRef(); - wrapper = mount(); + //it('in multiSelect mode, selectedIndices are correct after performing multiple selections using mouse click', () => + // { + // const comboBoxRef = React.createRef(); + // wrapper = mount(); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input'); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); + // const comboBoxRoot = wrapper.find('.ms-ComboBox'); + // const inputElement = comboBoxRoot.find('input'); + // inputElement.simulate('keydown', { which: KeyCodes.enter }); + // const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - ReactTestUtils.Simulate.change(buttons[0]); - ReactTestUtils.Simulate.change(buttons[2]); - ReactTestUtils.Simulate.change(buttons[1]); + // ReactTestUtils.Simulate.change(buttons[0]); + // ReactTestUtils.Simulate.change(buttons[2]); + // ReactTestUtils.Simulate.change(buttons[1]); - expect(((comboBoxRef.current as unknown) as ComboBox).state.selectedIndices).toEqual([0, 2, 1]); - }); + // expect(((comboBoxRef.current as unknown) as ComboBox).state.selectedIndices).toEqual([0, 2, 1]); + // }); it('in multiSelect mode, defaultselected keys produce correct display input', () => { - const comboBoxRef = React.createRef(); - wrapper = mount( + safeCreate( , + container => { + const comboBoxRoot = findNodeWithClass(container, 'ms-ComboBox'); + const inputElement = comboBoxRoot.findByType('input'); + const caretElement = findNodeWithClass(container, 'ms-ComboBox-CaretDown-button'); + renderer.act(() => { + caretElement.props?.onClick?.(); + }); + renderer.act(() => { + inputElement.props.onBlur?.({}); + }); + const button = findNodeWithClass(container, 'ms-ComboBox-option', true) + .filter(node => node.props.className.includes('ms-Checkbox')) + .map(node => node.findByType('input')); + renderer.act(() => { + button[2]?.props?.onChange?.({ persist: jest.fn() }); + }); + renderer.act(() => { + button[0]?.props?.onChange?.({ persist: jest.fn() }); + }); + const compare = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[2]].map(({ text }) => text).join(', '); + + expect(inputElement.props.value).toEqual(compare); + }, ); - - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input'); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - - ReactTestUtils.Simulate.change(buttons[2]); - ReactTestUtils.Simulate.change(buttons[0]); - const compare = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[2]].reduce((previous: string, current: IComboBoxOption) => { - if (previous !== '') { - return previous + ', ' + current.text; - } - return current.text; - }, ''); - - expect(((inputElement.instance() as unknown) as HTMLInputElement).value).toEqual(compare); }); it('in multiSelect mode, input has correct value', () => { - const comboBoxRef = React.createRef(); - wrapper = mount(); - - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input'); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - - ReactTestUtils.Simulate.change(buttons[0]); - ReactTestUtils.Simulate.change(buttons[2]); - const compare = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[2]].reduce((previous: string, current: IComboBoxOption) => { - if (previous !== '') { - return previous + ', ' + current.text; - } - return current.text; - }, ''); - - expect(((inputElement.instance() as unknown) as HTMLInputElement).value).toEqual(compare); + safeCreate(, container => { + const comboBoxRoot = findNodeWithClass(container, 'ms-ComboBox'); + const inputElement = comboBoxRoot.findByType('input'); + const caretElement = findNodeWithClass(container, 'ms-ComboBox-CaretDown-button'); + renderer.act(() => { + caretElement.props?.onClick?.(); + }); + renderer.act(() => { + inputElement.props.onBlur?.({}); + }); + const button = findNodeWithClass(container, 'ms-ComboBox-option', true) + .filter(node => node.props.className.includes('ms-Checkbox')) + .map(node => node.findByType('input')); + renderer.act(() => { + button[0]?.props?.onChange?.({ persist: jest.fn() }); + }); + renderer.act(() => { + button[2]?.props?.onChange?.({ persist: jest.fn() }); + }); + const compare = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[2]].map(({ text }) => text).join(', '); + + expect(inputElement.props.value).toEqual(compare); + }); }); it('in multiSelect mode, input has correct value when multiSelectDelimiter specified', () => { - const comboBoxRef = React.createRef(); - wrapper = mount( - , - ); - - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input'); - inputElement.simulate('keydown', { which: KeyCodes.enter }); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - - ReactTestUtils.Simulate.change(buttons[0]); - ReactTestUtils.Simulate.change(buttons[2]); - const compare = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[2]].reduce((previous: string, current: IComboBoxOption) => { - if (previous !== '') { - return previous + '; ' + current.text; - } - return current.text; - }, ''); - - expect(((inputElement.instance() as unknown) as HTMLInputElement).value).toEqual(compare); + safeCreate(, container => { + const comboBoxRoot = findNodeWithClass(container, 'ms-ComboBox'); + const inputElement = comboBoxRoot.findByType('input'); + const caretElement = findNodeWithClass(container, 'ms-ComboBox-CaretDown-button'); + renderer.act(() => { + caretElement.props?.onClick?.(); + }); + renderer.act(() => { + inputElement.props.onBlur?.({}); + }); + const button = findNodeWithClass(container, 'ms-ComboBox-option', true) + .filter(node => node.props.className.includes('ms-Checkbox')) + .map(node => node.findByType('input')); + renderer.act(() => { + button[0]?.props?.onChange?.({ persist: jest.fn() }); + }); + renderer.act(() => { + button[2]?.props?.onChange?.({ persist: jest.fn() }); + }); + const compare = [DEFAULT_OPTIONS[0], DEFAULT_OPTIONS[2]].map(({ text }) => text).join('; '); + + expect(inputElement.props.value).toEqual(compare); + }); }); it('in multiSelect mode, optional onItemClick callback invoked per option select', () => { const onItemClickMock = jest.fn(); - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - - ReactTestUtils.Simulate.change(buttons[0]); - ReactTestUtils.Simulate.change(buttons[1]); - ReactTestUtils.Simulate.change(buttons[2]); - - expect(onItemClickMock).toHaveBeenCalledTimes(3); + safeCreate(, container => { + const caretElement = findNodeWithClass(container, 'ms-ComboBox-CaretDown-button'); + renderer.act(() => { + caretElement?.props?.onClick?.(); + }); + const button = findNodeWithClass(container, 'ms-ComboBox-option', true); + renderer.act(() => { + button[0]?.props?.onClick?.({ persist: jest.fn() }); + button[1]?.props?.onClick?.({ persist: jest.fn() }); + button[2]?.props?.onClick?.({ persist: jest.fn() }); + }); + expect(onItemClickMock).toHaveBeenCalledTimes(3); + }); }); it('invokes optional onItemClick callback on option select', () => { const onItemClickMock = jest.fn(); - wrapper = mount(); - wrapper.find('input').simulate('keydown', { which: KeyCodes.enter }); - const buttons = document.querySelectorAll('.ms-ComboBox-option'); - - (buttons[0] as HTMLInputElement).click(); - - expect(onItemClickMock).toHaveBeenCalledTimes(1); + safeCreate(, container => { + const caretElement = findNodeWithClass(container, 'ms-ComboBox-CaretDown-button'); + ReactTestUtils.act(() => { + caretElement?.props?.onClick?.(); + }); + const button = findNodeWithClass(container, 'ms-ComboBox-option', true); + renderer.act(() => { + button?.[0]?.props?.onClick?.({ persist: jest.fn() }); + }); + expect(onItemClickMock).toHaveBeenCalledTimes(1); + }); }); it('allows adding a custom aria-describedby id to the input', () => { - const comboBoxRef = React.createRef(); const customId = 'customAriaDescriptionId'; - wrapper = mount(); - - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input').getDOMNode(); - const ariaDescribedByAttribute = inputElement.getAttribute('aria-describedby'); - expect(ariaDescribedByAttribute).toMatch(new RegExp('\\b' + customId + '\\b')); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + expect((inputElement.props as React.HTMLAttributes)['aria-describedby']).toMatch( + new RegExp('\\b' + customId + '\\b'), + ); + }); }); it('adds aria-required to the DOM when the required prop is set to true', () => { - const comboBoxRef = React.createRef(); - wrapper = mount(); - - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input').getDOMNode(); - const ariaRequiredAttribute = inputElement.getAttribute('aria-required'); - expect(ariaRequiredAttribute).toEqual('true'); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + expect((inputElement.props as React.HTMLAttributes)['aria-required']).toBe(true); + }); }); it('does not add aria-required to the DOM when the required prop is not set', () => { - const comboBoxRef = React.createRef(); - wrapper = mount(); - - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - const inputElement = comboBoxRoot.find('input').getDOMNode(); - const ariaRequiredAttribute = inputElement.getAttribute('aria-required'); - expect(ariaRequiredAttribute).toBeNull(); + safeCreate(, container => { + const inputElement = container.root.findByType('input'); + expect((inputElement.props as React.HTMLAttributes)['aria-required']).toBeUndefined(); + }); }); it('test persistMenu, callout should exist before and after opening menu', () => { const onMenuOpenMock = jest.fn(); const onMenuDismissedMock = jest.fn(); - wrapper = mount( + safeCreate( { onMenuOpen={onMenuOpenMock} onMenuDismissed={onMenuDismissedMock} />, - ); - const comboBoxRoot = wrapper.find('.ms-ComboBox'); - - // Find menu - const calloutBeforeOpen = document.querySelector('.ms-Callout')!; - expect(calloutBeforeOpen).toBeDefined(); - expect(calloutBeforeOpen.classList.contains('ms-ComboBox-callout')).toBeTruthy(); - - // Open combobox - const buttonElement = comboBoxRoot.find('button'); - buttonElement.simulate('click'); - expect(onMenuOpenMock.mock.calls.length).toBe(1); - - // Close combobox - buttonElement.simulate('click'); - expect(onMenuDismissedMock.mock.calls.length).toBe(1); - - // Ensure menu is still there - const calloutAfterClose = document.querySelector('.ms-Callout')!; - expect(calloutAfterClose).toBeDefined(); - expect(calloutAfterClose.classList.contains('ms-ComboBox-callout')).toBeTruthy(); - }); - - // Adds currentPendingValue to options and makes it selected onBlur - // if allowFreeFrom is true for multiselect with default selected values - it('adds currentPendingValue to options and selects if multiSelected with default values', () => { - const componentRef = React.createRef(); - const comboBoxOption: IComboBoxOption = { - key: 'ManuallyEnteredValue', - text: 'ManuallyEnteredValue', - selected: true, - }; - wrapper = mount( - , - ); - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS], [0, 1, 2]); - inputElement.simulate('focus'); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS], [0, 1, 2]); - inputElement.simulate('input', { target: { value: 'ManuallyEnteredValue' } }); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS], [0, 1, 2]); - inputElement.simulate('blur'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [0, 1, 2, 3]); - }); - - // Adds currentPendingValue to options and makes it selected onBlur - // if allowFreeForm is true for multiSelect with no default value selected - it('adds currentPendingValue to options and selects for multiSelect with no default value', () => { - const componentRef = React.createRef(); - const comboBoxOption: IComboBoxOption = { - key: 'ManuallyEnteredValue', - text: 'ManuallyEnteredValue', - selected: true, - }; - wrapper = mount( - , - ); - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS], []); - inputElement.simulate('focus'); - inputElement.simulate('keyup', { which: 10 }); - expect(((componentRef.current as unknown) as ComboBox).state.focusState).toEqual('focused'); - _verifyStateVariables(componentRef, 'focused', [...DEFAULT_OPTIONS], []); - inputElement.simulate('input', { target: { value: 'ManuallyEnteredValue' } }); - _verifyStateVariables(componentRef, 'focused', [...DEFAULT_OPTIONS], []); - inputElement.simulate('blur'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [3]); - - inputElement.simulate('focus'); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [3]); - inputElement.simulate('input', { target: { value: 'ManuallyEnteredValue' } }); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [3]); - - // This should toggle the checkbox off. With multi-select the currentPendingValue is not reset on input change - // because it would break keyboard accessibility - wrapper.find('.ms-ComboBox button').simulate('click'); - const buttons = document.querySelectorAll('.ms-ComboBox-option > input'); - ReactTestUtils.Simulate.change(buttons[3]); - - // with 'ManuallyEnteredValue' still in the input, on blur it should toggle the check back to on - inputElement.simulate('blur'); - _verifyStateVariables( - componentRef, - 'none', - [ - ...DEFAULT_OPTIONS, - { - ...comboBoxOption, - selected: true, - }, - ], - [3], + container => { + const comboBoxRoot = findNodeWithClass(container, 'ms-ComboBox'); + + // Find menu + const calloutBeforeOpen = findNodeWithClass(container, 'ms-Callout'); + expect(calloutBeforeOpen).toBeDefined(); + expect(calloutBeforeOpen?.props?.className?.includes?.('ms-ComboBox-callout')).toBeTruthy(); + + // Open combobox + const buttonElement = comboBoxRoot.findByType('button'); + ReactTestUtils.act(() => { + buttonElement?.props?.onClick(); + }); + expect(onMenuOpenMock.mock.calls.length).toBe(1); + + // Close combobox + ReactTestUtils.act(() => { + buttonElement?.props?.onClick(); + }); + expect(onMenuDismissedMock.mock.calls.length).toBe(1); + + // Ensure menu is still there + const calloutAfterClose = findNodeWithClass(container, 'ms-Callout'); + expect(calloutAfterClose).toBeDefined(); + expect(calloutBeforeOpen?.props?.className?.includes?.('ms-ComboBox-callout')).toBeTruthy(); + }, ); }); - - // adds currentPendingValue to options and makes it selected onBlur - // if allowFreeForm is true for singleSelect - it('adds currentPendingValue to options and selects for singleSelect', () => { - const componentRef = React.createRef(); - const comboBoxOption: IComboBoxOption = { - key: 'ManuallyEnteredValue', - text: 'ManuallyEnteredValue', - }; - wrapper = mount(); - const inputElement: InputElementWrapper = wrapper.find('.ms-ComboBox input'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS], []); - inputElement.simulate('focus'); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS], []); - inputElement.simulate('input', { target: { value: 'ManuallyEnteredValue' } }); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS], []); - inputElement.simulate('blur'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [3]); - - inputElement.simulate('focus'); - _verifyStateVariables(componentRef, 'focusing', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [3]); - const buttonElement = wrapper.find('.ms-ComboBox button')!; - buttonElement.simulate('click'); - const secondItem = document.querySelector('.ms-ComboBox-option[data-index="2"]')!; - ReactTestUtils.Simulate.click(secondItem); - - inputElement.simulate('blur'); - _verifyStateVariables(componentRef, 'none', [...DEFAULT_OPTIONS, { ...comboBoxOption }], [2]); - }); - - function _verifyStateVariables( - componentRef: React.RefObject, - focusState: 'none' | 'focused' | 'focusing', - currentOptions: IComboBoxOption[], - selectedIndices?: number[], - ): void { - expect((componentRef.current as ComboBox).state.focusState).toEqual(focusState); - expect((componentRef.current as ComboBox).state.selectedIndices).toEqual(selectedIndices); - expect((componentRef.current as ComboBox).state.currentOptions).toEqual(currentOptions); - } }); + +function simulateInputEvent(input: renderer.ReactTestInstance, value: string) { + renderer.act(() => { + input.props.onInput?.({ target: { value }, nativeEvent: { isComposing: false } }); + }); +} + +function simulateKeydown(input: renderer.ReactTestInstance, which: KeyCodes) { + renderer.act(() => { + input.props.onKeyDown?.({ + which, + nativeEvent: { isComposing: false }, + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + persist: jest.fn(), + }); + }); +} + +function findNodeWithClass(container: renderer.ReactTestRenderer, className: string): renderer.ReactTestInstance; +function findNodeWithClass( + container: renderer.ReactTestRenderer, + className: string, + findAll: true, +): renderer.ReactTestInstance[]; +function findNodeWithClass( + container: renderer.ReactTestRenderer, + className: string, + findAll?: true, +): renderer.ReactTestInstance | renderer.ReactTestInstance[] { + return container.root[findAll ? 'findAll' : 'find'](node => node.props?.className?.split(' ')?.includes?.(className)); +} diff --git a/packages/react-next/src/components/ComboBox/ComboBox.tsx b/packages/react-next/src/components/ComboBox/ComboBox.tsx index d9691c9501c70..b9dafc4961cc9 100644 --- a/packages/react-next/src/components/ComboBox/ComboBox.tsx +++ b/packages/react-next/src/components/ComboBox/ComboBox.tsx @@ -33,23 +33,16 @@ import { } from 'office-ui-fabric-react/lib/utilities/selectableOption/index'; import { BaseButton, Button, CommandButton, IButtonStyles, IconButton } from '../../compat/Button'; import { ICalloutProps } from '../../Callout'; +import { useMergedRefs } from '@uifabric/react-hooks'; +import { getPropsWithDefaults } from '../../utilities/index'; export interface IComboBoxState { /** The open state */ isOpen?: boolean; - /** The currently selected indices */ - selectedIndices?: number[]; - /** The focused state of the comboBox */ focusState?: 'none' | 'focused' | 'focusing'; - /** This value is used for the autocomplete hint value */ - suggestedDisplayValue?: string; - - /** The options currently available for the callout */ - currentOptions: IComboBoxOption[]; - /** * When taking input, this will store the index that the options input matches * (-1 if no input or match) @@ -104,34 +97,114 @@ interface IComboBoxOptionWrapperProps extends IComboBoxOption { } /** - * Internal class that is used to wrap all ComboBox options. + * Internal component that is used to wrap all ComboBox options. * This is used to customize when we want to rerender components, * so we don't rerender every option every time render is executed. */ -class ComboBoxOptionWrapper extends React.Component { - public render(): React.ReactNode { - return this.props.render(); - } - - public shouldComponentUpdate(newProps: IComboBoxOptionWrapperProps): boolean { +const ComboBoxOptionWrapper = React.memo( + ({ render }: IComboBoxOptionWrapperProps) => render(), + ( + { render: oldRender, ...oldProps }: IComboBoxOptionWrapperProps, + { render: newRender, ...newProps }: IComboBoxOptionWrapperProps, + ) => // The render function will always be different, so we ignore that prop - return !shallowCompare({ ...this.props, render: undefined }, { ...newProps, render: undefined }); - } -} + shallowCompare(oldProps, newProps), +); const COMPONENT_NAME = 'ComboBox'; +const DEFAULT_PROPS: Partial = { + options: [], + allowFreeform: false, + autoComplete: 'on', + buttonIconProps: { iconName: 'ChevronDown' }, +}; + +function useOptionsState({ options, defaultSelectedKey, selectedKey }: IComboBoxProps) { + /** The currently selected indices */ + const [selectedIndices, setSelectedIndices] = React.useState(() => + getSelectedIndices(options, buildDefaultSelectedKeys(defaultSelectedKey, selectedKey)), + ); + /** The options currently available for the callout */ + const [currentOptions, setCurrentOptions] = React.useState(options); + /** This value is used for the autocomplete hint value */ + const [suggestedDisplayValue, setSuggestedDisplayValue] = React.useState(); -@customizable('ComboBox', ['theme', 'styles'], true) -export class ComboBox extends React.Component { - public static defaultProps: IComboBoxProps = { - options: [], - allowFreeform: false, - autoComplete: 'on', - buttonIconProps: { iconName: 'ChevronDown' }, - }; + React.useEffect(() => { + if (selectedKey !== undefined) { + const selectedKeys: string[] | number[] = buildSelectedKeys(selectedKey); + const indices: number[] = getSelectedIndices(options, selectedKeys); - private _root = React.createRef(); + setSelectedIndices(indices); + } + setCurrentOptions(options); + }, [options, selectedKey]); + + React.useEffect(() => { + if (selectedKey === null) { + setSuggestedDisplayValue(undefined); + } + }, [selectedKey]); + + return [ + selectedIndices, + setSelectedIndices, + currentOptions, + setCurrentOptions, + suggestedDisplayValue, + setSuggestedDisplayValue, + ] as const; +} + +export const ComboBox: React.FunctionComponent = React.forwardRef( + (propsWithoutDefaults: IComboBoxProps, forwardedRef: React.Ref) => { + const { ref, ...props } = getPropsWithDefaults(DEFAULT_PROPS, propsWithoutDefaults); + const rootRef = React.useRef(null); + + const mergedRootRef = useMergedRefs(rootRef, forwardedRef); + + const [ + selectedIndices, + setSelectedIndices, + currentOptions, + setCurrentOptions, + suggestedDisplayValue, + setSuggestedDisplayValue, + ] = useOptionsState(props); + + return ( + + ); + }, +); +ComboBox.displayName = COMPONENT_NAME; + +interface IComboBoxInternalProps extends Omit { + hoisted: { + mergedRootRef: React.Ref; + rootRef: React.RefObject; + selectedIndices: number[]; + currentOptions: IComboBoxOption[]; + suggestedDisplayValue?: string; + setSelectedIndices: React.Dispatch>; + setCurrentOptions: React.Dispatch>; + setSuggestedDisplayValue: React.Dispatch>; + }; +} +@customizable('ComboBox', ['theme', 'styles'], true) +class ComboBoxInternal extends React.Component { /** The input aspect of the comboBox */ private _autofill = React.createRef(); @@ -182,7 +255,7 @@ export class ComboBox extends React.Component { private _async: Async; private _events: EventGroup; - constructor(props: IComboBoxProps) { + constructor(props: IComboBoxInternalProps) { super(props); initializeComponentRef(this); @@ -197,24 +270,15 @@ export class ComboBox extends React.Component { }); this._id = props.id || getId('ComboBox'); - const selectedKeys: string[] | number[] = this._buildDefaultSelectedKeys( - props.defaultSelectedKey, - props.selectedKey, - ); this._isScrollIdle = true; this._processingTouch = false; this._gotMouseMove = false; this._processingClearPendingInfo = false; - const initialSelectedIndices: number[] = this._getSelectedIndices(props.options, selectedKeys); - this.state = { isOpen: false, - selectedIndices: initialSelectedIndices, focusState: 'none', - suggestedDisplayValue: undefined, - currentOptions: this.props.options, currentPendingValueValidIndex: -1, currentPendingValue: undefined, currentPendingValueValidIndexOnHover: HoverStatus.default, @@ -225,7 +289,7 @@ export class ComboBox extends React.Component { * All selected options */ public get selectedOptions(): IComboBoxOption[] { - const { currentOptions, selectedIndices } = this.state; + const { currentOptions, selectedIndices } = this.props.hoisted; return getAllSelectedOptions(currentOptions, selectedIndices!); } @@ -243,32 +307,15 @@ export class ComboBox extends React.Component { } } - public UNSAFE_componentWillReceiveProps(newProps: IComboBoxProps): void { - // Update the selectedIndex and currentOptions state if - // the selectedKey, value, or options have changed - if ( - newProps.selectedKey !== this.props.selectedKey || - newProps.text !== this.props.text || - newProps.options !== this.props.options - ) { - const selectedKeys: string[] | number[] = this._buildSelectedKeys(newProps.selectedKey); - const indices: number[] = this._getSelectedIndices(newProps.options, selectedKeys); - - this.setState({ - selectedIndices: indices, - currentOptions: newProps.options, - }); - if (newProps.selectedKey === null) { - this.setState({ - suggestedDisplayValue: undefined, - }); - } - } - } - - public componentDidUpdate(prevProps: IComboBoxProps, prevState: IComboBoxState) { - const { allowFreeform, text, onMenuOpen, onMenuDismissed } = this.props; - const { isOpen, selectedIndices, currentPendingValueValidIndex } = this.state; + public componentDidUpdate(prevProps: IComboBoxInternalProps, prevState: IComboBoxState) { + const { + allowFreeform, + text, + onMenuOpen, + onMenuDismissed, + hoisted: { selectedIndices }, + } = this.props; + const { isOpen, currentPendingValueValidIndex } = this.state; // If we are newly open or are open and the pending valid index changed, // make sure the currently selected/pending option is scrolled into view @@ -305,9 +352,9 @@ export class ComboBox extends React.Component { (this._hasFocus() && ((!isOpen && !this.props.multiSelect && - prevState.selectedIndices && + prevProps.hoisted.selectedIndices && selectedIndices && - prevState.selectedIndices[0] !== selectedIndices[0]) || + prevProps.hoisted.selectedIndices[0] !== selectedIndices[0]) || !allowFreeform || text !== prevProps.text))) ) { @@ -350,15 +397,16 @@ export class ComboBox extends React.Component { keytipProps, persistMenu, multiSelect, + hoisted: { suggestedDisplayValue, selectedIndices, currentOptions }, } = this.props; - const { isOpen, suggestedDisplayValue } = this.state; + const { isOpen } = this.state; this._currentVisibleValue = this._getVisibleValue(); // Single select is already accessible since the whole text is selected // when focus enters the input. Since multiselect appears to clear the input // it needs special accessible text const multiselectAccessibleText = multiSelect - ? this._getMultiselectDisplayString(this.state.selectedIndices, this.state.currentOptions, suggestedDisplayValue) + ? this._getMultiselectDisplayString(selectedIndices, currentOptions, suggestedDisplayValue) : undefined; const divProps = getNativeProps>(this.props, divProperties, [ @@ -401,7 +449,7 @@ export class ComboBox extends React.Component { ); return ( -
    +
    {onRenderLabel({ props: this.props, multiselectAccessibleText }, this._onRenderLabel)} {comboBoxWrapper} {(persistMenu || isOpen) && @@ -411,7 +459,7 @@ export class ComboBox extends React.Component { onRenderList, onRenderItem, onRenderOption, - options: this.state.currentOptions.map((item, index) => ({ ...item, index: index })), + options: currentOptions.map((item, index) => ({ ...item, index: index })), onDismiss: this._onDismiss, }, this._onRenderContainer, @@ -510,9 +558,10 @@ export class ComboBox extends React.Component { tabIndex, autofill, iconButtonProps, + hoisted: { suggestedDisplayValue }, } = this.props; - const { isOpen, suggestedDisplayValue } = this.state; + const { isOpen } = this.state; // If the combobox has focus, is multiselect, and has a display string, then use that placeholder // so that the selected items don't appear to vanish. This is not ideal but it's the only reasonable way @@ -599,7 +648,7 @@ export class ComboBox extends React.Component { * True if the defaultVisibleValue equals the suggestedDisplayValue, false otherwise */ private _onShouldSelectFullInputValueInAutofillComponentDidUpdate = (): boolean => { - return this._currentVisibleValue === this.state.suggestedDisplayValue; + return this._currentVisibleValue === this.props.hoisted.suggestedDisplayValue; }; /** @@ -608,15 +657,13 @@ export class ComboBox extends React.Component { * @returns the value to pass to the input */ private _getVisibleValue = (): string | undefined => { - const { text, allowFreeform, autoComplete } = this.props; const { - selectedIndices, - currentPendingValueValidIndex, - currentOptions, - currentPendingValue, - suggestedDisplayValue, - isOpen, - } = this.state; + text, + allowFreeform, + autoComplete, + hoisted: { suggestedDisplayValue, selectedIndices, currentOptions }, + } = this.props; + const { currentPendingValueValidIndex, currentPendingValue, isOpen } = this.state; const currentPendingIndexValid = this._indexWithinBounds(currentOptions, currentPendingValueValidIndex); @@ -746,7 +793,7 @@ export class ComboBox extends React.Component { * @param updatedValue - the input's newly changed value */ private _processInputChangeWithFreeform(updatedValue: string): void { - const { currentOptions } = this.state; + const { currentOptions } = this.props.hoisted; let newCurrentPendingValueValidIndex = -1; // if the new value is empty, see if we have an exact match @@ -836,7 +883,8 @@ export class ComboBox extends React.Component { * @param updatedValue - the input's newly changed value */ private _processInputChangeWithoutFreeform(updatedValue: string): void { - const { currentPendingValue, currentPendingValueValidIndex, currentOptions } = this.state; + const { currentOptions } = this.props.hoisted; + const { currentPendingValue, currentPendingValueValidIndex } = this.state; if (this.props.autoComplete === 'on') { // If autoComplete is on while allow freeform is off, @@ -896,7 +944,9 @@ export class ComboBox extends React.Component { } private _getFirstSelectedIndex(): number { - return this.state.selectedIndices && this.state.selectedIndices.length > 0 ? this.state.selectedIndices[0] : -1; + return this.props.hoisted.selectedIndices && this.props.hoisted.selectedIndices.length > 0 + ? this.props.hoisted.selectedIndices[0] + : -1; } /** @@ -908,7 +958,7 @@ export class ComboBox extends React.Component { * it will snap to the edge of the options array. If delta == 0 and the given index is not selectable */ private _getNextSelectableIndex(index: number, searchDirection: SearchDirection): number { - const { currentOptions } = this.state; + const { currentOptions } = this.props.hoisted; let newIndex = index + searchDirection; @@ -954,9 +1004,11 @@ export class ComboBox extends React.Component { submitPendingValueEvent: React.SyntheticEvent, searchDirection: SearchDirection = SearchDirection.none, ): void { - const { onChange, onPendingValueChanged } = this.props; - const { currentOptions } = this.state; - const { selectedIndices: initialIndices } = this.state; + const { + onChange, + onPendingValueChanged, + hoisted: { selectedIndices: initialIndices, currentOptions }, + } = this.props; // Clone selectedIndices so we don't mutate state let selectedIndices = initialIndices ? initialIndices.slice() : []; @@ -1012,23 +1064,18 @@ export class ComboBox extends React.Component { changedOptions[index] = option; // Call onChange after state is updated - this.setState( - { - selectedIndices: selectedIndices, - currentOptions: changedOptions, - }, - () => { - // If ComboBox value is changed, revert preview first - if (this._hasPendingValue && onPendingValueChanged) { - onPendingValueChanged(); - this._hasPendingValue = false; - } - - if (onChange) { - onChange(submitPendingValueEvent, option, index, undefined); - } - }, - ); + this.props.hoisted.setSelectedIndices(selectedIndices); + this.props.hoisted.setCurrentOptions(changedOptions); + + // If ComboBox value is changed, revert preview first + if (this._hasPendingValue && onPendingValueChanged) { + onPendingValueChanged(); + this._hasPendingValue = false; + } + + if (onChange) { + onChange(submitPendingValueEvent, option, index, undefined); + } } } if (this.props.multiSelect && this.state.isOpen) { @@ -1060,24 +1107,20 @@ export class ComboBox extends React.Component { private _onResolveOptions = (): void => { if (this.props.onResolveOptions) { // get the options - const newOptions = this.props.onResolveOptions([...this.state.currentOptions]); + const newOptions = this.props.onResolveOptions([...this.props.hoisted.currentOptions]); // Check to see if the returned value is an array, if it is update the state // If the returned value is not an array then check to see if it's a promise or PromiseLike. // If it is then resolve it asynchronously. if (Array.isArray(newOptions)) { - this.setState({ - currentOptions: newOptions, - }); + this.props.hoisted.setCurrentOptions(newOptions); } else if (newOptions && newOptions.then) { // Ensure that the promise will only use the callback if it was the most recent one // and update the state when the promise returns const promise: PromiseLike = (this._currentPromise = newOptions); promise.then((newOptionsFromPromise: IComboBoxOption[]) => { if (promise === this._currentPromise) { - this.setState({ - currentOptions: newOptionsFromPromise, - }); + this.props.hoisted.setCurrentOptions(newOptionsFromPromise); } }); } @@ -1105,7 +1148,8 @@ export class ComboBox extends React.Component { if ( relatedTarget && // when event coming from withing the comboBox title - ((this._root.current && this._root.current.contains(relatedTarget as HTMLElement)) || + ((this.props.hoisted.rootRef.current && + this.props.hoisted.rootRef.current.contains(relatedTarget as HTMLElement)) || // when event coming from within the comboBox list menu (this._comboBoxMenu.current && (this._comboBoxMenu.current.contains(relatedTarget as HTMLElement) || @@ -1131,14 +1175,14 @@ export class ComboBox extends React.Component { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private _submitPendingValue(submitPendingValueEvent: React.SyntheticEvent): void { - const { onChange, allowFreeform, autoComplete } = this.props; const { - currentPendingValue, - currentPendingValueValidIndex, - currentOptions, - currentPendingValueValidIndexOnHover, - } = this.state; - let { selectedIndices } = this.state; + onChange, + allowFreeform, + autoComplete, + hoisted: { currentOptions }, + } = this.props; + const { currentPendingValue, currentPendingValueValidIndex, currentPendingValueValidIndexOnHover } = this.state; + let { selectedIndices } = this.props.hoisted; // Do not submit any pending value if we // have already initiated clearing the pending info @@ -1214,10 +1258,8 @@ export class ComboBox extends React.Component { } selectedIndices.push(newOptions.length - 1); } - this.setState({ - currentOptions: newOptions, - selectedIndices: selectedIndices, - }); + this.props.hoisted.setCurrentOptions(newOptions); + this.props.hoisted.setSelectedIndices(selectedIndices); } } else if (currentPendingValueValidIndex >= 0) { // Since we are not allowing freeform, we must have a matching @@ -1480,10 +1522,10 @@ export class ComboBox extends React.Component { } private _isOptionChecked(index: number | undefined): boolean { - if (this.props.multiSelect && index !== undefined && this.state.selectedIndices) { + if (this.props.multiSelect && index !== undefined && this.props.hoisted.selectedIndices) { let idxOfSelectedIndex = -1; - idxOfSelectedIndex = this.state.selectedIndices.indexOf(index); + idxOfSelectedIndex = this.props.hoisted.selectedIndices.indexOf(index); return idxOfSelectedIndex >= 0; } return false; @@ -1594,7 +1636,6 @@ export class ComboBox extends React.Component { private _onItemClick(item: IComboBoxOption): (ev: React.MouseEvent) => void { const { onItemClick } = this.props; const { index } = item; - // eslint-disable-next-line @typescript-eslint/no-explicit-any return (ev: React.MouseEvent): void => { onItemClick && onItemClick(ev, item, index); @@ -1635,39 +1676,6 @@ export class ComboBox extends React.Component { this._resetSelectedIndex(); }; - /** - * Get the indices of the options that are marked as selected - * @param options - the comboBox options - * @param selectedKeys - the known selected keys to find - * @returns - an array of the indices of the selected options, empty array if nothing is selected - */ - private _getSelectedIndices( - options: IComboBoxOption[] | undefined, - selectedKeys: (string | number | undefined)[], - ): number[] { - if (!options || !selectedKeys) { - return []; - } - - const selectedIndices: { [key: number]: boolean } = {}; - options.forEach((option: IComboBoxOption, index: number) => { - if (option.selected) { - selectedIndices[index] = true; - } - }); - - for (const selectedKey of selectedKeys) { - const index = findIndex(options, option => option.key === selectedKey); - if (index > -1) { - selectedIndices[index] = true; - } - } - - return Object.keys(selectedIndices) - .map(Number) - .sort(); - } - /** * Reset the selected index by clearing the * input (of any pending text), clearing the pending state, @@ -1675,19 +1683,15 @@ export class ComboBox extends React.Component { * selected state text */ private _resetSelectedIndex(): void { - const { currentOptions } = this.state; + const { currentOptions } = this.props.hoisted; this._clearPendingInfo(); const selectedIndex: number = this._getFirstSelectedIndex(); if (selectedIndex > 0 && selectedIndex < currentOptions.length) { - this.setState({ - suggestedDisplayValue: currentOptions[selectedIndex].text, - }); + this.props.hoisted.setSuggestedDisplayValue(currentOptions[selectedIndex].text); } else if (this.props.text) { // If we had a value initially, restore it - this.setState({ - suggestedDisplayValue: this.props.text, - }); + this.props.hoisted.setSuggestedDisplayValue(this.props.text); } } @@ -1697,11 +1701,11 @@ export class ComboBox extends React.Component { private _clearPendingInfo(): void { this._processingClearPendingInfo = true; + this.props.hoisted.setSuggestedDisplayValue(undefined); this.setState( { currentPendingValue: undefined, currentPendingValueValidIndex: -1, - suggestedDisplayValue: undefined, currentPendingValueValidIndexOnHover: HoverStatus.default, }, this._onAfterClearPendingInfo, @@ -1727,10 +1731,10 @@ export class ComboBox extends React.Component { return; } + this.props.hoisted.setSuggestedDisplayValue(suggestedDisplayValue); this.setState({ currentPendingValue: this._normalizeToString(currentPendingValue), currentPendingValueValidIndex: currentPendingValueValidIndex, - suggestedDisplayValue: suggestedDisplayValue, currentPendingValueValidIndexOnHover: HoverStatus.default, }); } @@ -1740,7 +1744,7 @@ export class ComboBox extends React.Component { * @param index - the index to set the pending info from */ private _setPendingInfoFromIndex(index: number): void { - const { currentOptions } = this.state; + const { currentOptions } = this.props.hoisted; if (index >= 0 && index < currentOptions.length) { const option = currentOptions[index]; @@ -1756,7 +1760,7 @@ export class ComboBox extends React.Component { * @param searchDirection - the direction to search */ private _setPendingInfoFromIndexAndDirection(index: number, searchDirection: SearchDirection): void { - const { currentOptions } = this.state; + const { currentOptions } = this.props.hoisted; // update index to allow content to wrap if (searchDirection === SearchDirection.forward && index >= currentOptions.length - 1) { @@ -1794,12 +1798,8 @@ export class ComboBox extends React.Component { return; } - const { - currentPendingValue, - currentOptions, - currentPendingValueValidIndex, - currentPendingValueValidIndexOnHover, - } = this.state; + const { currentOptions } = this.props.hoisted; + const { currentPendingValue, currentPendingValueValidIndex, currentPendingValueValidIndexOnHover } = this.state; let newPendingIndex: number | undefined = undefined; let newPendingValue: string | undefined = undefined; @@ -1847,8 +1847,13 @@ export class ComboBox extends React.Component { * @param ev - The keyboard event that was fired */ private _onInputKeyDown = (ev: React.KeyboardEvent): void => { - const { disabled, allowFreeform, autoComplete } = this.props; - const { isOpen, currentOptions, currentPendingValueValidIndexOnHover } = this.state; + const { + disabled, + allowFreeform, + autoComplete, + hoisted: { currentOptions }, + } = this.props; + const { isOpen, currentPendingValueValidIndexOnHover } = this.state; // Take note if we are processing an alt (option) or meta (command) keydown. // See comment in _onInputKeyUp for reasoning. @@ -1928,7 +1933,7 @@ export class ComboBox extends React.Component { // and has since mousedOut of the menu items), // go to the last index if (currentPendingValueValidIndexOnHover === HoverStatus.clearAll) { - index = this.state.currentOptions.length; + index = this.props.hoisted.currentOptions.length; } if (ev.altKey || ev.metaKey) { @@ -2165,7 +2170,6 @@ export class ComboBox extends React.Component { */ private _onAutofillClick = (): void => { const { disabled, allowFreeform } = this.props; - if (allowFreeform && !disabled) { this.focus(this.state.isOpen || this._processingTouch); } else { @@ -2236,8 +2240,8 @@ export class ComboBox extends React.Component { */ private _getAriaActiveDescendantValue(): string | undefined { let descendantText = - this.state.isOpen && this.state.selectedIndices && this.state.selectedIndices.length > 0 - ? this._id + '-list' + this.state.selectedIndices[0] + this.state.isOpen && this.props.hoisted.selectedIndices && this.props.hoisted.selectedIndices.length > 0 + ? this._id + '-list' + this.props.hoisted.selectedIndices[0] : undefined; if (this.state.isOpen && this._hasFocus() && this.state.currentPendingValueValidIndex !== -1) { descendantText = this._id + '-list' + this.state.currentPendingValueValidIndex; @@ -2259,35 +2263,6 @@ export class ComboBox extends React.Component { return item && item.index === this.state.currentPendingValueValidIndex; } - /** - * Given default selected key(s) and selected key(s), return the selected keys(s). - * When default selected key(s) are available, they take precedence and return them instead of selected key(s). - * - * @returns No matter what specific types the input parameters are, always return an array of - * either strings or numbers instead of premitive type. This normlization makes caller's logic easier. - */ - private _buildDefaultSelectedKeys( - defaultSelectedKey: string | number | string[] | number[] | null | undefined, - selectedKey: string | number | string[] | number[] | null | undefined, - ): string[] | number[] { - const selectedKeys: string[] | number[] = this._buildSelectedKeys(defaultSelectedKey); - if (selectedKeys.length) { - return selectedKeys; - } - return this._buildSelectedKeys(selectedKey); - } - - private _buildSelectedKeys( - selectedKey: string | number | string[] | number[] | null | undefined, - ): string[] | number[] { - if (selectedKey === undefined) { - return []; - } - - // need to cast here so typescript does not complain - return (selectedKey instanceof Array ? selectedKey : [selectedKey]) as string[] | number[]; - } - // For scenarios where the option's text prop contains embedded styles, we use the option's // ariaLabel value as the text in the input and for autocomplete matching. We know to use this // when the useAriaLabelAsText prop is set to true @@ -2306,3 +2281,63 @@ export class ComboBox extends React.Component { return this.state.focusState !== 'none'; } } + +/** + * Get the indices of the options that are marked as selected + * @param options - the comboBox options + * @param selectedKeys - the known selected keys to find + * @returns - an array of the indices of the selected options, empty array if nothing is selected + */ +function getSelectedIndices( + options: IComboBoxOption[] | undefined, + selectedKeys: (string | number | undefined)[], +): number[] { + if (!options || !selectedKeys) { + return []; + } + + const selectedIndices: { [key: number]: boolean } = {}; + options.forEach((option: IComboBoxOption, index: number) => { + if (option.selected) { + selectedIndices[index] = true; + } + }); + + for (const selectedKey of selectedKeys) { + const index = findIndex(options, option => option.key === selectedKey); + if (index > -1) { + selectedIndices[index] = true; + } + } + + return Object.keys(selectedIndices) + .map(Number) + .sort(); +} + +/** + * Given default selected key(s) and selected key(s), return the selected keys(s). + * When default selected key(s) are available, they take precedence and return them instead of selected key(s). + * + * @returns No matter what specific types the input parameters are, always return an array of + * either strings or numbers instead of premitive type. This normlization makes caller's logic easier. + */ +function buildDefaultSelectedKeys( + defaultSelectedKey: string | number | string[] | number[] | null | undefined, + selectedKey: string | number | string[] | number[] | null | undefined, +): string[] | number[] { + const selectedKeys: string[] | number[] = buildSelectedKeys(defaultSelectedKey); + if (selectedKeys.length) { + return selectedKeys; + } + return buildSelectedKeys(selectedKey); +} + +function buildSelectedKeys(selectedKey: string | number | string[] | number[] | null | undefined): string[] | number[] { + if (selectedKey === undefined) { + return []; + } + + // need to cast here so typescript does not complain + return (selectedKey instanceof Array ? selectedKey : [selectedKey]) as string[] | number[]; +} diff --git a/packages/react-next/src/components/ComboBox/ComboBox.types.ts b/packages/react-next/src/components/ComboBox/ComboBox.types.ts index 49b4cb99b368a..bdadfc5a343b5 100644 --- a/packages/react-next/src/components/ComboBox/ComboBox.types.ts +++ b/packages/react-next/src/components/ComboBox/ComboBox.types.ts @@ -54,7 +54,9 @@ export interface IComboBoxOption extends ISelectableOption { /** * {@docCategory ComboBox} */ -export interface IComboBoxProps extends ISelectableDroppableTextProps { +export interface IComboBoxProps + extends ISelectableDroppableTextProps, + React.RefAttributes { /** * Optional callback to access the IComboBox interface. Use this instead of ref for accessing * the public methods and properties of the component. diff --git a/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.scss b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.scss new file mode 100644 index 0000000000000..1f8e6ac62bff9 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.scss @@ -0,0 +1,28 @@ +@import '~@fluentui/common-styles/dist/sass/common'; + +.pickerText { + display: flex; + flex-wrap: wrap; + align-items: center; + box-sizing: border-box; + border: 1px solid $ms-color-neutralTertiary; + min-width: 180px; + padding: 1px; + min-height: 32px; + &:hover { + border-color: $ms-color-themeLight; + } +} + +.pickerInput { + height: 34px; + border: none; + flex-grow: 1; + outline: none; + padding: 0 6px 0px; + margin: 1px; + + &::-ms-clear { + display: none; + } +} diff --git a/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.test.tsx b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.test.tsx new file mode 100644 index 0000000000000..3d18991510b28 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.test.tsx @@ -0,0 +1,364 @@ +import * as React from 'react'; +import * as ReactTestUtils from 'react-dom/test-utils'; +import * as ReactDOM from 'react-dom'; +import * as renderer from 'react-test-renderer'; +import { IBaseExtendedPickerProps } from './BaseExtendedPicker.types'; +import { BaseExtendedPicker } from './BaseExtendedPicker'; +import { IBaseFloatingPickerProps, BaseFloatingPicker, SuggestionsStore } from '../FloatingPicker/index'; +import { IBaseSelectedItemsListProps, ISelectedItemProps, BaseSelectedItemsList } from '../SelectedItemsList/index'; +import { KeyCodes } from '../../Utilities'; + +function onResolveSuggestions(text: string): ISimple[] { + return [ + 'black', + 'blue', + 'brown', + 'cyan', + 'green', + 'magenta', + 'mauve', + 'orange', + 'pink', + 'purple', + 'red', + 'rose', + 'violet', + 'white', + 'yellow', + ] + .filter((tag: string) => tag.toLowerCase().indexOf(text.toLowerCase()) === 0) + .map((item: string) => ({ key: item, name: item })); +} + +const BasePickerWithType = BaseFloatingPicker as new (props: IBaseFloatingPickerProps) => BaseFloatingPicker< + ISimple, + IBaseFloatingPickerProps +>; + +const BaseSelectedItemsListWithType = BaseSelectedItemsList as new ( + props: IBaseSelectedItemsListProps, +) => BaseSelectedItemsList>; + +const basicSuggestionRenderer = (props: ISimple) => { + return
    {props.name}
    ; +}; + +const basicItemRenderer = (props: ISelectedItemProps) => { + return
    {props.name}
    ; +}; + +const basicRenderFloatingPicker = (props: IBaseFloatingPickerProps) => { + return ; +}; + +const basicRenderSelectedItemsList = (props: IBaseSelectedItemsListProps) => { + return ; +}; + +const floatingPickerProps = { + onResolveSuggestions: onResolveSuggestions, + onRenderSuggestionsItem: basicSuggestionRenderer, + suggestionsStore: new SuggestionsStore(), +}; + +const selectedItemsListProps: IBaseSelectedItemsListProps = { + onRenderItem: basicItemRenderer, +}; + +export interface ISimple { + key: string; + name: string; +} + +export type TypedBaseExtendedPicker = BaseExtendedPicker>; + +describe('Pickers', () => { + describe('BasePicker', () => { + const BaseExtendedPickerWithType = BaseExtendedPicker as new ( + props: IBaseExtendedPickerProps, + ) => BaseExtendedPicker>; + // Our functional tests need to run against actual DOM for callouts to work, + // since callout mount a new react root with ReactDOM. + // + // see https://github.com/facebook/react/pull/12895 + let hostNode: HTMLDivElement | null = null; + const create = (elem: React.ReactElement) => { + hostNode = document.createElement('div'); + document.body.appendChild(hostNode); + ReactDOM.render(elem, hostNode); + }; + + afterEach(() => { + if (hostNode) { + ReactDOM.unmountComponentAtNode(hostNode); + document.body.removeChild(hostNode); + hostNode = null; + } + }); + + it('renders BaseExtendedPicker correctly with no items', () => { + const component = renderer.create( + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders BaseExtendedPicker correctly with selected and suggested items', () => { + const component = renderer.create( + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('force resolves to the first suggestion', () => { + jest.useFakeTimers(); + const pickerRef: React.RefObject = React.createRef(); + create( + , + ); + + expect(pickerRef.current).not.toBeFalsy(); + const picker = pickerRef.current!; + + if (picker.inputElement) { + picker.inputElement.value = 'bl'; + ReactTestUtils.Simulate.input(picker.inputElement); + ReactTestUtils.act(() => { + jest.runAllTimers(); + }); + } + + ReactDOM.render( + , + hostNode, + ); + + expect(picker.state.queryString).toBe('bl'); + expect(picker.floatingPicker.current && picker.floatingPicker.current.suggestions.length).toBe(2); + expect(picker.floatingPicker.current && picker.floatingPicker.current.suggestions[0].item.name).toBe('black'); + + // Force resolve to the first suggestions + picker.floatingPicker.current && picker.floatingPicker.current.forceResolveSuggestion(); + + ReactDOM.render( + , + hostNode, + ); + + expect(picker.items.length).toBe(1); + expect(picker.items[0].name).toBe('black'); + }); + + it('Can hide and show picker', () => { + jest.useFakeTimers(); + const pickerRef: React.RefObject = React.createRef(); + create( + , + ); + + expect(pickerRef.current).not.toBeFalsy(); + const picker = pickerRef.current!; + + if (picker.inputElement) { + picker.inputElement.value = 'bl'; + ReactTestUtils.Simulate.input(picker.inputElement); + } + + ReactTestUtils.act(() => { + jest.runAllTimers(); + }); + + expect(picker.floatingPicker.current && picker.floatingPicker.current.isSuggestionsShown).toBeTruthy(); + picker.floatingPicker.current && picker.floatingPicker.current.hidePicker(); + expect(picker.floatingPicker.current && picker.floatingPicker.current.isSuggestionsShown).toBeFalsy(); + picker.floatingPicker.current && picker.floatingPicker.current.showPicker(); + expect(picker.floatingPicker.current && picker.floatingPicker.current.isSuggestionsShown).toBeTruthy(); + }); + + it('Completes the suggestion', () => { + jest.useFakeTimers(); + const pickerRef: React.RefObject = React.createRef(); + create( + , + ); + + expect(pickerRef.current).not.toBeFalsy(); + const picker = pickerRef.current!; + + // setup + if (picker.inputElement) { + picker.inputElement.value = 'bl'; + ReactTestUtils.Simulate.input(picker.inputElement); + ReactTestUtils.Simulate.keyDown(picker.inputElement, { which: KeyCodes.down }); + ReactTestUtils.act(() => { + jest.runAllTimers(); + }); + } + + // precondition check + expect(picker.floatingPicker.current).toBeTruthy(); + expect(picker.floatingPicker.current!.suggestions).toEqual([ + jasmine.objectContaining({ + item: jasmine.objectContaining({ + name: 'black', + key: 'black', + }), + }), + jasmine.objectContaining({ + item: jasmine.objectContaining({ + name: 'blue', + key: 'blue', + }), + }), + ]); + + // act + picker.floatingPicker.current && picker.floatingPicker.current.completeSuggestion(); + + // assert + expect(picker.items).toEqual([ + jasmine.objectContaining({ + name: 'black', + key: 'black', + }), + ]); + }); + + describe('aria-owns', () => { + it('does not render an aria-owns when the floating picker has not been opened', () => { + const root = document.createElement('div'); + document.body.appendChild(root); + + const pickerRef = React.createRef(); + + ReactDOM.render( + , + root, + ); + + expect(document.querySelector('[aria-owns="suggestion-list"]')).not.toBeTruthy(); + expect(document.querySelector('#suggestion-list')).not.toBeTruthy(); + + ReactDOM.unmountComponentAtNode(root); + }); + + it('renders an aria-owns when the floating picker is open', () => { + const root = document.createElement('div'); + document.body.appendChild(root); + + const pickerRef = React.createRef(); + + ReactDOM.render( + , + root, + ); + + pickerRef.current!.floatingPicker.current!.showPicker(); + + expect(document.querySelector('[aria-owns="suggestion-list"]')).toBeTruthy(); + expect(document.querySelector('#suggestion-list')).toBeTruthy(); + + ReactDOM.unmountComponentAtNode(root); + }); + + it('does not render an aria-owns when the floating picker has been opened and closed', () => { + const root = document.createElement('div'); + document.body.appendChild(root); + + const pickerRef = React.createRef(); + + ReactDOM.render( + , + root, + ); + + pickerRef.current!.floatingPicker.current!.showPicker(); + + pickerRef.current!.floatingPicker.current!.hidePicker(); + + expect(document.querySelector('[aria-owns="suggestion-list"]')).not.toBeTruthy(); + expect(document.querySelector('#suggestion-list')).not.toBeTruthy(); + + ReactDOM.unmountComponentAtNode(root); + }); + }); + }); +}); diff --git a/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.tsx b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.tsx new file mode 100644 index 0000000000000..b932d3e28bf2e --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.tsx @@ -0,0 +1,325 @@ +import * as React from 'react'; +import { KeyCodes, css, initializeComponentRef } from '../../Utilities'; +import { Autofill } from '../../Autofill'; +import { IInputProps } from '../../Pickers'; +import * as stylesImport from './BaseExtendedPicker.scss'; +import { IBaseExtendedPickerProps, IBaseExtendedPicker } from './BaseExtendedPicker.types'; +import { IBaseFloatingPickerProps, BaseFloatingPicker } from '../../FloatingPicker'; +import { BaseSelectedItemsList, IBaseSelectedItemsListProps } from '../../SelectedItemsList'; +import { FocusZone, FocusZoneDirection } from '../../FocusZone'; +import { Selection, SelectionMode, SelectionZone } from '../../Selection'; +/* eslint-disable */ + +const styles: any = stylesImport; + +export interface IBaseExtendedPickerState { + queryString: string | null; + selectedItems: T[] | null; + suggestionItems: T[] | null; +} + +export class BaseExtendedPicker> + extends React.Component> + implements IBaseExtendedPicker { + public floatingPicker = React.createRef>>(); + public selectedItemsList = React.createRef>>(); + + protected root = React.createRef(); + protected input = React.createRef(); + protected selection: Selection; + protected floatingPickerProps: IBaseFloatingPickerProps; + protected selectedItemsListProps: IBaseSelectedItemsListProps; + + constructor(basePickerProps: P) { + super(basePickerProps); + + initializeComponentRef(this); + this.selection = new Selection({ onSelectionChanged: () => this.onSelectionChange() }); + + this.state = { + queryString: '', + // TODO: determine whether this can be removed + // eslint-disable-next-line react/no-unused-state + suggestionItems: this.props.suggestionItems ? (this.props.suggestionItems as T[]) : null, + selectedItems: this.props.defaultSelectedItems + ? (this.props.defaultSelectedItems as T[]) + : this.props.selectedItems + ? (this.props.selectedItems as T[]) + : null, + }; + + this.floatingPickerProps = this.props.floatingPickerProps; + this.selectedItemsListProps = this.props.selectedItemsListProps; + } + + public get items(): any { + return this.state.selectedItems ?? this.selectedItemsList.current?.items ?? null; + } + + public componentDidMount(): void { + this.forceUpdate(); + } + + public UNSAFE_componentWillReceiveProps(newProps: P): void { + if (newProps.floatingPickerProps) { + this.floatingPickerProps = newProps.floatingPickerProps; + } + + if (newProps.selectedItemsListProps) { + this.selectedItemsListProps = newProps.selectedItemsListProps; + } + + if (newProps.selectedItems) { + this.setState({ selectedItems: newProps.selectedItems }); + } + } + + public focus(): void { + if (this.input.current) { + this.input.current.focus(); + } + } + + public clearInput(): void { + if (this.input.current) { + this.input.current.clear(); + } + } + + public get inputElement(): HTMLInputElement | null { + return this.input.current && this.input.current.inputElement; + } + + public get highlightedItems(): T[] { + return this.selectedItemsList.current ? this.selectedItemsList.current.highlightedItems() : []; + } + + public render(): JSX.Element { + const { className, inputProps, disabled, focusZoneProps } = this.props; + const activeDescendant = + this.floatingPicker.current && this.floatingPicker.current.currentSelectedSuggestionIndex !== -1 + ? 'sug-' + this.floatingPicker.current.currentSelectedSuggestionIndex + : undefined; + const isExpanded = this.floatingPicker.current ? this.floatingPicker.current.isSuggestionsShown : false; + + return ( +
    + + +
    + {this.props.headerComponent} + {this.renderSelectedItemsList()} + {this.canAddItems() && ( + + )} +
    +
    +
    + {this.renderFloatingPicker()} +
    + ); + } + + protected onSelectionChange = (): void => { + this.forceUpdate(); + }; + + protected canAddItems(): boolean { + const { itemLimit } = this.props; + return itemLimit === undefined || this.items.length < itemLimit; + } + + protected renderFloatingPicker(): JSX.Element { + const FloatingPicker: React.ComponentType> = this.props.onRenderFloatingPicker; + return ( + + ); + } + + protected renderSelectedItemsList(): JSX.Element { + const SelectedItems: React.ComponentType> = this.props.onRenderSelectedItems; + return ( + + ); + } + + protected onInputChange = (value: string, composing?: boolean): void => { + // We don't want to update the picker's suggestions when the input is still being composed + if (!composing) { + this.setState({ queryString: value }); + if (this.floatingPicker.current) { + this.floatingPicker.current.onQueryStringChanged(value); + } + } + }; + + protected onInputFocus = (ev: React.FocusEvent): void => { + if (this.selectedItemsList.current) { + this.selectedItemsList.current.unselectAll(); + } + + if (this.props.inputProps && this.props.inputProps.onFocus) { + this.props.inputProps.onFocus(ev as React.FocusEvent); + } + }; + + protected onInputClick = (ev: React.MouseEvent): void => { + if (this.selectedItemsList.current) { + this.selectedItemsList.current.unselectAll(); + } + + if (this.floatingPicker.current && this.inputElement) { + // Update the value if the input value is empty or is different than the current inputText from the floatingPicker + const shoudUpdateValue = + this.inputElement.value === '' || this.inputElement.value !== this.floatingPicker.current.inputText; + this.floatingPicker.current.showPicker(shoudUpdateValue); + } + }; + + // This is protected because we may expect the backspace key to work differently in a different kind of picker. + // This lets the subclass override it and provide it's own onBackspace. For an example see the BasePickerListBelow + protected onBackspace = (ev: React.KeyboardEvent): void => { + if (ev.which !== KeyCodes.backspace) { + return; + } + + if (this.selectedItemsList.current && this.items.length) { + if ( + this.input.current && + !this.input.current.isValueSelected && + this.input.current.inputElement === document.activeElement && + (this.input.current as Autofill).cursorLocation === 0 + ) { + if (this.floatingPicker.current) { + this.floatingPicker.current.hidePicker(); + } + ev.preventDefault(); + this.selectedItemsList.current.removeItemAt(this.items.length - 1); + this._onSelectedItemsChanged(); + } else if (this.selectedItemsList.current.hasSelectedItems()) { + if (this.floatingPicker.current) { + this.floatingPicker.current.hidePicker(); + } + ev.preventDefault(); + this.selectedItemsList.current.removeSelectedItems(); + this._onSelectedItemsChanged(); + } + } + }; + + protected onCopy = (ev: React.ClipboardEvent): void => { + if (this.selectedItemsList.current) { + // Pass it down into the selected items list + this.selectedItemsList.current.onCopy(ev); + } + }; + + protected onPaste = (ev: React.ClipboardEvent): void => { + if (this.props.onPaste) { + const inputText = ev.clipboardData.getData('Text'); + ev.preventDefault(); + this.props.onPaste(inputText); + } + }; + + protected _onSuggestionSelected = (item: T): void => { + const currentRenderedQueryString = this.props.currentRenderedQueryString; + const queryString = this.state.queryString; + if (currentRenderedQueryString === undefined || currentRenderedQueryString === queryString) { + const processedItem: T | PromiseLike | null = this.props.onItemSelected + ? (this.props.onItemSelected as any)(item) + : item; + + if (processedItem === null) { + return; + } + + const processedItemObject: T = processedItem as T; + const processedItemPromiseLike: PromiseLike = processedItem as PromiseLike; + + let newItem: T; + if (processedItemPromiseLike && processedItemPromiseLike.then) { + processedItemPromiseLike.then((resolvedProcessedItem: T) => { + newItem = resolvedProcessedItem; + this._addProcessedItem(newItem); + }); + } else { + newItem = processedItemObject; + this._addProcessedItem(newItem); + } + } + }; + + protected _onSelectedItemsChanged = (): void => { + this.focus(); + }; + + /** + * The floating picker is the source of truth for if the menu has been opened or not. + * + * Because this isn't tracked inside the state of this component, we need to + * force an update here to keep the rendered output that depends on the picker being open + * in sync with the state + * + * Called when the suggestions is shown or closed + */ + private _onSuggestionsShownOrHidden = () => { + this.forceUpdate(); + }; + + private _addProcessedItem(newItem: T) { + // If this is a controlled component, call the on item selected callback + // Otherwise add it to the selectedItemsList + if (this.props.onItemAdded) { + this.props.onItemAdded(newItem); + } + + if (this.selectedItemsList.current) { + this.selectedItemsList.current.addItems([newItem]); + } + + if (this.input.current) { + this.input.current.clear(); + } + + if (this.floatingPicker.current) { + this.floatingPicker.current.hidePicker(); + } + + this.focus(); + } +} diff --git a/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.types.ts b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.types.ts new file mode 100644 index 0000000000000..8efc9ac765cde --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/BaseExtendedPicker.types.ts @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { Autofill } from '../../Autofill'; +import { IInputProps } from '../../Pickers'; +import { IBaseFloatingPickerProps } from '../../FloatingPicker'; +import { IBaseSelectedItemsListProps } from '../../SelectedItemsList'; +import { IRefObject } from '../../Utilities'; +import { IFocusZoneProps } from '../../FocusZone'; + +export interface IBaseExtendedPicker { + /** Forces the picker to resolve */ + forceResolve?: () => void; + /** Gets the current value of the input. */ + items: T[] | undefined; + /** Sets focus to the input. */ + focus: () => void; +} + +// Type T is the type of the item that is displayed +// and searched for by the people picker. For example, if the picker is +// displaying persona's than type T could either be of Persona or Ipersona props +export interface IBaseExtendedPickerProps { + /** + * Ref of the component + */ + componentRef?: IRefObject>; + + /** + * Header/title element for the picker + */ + headerComponent?: JSX.Element; + + /** + * Initial items that have already been selected and should appear in the people picker. + */ + defaultSelectedItems?: T[]; + + /** + * A callback for when the selected list of items changes. + */ + onChange?: (items?: T[]) => void; + + /** + * A callback for when text is pasted into the input + */ + onPaste?: (pastedText: string) => T[]; + + /** + * A callback for when the user put focus on the picker + */ + onFocus?: React.FocusEventHandler; + + /** + * A callback for when the user moves the focus away from the picker + */ + onBlur?: React.FocusEventHandler; + + /** + * ClassName for the picker. + */ + className?: string; + + /** + * Function that specifies how the floating picker will appear. + */ + onRenderFloatingPicker: React.ComponentType>; + + /** + * Function that specifies how the floating picker will appear. + */ + onRenderSelectedItems: React.ComponentType>; + + /** + * Floating picker properties + */ + floatingPickerProps: IBaseFloatingPickerProps; + + /** + * Selected items list properties + */ + selectedItemsListProps: IBaseSelectedItemsListProps; + + /** + * Autofill input native props + * @defaultvalue undefined + */ + inputProps?: IInputProps; + + /** + * Flag for disabling the picker. + * @defaultvalue false + */ + disabled?: boolean; + + /** + * Restrict the amount of selectable items. + * @defaultvalue undefined + */ + itemLimit?: number; + + /** + * A callback to process a selection after the user selects a suggestion from the picker. + * The returned item will be added to the selected items list + */ + onItemSelected?: (selectedItem?: T) => T | PromiseLike; + + /** + * A callback on when an item was added to the selected item list + */ + onItemAdded?: (addedItem: T) => void; + + /** + * A callback on when an item or items were removed from the selected item list + */ + onItemsRemoved?: (removedItems: T[]) => void; + + /** + * If using as a controlled component use selectedItems here instead of the SelectedItemsList + */ + selectedItems?: T[]; + + /** + * If using as a controlled component use suggestionItems here instead of FloatingPicker + */ + suggestionItems?: T[]; + + /** + * Focus zone props + */ + focusZoneProps?: IFocusZoneProps; + + /** + * Current rendered query string that's corealte to current rendered result + **/ + currentRenderedQueryString?: string; +} diff --git a/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.doc.tsx b/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.doc.tsx new file mode 100644 index 0000000000000..870a7f8771aa4 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.doc.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { ExtendedPeoplePickerBasicExample } from '../examples/ExtendedPeoplePicker.Basic.Example'; +import { ExtendedPeoplePickerControlledExample } from '../examples/ExtendedPeoplePicker.Controlled.Example'; + +import { IDocPageProps } from '../../../common/DocPage.types'; + +const ExtendedPeoplePickerBasicExampleCode = require('!raw-loader!@fluentui/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Basic.Example.tsx') as string; +const ExtendedPeoplePickerControlledExampleCode = require('!raw-loader!@fluentui/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Controlled.Example.tsx') as string; + +export const ExtendedPeoplePickerPageProps: IDocPageProps = { + title: 'ExtendedPeoplePicker', + componentName: 'ExtendedPeoplePicker', + componentUrl: + 'https://github.com/microsoft/fluentui/tree/master/packages/react-next/src/components/ExtendedPicker/PeoplePicker', + examples: [ + { + title: 'Extended People Picker (uncontrolled)', + code: ExtendedPeoplePickerBasicExampleCode, + view: , + }, + { + title: 'Extended People Picker (controlled)', + code: ExtendedPeoplePickerControlledExampleCode, + view: , + }, + ], + overview: require('!raw-loader!@fluentui/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerOverview.md') as string, + bestPractices: require('!raw-loader!@fluentui/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerBestPractices.md') as string, + dos: require('!raw-loader!@fluentui/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDos.md') as string, + donts: require('!raw-loader!@fluentui/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDonts.md') as string, + isHeaderVisible: true, + isFeedbackVisible: true, +}; diff --git a/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.scss b/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.scss new file mode 100644 index 0000000000000..c8601af055d83 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.scss @@ -0,0 +1,29 @@ +@import '~@fluentui/common-styles/dist/sass/common'; + +.resultContent { + display: table-row; + .resultItem { + display: table-cell; + vertical-align: bottom; + } +} + +.peoplePickerPersona { + width: 180px; + :global(.ms-Persona-details) { + width: 100%; + } +} + +.peoplePicker { + :global(.ms-BasePicker-text) { + min-height: 40px; + } +} + +.peoplePickerPersonaContent { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; +} diff --git a/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.tsx b/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.tsx new file mode 100644 index 0000000000000..060a4ff402a05 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/PeoplePicker/ExtendedPeoplePicker.tsx @@ -0,0 +1,27 @@ +import { IPickerItemProps } from '../../../Pickers'; + +import { IExtendedPersonaProps } from '../../../SelectedItemsList'; +import { IPersonaProps } from '../../../Persona'; +import './ExtendedPeoplePicker.scss'; +import { BaseExtendedPicker } from '../BaseExtendedPicker'; +import { IBaseExtendedPickerProps } from '../BaseExtendedPicker.types'; + +/** + * {@docCategory ExtendedPeoplePicker} + */ +export interface IPeoplePickerItemProps extends IPickerItemProps {} + +/** + * {@docCategory ExtendedPeoplePicker} + */ +export interface IExtendedPeoplePickerProps extends IBaseExtendedPickerProps {} + +/** + * {@docCategory ExtendedPeoplePicker} + */ +export class BaseExtendedPeoplePicker extends BaseExtendedPicker {} + +/** + * {@docCategory ExtendedPeoplePicker} + */ +export class ExtendedPeoplePicker extends BaseExtendedPeoplePicker {} diff --git a/packages/react-next/src/components/ExtendedPicker/__snapshots__/BaseExtendedPicker.test.tsx.snap b/packages/react-next/src/components/ExtendedPicker/__snapshots__/BaseExtendedPicker.test.tsx.snap new file mode 100644 index 0000000000000..8330aeb2f57ec --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/__snapshots__/BaseExtendedPicker.test.tsx.snap @@ -0,0 +1,133 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pickers BasePicker renders BaseExtendedPicker correctly with no items 1`] = ` +
    +
    +
    +
    + +
    +
    +
    +
    +
    +`; + +exports[`Pickers BasePicker renders BaseExtendedPicker correctly with selected and suggested items 1`] = ` +
    +
    +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    +
    +`; diff --git a/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerBestPractices.md b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerBestPractices.md new file mode 100644 index 0000000000000..4e0c9e300fcca --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerBestPractices.md @@ -0,0 +1,4 @@ +The ExtendedPeoplePicker is used to select one or more entities, such as people or groups. Entry points for +ExtendedPeoplePicker are typically specialized TextField-like input fields known as a "well", which are used to search for recipients from a list. When +a recipient is selected from the list, it is added to the well as a specialized Persona that can be interacted with or removed. +Clicking on a Persona from the well should invoke a PersonaCard or open a profile pane for that recipient. diff --git a/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDonts.md b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDonts.md new file mode 100644 index 0000000000000..ab7ec4895f598 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDonts.md @@ -0,0 +1,3 @@ +- Use the ExtendedPeoplePicker to select something other than people +- Use the ExtendedPeoplePicker to display people without an option to select them +- Use the ExtendedPeoplePicker without sufficient space diff --git a/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDos.md b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDos.md new file mode 100644 index 0000000000000..18c7be162cbbb --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerDos.md @@ -0,0 +1,3 @@ +- Use the ExtendedPeoplePicker to quickly search for a few people +- Use the ExtendedPeoplePicker to manage a group of people +- Use defaultSelectedItems when some people have already been selected diff --git a/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerOverview.md b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerOverview.md new file mode 100644 index 0000000000000..84e83ae487310 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/docs/ExtendedPeoplePickerOverview.md @@ -0,0 +1,3 @@ +ExtendedPeoplePicker are used to pick recipients. The difference between this and the current PeoplePicker are: + +- Will remove selected items on backspace even if there is text in the input area diff --git a/packages/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Basic.Example.tsx b/packages/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Basic.Example.tsx new file mode 100644 index 0000000000000..39791bb6e5d52 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Basic.Example.tsx @@ -0,0 +1,232 @@ +import * as React from 'react'; +import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona'; +import { ExtendedPeoplePicker } from 'office-ui-fabric-react/lib/ExtendedPicker'; +import { PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { SuggestionsStore, FloatingPeoplePicker } from 'office-ui-fabric-react/lib/FloatingPicker'; +import { SelectedPeopleList, IExtendedPersonaProps } from 'office-ui-fabric-react/lib/SelectedItemsList'; +import { FocusZoneTabbableElements } from 'office-ui-fabric-react/lib/FocusZone'; +import { mergeStyleSets, getTheme } from 'office-ui-fabric-react/lib/Styling'; +import { people, mru, groupOne, groupTwo } from '@uifabric/example-data'; +import { useConst } from '@uifabric/react-hooks'; + +const theme = getTheme(); + +const startsWith = (text: string, filterText: string): boolean => { + return text.toLowerCase().indexOf(filterText.toLowerCase()) === 0; +}; + +const classNames = mergeStyleSets({ + picker: { maxWidth: 400, marginBottom: 15 }, + headerItem: { + borderBottom: '1px solid ' + theme.palette.neutralLight, + padding: '8px 12px', + }, + footerItem: { + borderBottom: '1px solid ' + theme.palette.neutralLight, + height: 60, + paddingLeft: 12, + }, + to: { padding: '0 10px' }, +}); + +const focusZoneProps = { + shouldInputLoseFocusOnArrowKey: () => true, + handleTabKey: FocusZoneTabbableElements.all, +}; + +export const ExtendedPeoplePickerBasicExample: React.FunctionComponent = () => { + const picker = React.useRef(null); + const [peopleList, setPeopleList] = React.useState(people); + const [mostRecentlyUsed, setMostRecentlyUsed] = React.useState(mru); + const [searchMoreAvailable, setSearchMoreAvailable] = React.useState(true); + + const getEditingItemText = (item: IExtendedPersonaProps): string => { + return item.text as string; + }; + + const onSetFocusButtonClicked = React.useCallback((): void => { + picker.current?.focus(); + }, []); + + const onExpandItem = (item: IExtendedPersonaProps): void => { + const selectedItemsList = picker.current?.selectedItemsList.current; + if (selectedItemsList) { + (selectedItemsList as SelectedPeopleList).replaceItem(item, getExpandedGroupItems(item)); + } + }; + + const onRemoveSuggestion = (item: IPersonaProps): void => { + const itemIndex = peopleList.indexOf(item); + const itemMruIndex = mostRecentlyUsed.indexOf(item); + if (itemIndex >= 0) { + setPeopleList(peopleList.slice(0, itemIndex).concat(peopleList.slice(itemIndex + 1))); + } + if (itemMruIndex >= 0) { + setMostRecentlyUsed(mostRecentlyUsed.slice(0, itemMruIndex).concat(mostRecentlyUsed.slice(itemMruIndex + 1))); + } + }; + + const onFilterChanged = (filterText: string, currentPersonas?: IPersonaProps[]): Promise | null => { + let filteredPersonas: IPersonaProps[] = []; + if (filterText) { + filteredPersonas = peopleList.filter((item: IPersonaProps) => startsWith(item.text || '', filterText)); + filteredPersonas = removeDuplicates(filteredPersonas, currentPersonas); + } + return convertResultsToPromise(filteredPersonas); + }; + + const returnMostRecentlyUsed = (): IPersonaProps[] | Promise | null => { + let currentMostRecentlyUsed = mostRecentlyUsed; + const items = picker.current?.items || []; + currentMostRecentlyUsed = removeDuplicates(currentMostRecentlyUsed, items); + return convertResultsToPromise(currentMostRecentlyUsed); + }; + + const onCopyItems = (items: IExtendedPersonaProps[]): string => { + return items.map(item => item.text).join(', '); + }; + + const shouldShowForceResolve = (): boolean => { + const floatingPicker = picker.current?.floatingPicker.current; + return !!floatingPicker && validateInput(floatingPicker.inputText) && floatingPicker.suggestions.length === 0; + }; + + const shouldShowSuggestedContacts = (): boolean => { + return picker.current?.inputElement?.value === ''; + }; + + const listContainsPersona = (persona: IPersonaProps, personas?: IPersonaProps[]): boolean => { + return !!personas && personas.some((item: IPersonaProps) => item.text === persona.text); + }; + + const removeDuplicates = (personas: IPersonaProps[], possibleDupes?: IPersonaProps[]): IPersonaProps[] => { + return personas.filter((persona: IPersonaProps) => !listContainsPersona(persona, possibleDupes)); + }; + + const onInputChanged = (): void => { + setSearchMoreAvailable(true); + }; + + const convertResultsToPromise = (results: IPersonaProps[]): Promise => { + return new Promise(resolve => setTimeout(() => resolve(results), 150)); + }; + + const validateInput = (input: string): boolean => { + return input.indexOf('@') !== -1; + }; + + const getExpandedGroupItems = (item: IExtendedPersonaProps): IExtendedPersonaProps[] => { + return item.text === 'Group One' ? groupOne : item.text === 'Group Two' ? groupTwo : []; + }; + + const suggestionProps = useConst({ + showRemoveButtons: true, + headerItemsProps: [ + { + renderItem: () => { + return ( +
    Use this address: {picker.current?.inputElement?.value || ''}
    + ); + }, + shouldShow: () => { + return !!picker.current?.inputElement && picker.current.inputElement.value.indexOf('@') > -1; + }, + onExecute: () => { + const floatingPicker = picker.current?.floatingPicker.current; + if (floatingPicker) { + floatingPicker.forceResolveSuggestion(); + } + }, + ariaLabel: 'Use the typed address', + }, + { + renderItem: () => { + return
    Suggested Contacts
    ; + }, + shouldShow: shouldShowSuggestedContacts, + }, + ], + footerItemsProps: [ + { + renderItem: () => { + return
    No results
    ; + }, + shouldShow: () => { + const floatingPicker = picker.current?.floatingPicker.current; + return !!floatingPicker && floatingPicker.suggestions.length === 0; + }, + }, + { + renderItem: () => { + return
    Search for more
    ; + }, + onExecute: () => { + setSearchMoreAvailable(false); + }, + shouldShow: () => { + return searchMoreAvailable && !shouldShowSuggestedContacts(); + }, + ariaLabel: 'Search more', + }, + ], + shouldSelectFirstItem: () => { + return !shouldShowSuggestedContacts(); + }, + }); + + const floatingPickerProps = { + suggestionsStore: new SuggestionsStore(), + onResolveSuggestions: onFilterChanged, + getTextFromItem: (persona: IPersonaProps) => persona.text || '', + pickerSuggestionsProps: suggestionProps, + key: 'normal', + onRemoveSuggestion: onRemoveSuggestion, + onValidateInput: validateInput, + onZeroQuerySuggestion: returnMostRecentlyUsed, + showForceResolve: shouldShowForceResolve, + onInputChanged: onInputChanged, + onSuggestionsHidden: () => { + console.log('FLOATINGPICKER: hidden'); + }, + onSuggestionsShown: () => { + console.log('FLOATINGPICKER: shown'); + }, + }; + + const selectedItemsListProps = { + onCopyItems: onCopyItems, + onExpandGroup: onExpandItem, + removeMenuItemText: 'Remove', + copyMenuItemText: 'Copy name', + editMenuItemText: 'Edit', + getEditingItemText: getEditingItemText, + onRenderFloatingPicker: FloatingPeoplePicker, + floatingPickerProps: floatingPickerProps, + }; + + return ( +
    + console.log('onBlur called'), + onFocus: () => console.log('onFocus called'), + 'aria-label': 'People Picker', + }} + componentRef={picker} + headerComponent={ +
    + To: +
    + } + focusZoneProps={focusZoneProps} + /> + +
    + ); +}; diff --git a/packages/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Controlled.Example.tsx b/packages/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Controlled.Example.tsx new file mode 100644 index 0000000000000..8cbe23f6bff88 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/examples/ExtendedPeoplePicker.Controlled.Example.tsx @@ -0,0 +1,313 @@ +import * as React from 'react'; + +import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona'; +import { ExtendedPeoplePicker } from 'office-ui-fabric-react/lib/ExtendedPicker'; +import { PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { + SuggestionsStore, + FloatingPeoplePicker, + IBaseFloatingPickerProps, + IBaseFloatingPickerSuggestionProps, +} from 'office-ui-fabric-react/lib/FloatingPicker'; +import { + ISelectedPeopleProps, + SelectedPeopleList, + IExtendedPersonaProps, +} from 'office-ui-fabric-react/lib/SelectedItemsList'; +import { IFocusZoneProps, FocusZoneTabbableElements } from 'office-ui-fabric-react/lib/FocusZone'; +import { mergeStyleSets, getTheme, IStyle, IProcessedStyleSet } from 'office-ui-fabric-react/lib/Styling'; +import { people, mru, groupOne, groupTwo } from '@uifabric/example-data'; + +export interface IPeoplePickerExampleState { + peopleList: IPersonaProps[]; + mostRecentlyUsed: IPersonaProps[]; + searchMoreAvailable: boolean; + currentlySelectedItems: IExtendedPersonaProps[]; + suggestionItems: IPersonaProps[]; +} + +interface IClassNames { + picker: IStyle; + headerItem: IStyle; + footerItem: IStyle; + to: IStyle; +} + +export class ExtendedPeoplePickerControlledExample extends React.Component<{}, IPeoplePickerExampleState> { + private _picker = React.createRef(); + private _floatingPickerProps: IBaseFloatingPickerProps; + private _selectedItemsListProps: ISelectedPeopleProps; + private _focusZoneProps: IFocusZoneProps; + private _suggestionProps: IBaseFloatingPickerSuggestionProps; + private _classNames: IProcessedStyleSet; + + constructor(props: {}) { + super(props); + + this.state = { + peopleList: people, + mostRecentlyUsed: mru, + searchMoreAvailable: true, + currentlySelectedItems: [], + suggestionItems: [], + }; + + this._suggestionProps = { + showRemoveButtons: true, + headerItemsProps: [ + { + renderItem: () => { + const picker = this._picker.current; + return ( +
    + Use this address: {picker && picker.inputElement ? picker.inputElement.value : ''} +
    + ); + }, + shouldShow: () => { + const picker = this._picker.current; + return !!(picker && picker.inputElement) && picker.inputElement.value.indexOf('@') > -1; + }, + onExecute: () => { + const picker = this._picker.current; + const floatingPicker = picker && picker.floatingPicker.current; + if (floatingPicker) { + floatingPicker.forceResolveSuggestion(); + } + }, + ariaLabel: 'Use the typed address', + }, + { + renderItem: () => { + return
    Suggested Contacts
    ; + }, + shouldShow: this._shouldShowSuggestedContacts, + }, + ], + footerItemsProps: [ + { + renderItem: () => { + return
    No results
    ; + }, + shouldShow: () => { + const picker = this._picker.current; + const floatingPicker = picker && picker.floatingPicker.current; + return !!floatingPicker && floatingPicker.suggestions.length === 0; + }, + }, + { + renderItem: () => { + return
    Search for more
    ; + }, + onExecute: () => { + this.setState({ searchMoreAvailable: false }); + }, + shouldShow: () => { + return this.state.searchMoreAvailable && !this._shouldShowSuggestedContacts(); + }, + ariaLabel: 'Search more', + }, + ], + shouldSelectFirstItem: () => { + return !this._shouldShowSuggestedContacts(); + }, + }; + + this._floatingPickerProps = { + suggestionsStore: new SuggestionsStore(), + onResolveSuggestions: this._onFilterChanged, + getTextFromItem: (persona: IPersonaProps) => persona.text || '', + pickerSuggestionsProps: this._suggestionProps, + key: 'normal', + onRemoveSuggestion: this._onRemoveSuggestion, + onValidateInput: this._validateInput, + onZeroQuerySuggestion: this._returnMostRecentlyUsed, + showForceResolve: this._shouldShowForceResolve, + onInputChanged: this._onInputChanged, + onSuggestionsHidden: () => { + console.log('FLOATINGPICKER: hidden'); + }, + onSuggestionsShown: () => { + console.log('FLOATINGPICKER: shown'); + }, + }; + + this._selectedItemsListProps = { + onCopyItems: this._onCopyItems, + onExpandGroup: this._onExpandItem, + removeMenuItemText: 'Remove', + copyMenuItemText: 'Copy name', + editMenuItemText: 'Edit', + getEditingItemText: this._getEditingItemText, + onRenderFloatingPicker: FloatingPeoplePicker, + floatingPickerProps: this._floatingPickerProps, + }; + + this._focusZoneProps = { + shouldInputLoseFocusOnArrowKey: () => true, + handleTabKey: FocusZoneTabbableElements.all, + }; + } + + public render(): JSX.Element { + const theme = getTheme(); + this._classNames = mergeStyleSets({ + picker: { maxWidth: 400, marginBottom: 15 }, + headerItem: { + borderBottom: '1px solid ' + theme.palette.neutralLight, + padding: '8px 12px', + }, + footerItem: { + borderBottom: '1px solid ' + theme.palette.neutralLight, + height: 60, + paddingLeft: 12, + }, + to: { padding: '0 10px' }, + }); + + return ( +
    + {this._renderExtendedPicker()} + +
    + ); + } + + private _renderExtendedPicker(): JSX.Element { + return ( + console.log('onBlur called'), + onFocus: () => console.log('onFocus called'), + 'aria-label': 'People Picker', + }} + componentRef={this._picker} + headerComponent={this._renderHeader()} + focusZoneProps={this._focusZoneProps} + /> + ); + } + + private _renderHeader(): JSX.Element { + return ( +
    + To: +
    + ); + } + + private _getEditingItemText = (item: IExtendedPersonaProps): string => { + return item.text || ''; + }; + + private _onSetFocusButtonClicked = (): void => { + if (this._picker.current) { + this._picker.current.focus(); + } + }; + + private _onExpandItem = (item: IExtendedPersonaProps): void => { + const { currentlySelectedItems } = this.state; + const indexToRemove = currentlySelectedItems.indexOf(item); + const newItems = currentlySelectedItems; + newItems.splice(indexToRemove, 1, ...this._getExpandedGroupItems(item)); + this.setState({ currentlySelectedItems: newItems }); + }; + + private _onRemoveSuggestion = (item: IPersonaProps): void => { + const { peopleList, mostRecentlyUsed: mruState } = this.state; + const itemIndex = peopleList.indexOf(item); + const itemMruIndex = mruState.indexOf(item); + + const stateUpdate = {} as IPeoplePickerExampleState; + if (itemIndex >= 0) { + stateUpdate.peopleList = peopleList.slice(0, itemIndex).concat(peopleList.slice(itemIndex + 1)); + } + if (itemMruIndex >= 0) { + stateUpdate.mostRecentlyUsed = mruState.slice(0, itemMruIndex).concat(mruState.slice(itemMruIndex + 1)); + } + this.setState(stateUpdate); + }; + + private _onFilterChanged = ( + filterText: string, + currentPersonas?: IPersonaProps[], + ): Promise | null => { + let filteredPersonas: IPersonaProps[] = []; + if (filterText) { + filteredPersonas = this.state.peopleList.filter((item: IPersonaProps) => + _startsWith(item.text || '', filterText), + ); + filteredPersonas = this._removeDuplicates(filteredPersonas, currentPersonas); + } + + this.setState({ suggestionItems: filteredPersonas }); + return null; + }; + + private _returnMostRecentlyUsed = (): IPersonaProps[] | Promise | null => { + let { mostRecentlyUsed } = this.state; + const items = (this._picker.current && this._picker.current.items) || []; + mostRecentlyUsed = this._removeDuplicates(mostRecentlyUsed, items); + this.setState({ suggestionItems: mostRecentlyUsed }); + return null; + }; + + private _onCopyItems(items: IExtendedPersonaProps[]): string { + return items.map(item => item.text).join(', '); + } + + private _shouldShowForceResolve = (): boolean => { + const picker = this._picker.current; + const floatingPicker = picker && picker.floatingPicker.current; + return !!floatingPicker && this._validateInput(floatingPicker.inputText) && floatingPicker.suggestions.length === 0; + }; + + private _shouldShowSuggestedContacts = (): boolean => { + const picker = this._picker.current; + return !!(picker && picker.inputElement) && picker.inputElement.value === ''; + }; + + private _listContainsPersona(persona: IPersonaProps, personas?: IPersonaProps[]): boolean { + return !!personas && personas.some((item: IPersonaProps) => item.text === persona.text); + } + + private _removeDuplicates(personas: IPersonaProps[], possibleDupes?: IPersonaProps[]): IPersonaProps[] { + return personas.filter((persona: IPersonaProps) => !this._listContainsPersona(persona, possibleDupes)); + } + + private _onInputChanged = (): void => { + this.setState({ searchMoreAvailable: true }); + }; + + private _onItemAdded = (selectedSuggestion: IExtendedPersonaProps) => { + this.setState({ currentlySelectedItems: this.state.currentlySelectedItems.concat(selectedSuggestion) }); + }; + + private _onItemsRemoved = (items: IExtendedPersonaProps[]): void => { + const newItems = this.state.currentlySelectedItems.filter(value => items.indexOf(value) === -1); + this.setState({ currentlySelectedItems: newItems }); + }; + + private _validateInput = (input: string): boolean => { + return input.indexOf('@') !== -1; + }; + + private _getExpandedGroupItems(item: IExtendedPersonaProps): IExtendedPersonaProps[] { + return item.text === 'Group One' ? groupOne : item.text === 'Group Two' ? groupTwo : []; + } +} + +function _startsWith(text: string, filterText: string): boolean { + return text.toLowerCase().indexOf(filterText.toLowerCase()) === 0; +} diff --git a/packages/react-next/src/components/ExtendedPicker/examples/PeopleExampleData.ts b/packages/react-next/src/components/ExtendedPicker/examples/PeopleExampleData.ts new file mode 100644 index 0000000000000..4410466915ba4 --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/examples/PeopleExampleData.ts @@ -0,0 +1,474 @@ +// If this file is moved or split, the scripts for building codepen examples will likely need to be updated. + +/* eslint-disable import/no-extraneous-dependencies, deprecation/deprecation */ + +import { PersonaPresence } from 'office-ui-fabric-react/lib/Persona'; +import { IExtendedPersonaProps } from 'office-ui-fabric-react/lib/SelectedItemsList'; +import { TestImages } from '@uifabric/example-data'; + +/** @deprecated Use the version from `@uifabric/example-data` instead. */ +export const people: (IExtendedPersonaProps & { key: string | number })[] = [ + { + key: 1, + imageUrl: TestImages.personaFemale, + imageInitials: 'PV', + text: 'Annie Lindqvist', + secondaryText: 'Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 2, + imageUrl: TestImages.personaMale, + imageInitials: 'AR', + text: 'Aaron Reid', + secondaryText: 'Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 3, + imageUrl: TestImages.personaMale, + imageInitials: 'AL', + text: 'Alex Lundberg', + secondaryText: 'Software Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.dnd, + }, + { + key: 4, + imageUrl: TestImages.personaMale, + imageInitials: 'RK', + text: 'Roko Kolar', + secondaryText: 'Financial Analyst', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.offline, + }, + { + key: 5, + imageUrl: TestImages.personaMale, + imageInitials: 'CB', + text: 'Christian Bergqvist', + secondaryText: 'Sr. Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 6, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Valentina Lovric', + secondaryText: 'Design Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 7, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Maor Sharett', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.away, + }, + { + key: 8, + imageUrl: TestImages.personaFemale, + imageInitials: 'PV', + text: 'Anny Lindqvist', + secondaryText: 'Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 9, + imageUrl: TestImages.personaMale, + imageInitials: 'AR', + text: 'Aron Reid', + secondaryText: 'Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.dnd, + }, + { + key: 10, + imageUrl: TestImages.personaMale, + imageInitials: 'AL', + text: 'Alix Lundberg', + secondaryText: 'Software Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.offline, + }, + { + key: 11, + imageUrl: TestImages.personaMale, + imageInitials: 'RK', + text: 'Roko Kular', + secondaryText: 'Financial Analyst', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.none, + }, + { + key: 12, + imageUrl: TestImages.personaMale, + imageInitials: 'CB', + text: 'Christian Bergqvest', + secondaryText: 'Sr. Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 13, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Valintina Lovric', + secondaryText: 'Design Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 14, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Maor Sharet', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.blocked, + }, + { + key: 15, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Anny Lindqvest', + secondaryText: 'SDE', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.blocked, + }, + { + key: 16, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Alix Lunberg', + secondaryText: 'SE', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.away, + }, + { + key: 17, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Annie Lindqvest', + secondaryText: 'SDET', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 18, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Alixander Lundberg', + secondaryText: 'Senior Manager of SDET', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.offline, + }, + { + key: 19, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Anny Lundqvist', + secondaryText: 'Junior Manager of Software', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.away, + }, + { + key: 20, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Maor Shorett', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.blocked, + }, + { + key: 21, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Valentina Lovrics', + secondaryText: 'Design Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 22, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Maor Sharet', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 23, + imageUrl: TestImages.personaFemale, + imageInitials: 'VL', + text: 'Valentina Lovrecs', + secondaryText: 'Design Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.blocked, + }, + { + key: 24, + imageUrl: TestImages.personaMale, + imageInitials: 'MS', + text: 'Maor Sharitt', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.offline, + }, + { + key: 25, + imageUrl: './images/persona-male.png', + imageInitials: 'MS', + text: 'Maor Shariett', + secondaryText: 'Design Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 3:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 26, + imageUrl: './images/persona-female.png', + imageInitials: 'AL', + text: 'Alix Lundburg', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 3:00pm', + isValid: true, + presence: PersonaPresence.away, + }, + { + key: 27, + imageUrl: './images/persona-female.png', + imageInitials: 'VL', + text: 'Valantena Lovric', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 28, + imageUrl: './images/persona-female.png', + imageInitials: 'VL', + text: 'Velatine Lourvric', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 29, + imageUrl: './images/persona-female.png', + imageInitials: 'VL', + text: 'Valentyna Lovrique', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 30, + imageUrl: './images/persona-female.png', + imageInitials: 'AL', + text: 'Annie Lindquest', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.dnd, + }, + { + key: 31, + imageUrl: './images/persona-female.png', + imageInitials: 'AL', + text: 'Anne Lindquist', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.blocked, + }, + { + key: 32, + imageUrl: './images/persona-female.png', + imageInitials: 'AL', + text: 'Ann Lindqiest', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 33, + imageUrl: './images/persona-male.png', + imageInitials: 'AR', + text: 'Aron Reid', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.away, + }, + { + key: 34, + imageUrl: './images/persona-male.png', + imageInitials: 'AR', + text: 'Aaron Reed', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.offline, + }, + { + key: 35, + imageUrl: './images/persona-female.png', + imageInitials: 'AL', + text: 'Alix Lindberg', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 36, + imageUrl: './images/persona-male.png', + imageInitials: 'AL', + text: 'Alan Lindberg', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.busy, + }, + { + key: 37, + imageUrl: './images/persona-male.png', + imageInitials: 'MS', + text: 'Maor Sharit', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.offline, + }, + { + key: 38, + imageUrl: './images/persona-male.png', + imageInitials: 'MS', + text: 'Maorr Sherit', + secondaryText: 'UX Designer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 39, + imageUrl: './images/persona-male.png', + imageInitials: 'AL', + text: 'Alex Lindbirg', + secondaryText: 'Software Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.dnd, + }, + { + key: 40, + imageUrl: './images/persona-male.png', + imageInitials: 'AL', + text: 'Alex Lindbarg', + secondaryText: 'Software Developer', + tertiaryText: 'In a meeting', + optionalText: 'Available at 4:00pm', + isValid: true, + presence: PersonaPresence.online, + }, + { + key: 41, + imageInitials: 'GO', + text: 'Group One', + canExpand: true, + isValid: true, + }, + { + key: 42, + imageInitials: 'GT', + text: 'Group Two', + canExpand: true, + isValid: true, + }, +]; + +/** @deprecated Use the version from `@uifabric/example-data` instead. */ +export const mru: IExtendedPersonaProps[] = people.slice(0, 5); + +/** @deprecated Use the version from `@uifabric/example-data` instead. */ +export const groupOne: IExtendedPersonaProps[] = people.slice(6, 10); + +/** @deprecated Use the version from `@uifabric/example-data` instead. */ +export const groupTwo: IExtendedPersonaProps[] = people.slice(11, 16); diff --git a/packages/react-next/src/components/ExtendedPicker/index.ts b/packages/react-next/src/components/ExtendedPicker/index.ts new file mode 100644 index 0000000000000..4c9f8094c397f --- /dev/null +++ b/packages/react-next/src/components/ExtendedPicker/index.ts @@ -0,0 +1,3 @@ +export * from './BaseExtendedPicker'; +export * from './BaseExtendedPicker.types'; +export * from './PeoplePicker/ExtendedPeoplePicker'; diff --git a/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.scss b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.scss new file mode 100644 index 0000000000000..59410312ea24d --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.scss @@ -0,0 +1,10 @@ +.callout { + :global(.ms-Suggestions-itemButton) { + padding: 0px; + border: none; + } + + :global(.ms-Suggestions) { + min-width: 300px; + } +} diff --git a/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.test.tsx b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.test.tsx new file mode 100644 index 0000000000000..f282a1f8fbc04 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as renderer from 'react-test-renderer'; +import * as ReactTestUtils from 'react-dom/test-utils'; + +import { IBaseFloatingPickerProps } from './BaseFloatingPicker.types'; +import { BaseFloatingPicker } from './BaseFloatingPicker'; +import { SuggestionsStore } from './Suggestions/SuggestionsStore'; + +function onResolveSuggestions(text: string): ISimple[] { + return [ + 'black', + 'blue', + 'brown', + 'cyan', + 'green', + 'magenta', + 'mauve', + 'orange', + 'pink', + 'purple', + 'red', + 'rose', + 'violet', + 'white', + 'yellow', + ] + .filter((tag: string) => tag.toLowerCase().indexOf(text.toLowerCase()) === 0) + .map((item: string) => ({ key: item, name: item })); +} + +function onZeroQuerySuggestion(): ISimple[] { + return ['black', 'blue', 'brown', 'cyan'].map((item: string) => ({ key: item, name: item })); +} + +const basicSuggestionRenderer = (props: ISimple) => { + return
    {props.name}
    ; +}; + +export interface ISimple { + key: string; + name: string; +} + +export type TypedBaseFloatingPicker = BaseFloatingPicker>; + +describe('Pickers', () => { + describe('BaseFloatingPicker', () => { + const BaseFloatingPickerWithType = BaseFloatingPicker as new ( + props: IBaseFloatingPickerProps, + ) => BaseFloatingPicker>; + + it('renders BaseFloatingPicker correctly', () => { + const component = renderer.create( + ()} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('shows zero query options on empty input', () => { + const root = document.createElement('div'); + const input = document.createElement('input'); + document.body.appendChild(input); + document.body.appendChild(root); + + const picker: TypedBaseFloatingPicker = (ReactDOM.render( + ()} + onZeroQuerySuggestion={onZeroQuerySuggestion} + inputElement={input} + />, + root, + ) as unknown) as TypedBaseFloatingPicker; + + input.value = 'a'; + picker.onQueryStringChanged('a'); + + input.value = ''; + picker.onQueryStringChanged(''); + + expect(picker.suggestions.length).toEqual(4); + + ReactDOM.unmountComponentAtNode(root); + }); + + it('updates suggestions on query string changed', () => { + jest.useFakeTimers(); + const root = document.createElement('div'); + const input = document.createElement('input'); + document.body.appendChild(input); + document.body.appendChild(root); + + const picker: TypedBaseFloatingPicker = (ReactDOM.render( + ()} + inputElement={input} + />, + root, + ) as unknown) as TypedBaseFloatingPicker; + + input.value = 'b'; + picker.onQueryStringChanged('b'); + ReactTestUtils.act(() => { + jest.runAllTimers(); + }); + + expect(picker.suggestions.length).toEqual(3); + + ReactDOM.unmountComponentAtNode(root); + }); + }); +}); diff --git a/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.tsx b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.tsx new file mode 100644 index 0000000000000..0a328bf7f5aeb --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.tsx @@ -0,0 +1,374 @@ +import * as React from 'react'; +import * as stylesImport from './BaseFloatingPicker.scss'; +import { Async, initializeComponentRef, css, KeyCodes } from '../../Utilities'; +import { Callout } from '../../Callout'; +import { DirectionalHint } from '../../common/DirectionalHint'; +import { IBaseFloatingPicker, IBaseFloatingPickerProps } from './BaseFloatingPicker.types'; +import { ISuggestionModel } from '../../Pickers'; +import { ISuggestionsControlProps } from './Suggestions/Suggestions.types'; +import { SuggestionsControl } from './Suggestions/SuggestionsControl'; +import { SuggestionsStore } from './Suggestions/SuggestionsStore'; +/* eslint-disable */ +const styles: any = stylesImport; + +export interface IBaseFloatingPickerState { + queryString: string; + suggestionsVisible?: boolean; + didBind: boolean; +} + +export class BaseFloatingPicker> + extends React.Component + implements IBaseFloatingPicker { + protected selection: Selection; + + protected root = React.createRef(); + protected suggestionStore: SuggestionsStore; + protected suggestionsControl: React.RefObject> = React.createRef(); + protected SuggestionsControlOfProperType: new (props: ISuggestionsControlProps) => SuggestionsControl< + T + > = SuggestionsControl as new (props: ISuggestionsControlProps) => SuggestionsControl; + protected currentPromise: PromiseLike; + protected isComponentMounted: boolean = false; + + private _async: Async; + constructor(basePickerProps: P) { + super(basePickerProps); + + this._async = new Async(this); + initializeComponentRef(this); + + this.suggestionStore = basePickerProps.suggestionsStore; + this.state = { + queryString: '', + didBind: false, + }; + } + + public get inputText(): string { + return this.state.queryString; + } + + public get suggestions(): any[] { + return this.suggestionStore.suggestions; + } + + public forceResolveSuggestion(): void { + if (this.suggestionsControl.current && this.suggestionsControl.current.hasSuggestionSelected()) { + this.completeSuggestion(); + } else { + this._onValidateInput(); + } + } + + public get currentSelectedSuggestionIndex(): number { + return this.suggestionsControl.current ? this.suggestionsControl.current.currentSuggestionIndex : -1; + } + + public get isSuggestionsShown(): boolean { + return this.state.suggestionsVisible === undefined ? false : this.state.suggestionsVisible; + } + + public onQueryStringChanged = (queryString: string): void => { + if (queryString !== this.state.queryString) { + this.setState({ + queryString: queryString, + }); + + if (this.props.onInputChanged) { + (this.props.onInputChanged as (filter: string) => void)(queryString); + } + + this.updateValue(queryString); + } + }; + + public hidePicker = (): void => { + const wasShownBeforeUpdate = this.isSuggestionsShown; + + this.setState({ + suggestionsVisible: false, + }); + + if (this.props.onSuggestionsHidden && wasShownBeforeUpdate) { + this.props.onSuggestionsHidden(); + } + }; + + public showPicker = (updateValue: boolean = false): void => { + const wasShownBeforeUpdate = this.isSuggestionsShown; + this.setState({ + suggestionsVisible: true, + }); + + // Update the suggestions if updateValue == true + const value = this.props.inputElement ? this.props.inputElement.value : ''; + if (updateValue) { + this.updateValue(value); + } + + if (this.props.onSuggestionsShown && !wasShownBeforeUpdate) { + this.props.onSuggestionsShown(); + } + }; + + public componentDidMount(): void { + this._bindToInputElement(); + this.isComponentMounted = true; + + this._onResolveSuggestions = this._async.debounce(this._onResolveSuggestions, this.props.resolveDelay); + } + + public componentDidUpdate(): void { + this._bindToInputElement(); + } + + public componentWillUnmount(): void { + this._unbindFromInputElement(); + this.isComponentMounted = false; + } + + public UNSAFE_componentWillReceiveProps(newProps: IBaseFloatingPickerProps): void { + if (newProps.suggestionItems) { + this.updateSuggestions(newProps.suggestionItems); + } + } + + public completeSuggestion = (): void => { + if (this.suggestionsControl.current && this.suggestionsControl.current.hasSuggestionSelected()) { + this.onChange(this.suggestionsControl.current.currentSuggestion!.item); + } + }; + + public updateSuggestions(suggestions: T[], forceUpdate: boolean = false): void { + this.suggestionStore.updateSuggestions(suggestions); + + if (forceUpdate) { + this.forceUpdate(); + } + } + + public render(): JSX.Element { + const { className } = this.props; + return ( +
    + {this.renderSuggestions()} +
    + ); + } + + protected renderSuggestions(): JSX.Element | null { + const TypedSuggestionsControl = this.SuggestionsControlOfProperType; + return this.state.suggestionsVisible ? ( + + + + ) : null; + } + + protected onSelectionChange(): void { + this.forceUpdate(); + } + + protected updateValue(updatedValue: string): void { + if (updatedValue === '') { + this.updateSuggestionWithZeroState(); + } else { + this._onResolveSuggestions(updatedValue); + } + } + + protected updateSuggestionWithZeroState(): void { + if (this.props.onZeroQuerySuggestion) { + const onEmptyInputFocus = this.props.onZeroQuerySuggestion as (selectedItems?: T[]) => T[] | PromiseLike; + const suggestions: T[] | PromiseLike = onEmptyInputFocus(this.props.selectedItems); + this.updateSuggestionsList(suggestions); + } else { + this.hidePicker(); + } + } + + protected updateSuggestionsList(suggestions: T[] | PromiseLike): void { + const suggestionsArray: T[] = suggestions as T[]; + const suggestionsPromiseLike: PromiseLike = suggestions as PromiseLike; + + // Check to see if the returned value is an array, if it is then just pass it into the next function. + // If the returned value is not an array then check to see if it's a promise or PromiseLike. + // If it is then resolve it asynchronously. + if (Array.isArray(suggestionsArray)) { + this.updateSuggestions(suggestionsArray, true /*forceUpdate*/); + } else if (suggestionsPromiseLike && suggestionsPromiseLike.then) { + // Ensure that the promise will only use the callback if it was the most recent one. + const promise: PromiseLike = (this.currentPromise = suggestionsPromiseLike); + promise.then((newSuggestions: T[]) => { + // Only update if the next promise has not yet resolved and + // the floating picker is still mounted. + if (promise === this.currentPromise && this.isComponentMounted) { + this.updateSuggestions(newSuggestions, true /*forceUpdate*/); + } + }); + } + } + + protected onChange(item: T): void { + if (this.props.onChange) { + (this.props.onChange as (items: T) => void)(item); + } + } + + protected onSuggestionClick = (ev: React.MouseEvent, item: T, index: number): void => { + this.onChange(item); + this._updateSuggestionsVisible(false /*shouldShow*/); + }; + + protected onSuggestionRemove = (ev: React.MouseEvent, item: T, index: number): void => { + if (this.props.onRemoveSuggestion) { + (this.props.onRemoveSuggestion as (item: T) => void)(item); + } + + if (this.suggestionsControl.current) { + this.suggestionsControl.current.removeSuggestion(index); + } + }; + + protected onKeyDown = (ev: MouseEvent): void => { + if ( + !this.state.suggestionsVisible || + (this.props.inputElement && !(this.props.inputElement as HTMLElement).contains(ev.target as HTMLElement)) + ) { + return; + } + // eslint-disable-next-line deprecation/deprecation + const keyCode = ev.which; + switch (keyCode) { + case KeyCodes.escape: + this.hidePicker(); + ev.preventDefault(); + ev.stopPropagation(); + break; + + case KeyCodes.tab: + case KeyCodes.enter: + if ( + !ev.shiftKey && + !ev.ctrlKey && + this.suggestionsControl.current && + this.suggestionsControl.current.handleKeyDown(keyCode) + ) { + ev.preventDefault(); + ev.stopPropagation(); + } else { + this._onValidateInput(); + } + break; + + case KeyCodes.del: + if ( + this.props.onRemoveSuggestion && + this.suggestionsControl.current && + this.suggestionsControl.current.hasSuggestionSelected && + this.suggestionsControl.current.currentSuggestion && + ev.shiftKey + ) { + (this.props.onRemoveSuggestion as (item: T) => void)(this.suggestionsControl.current.currentSuggestion!.item); + + this.suggestionsControl.current.removeSuggestion(); + this.forceUpdate(); + ev.stopPropagation(); + } + break; + + case KeyCodes.up: + if (this.suggestionsControl.current && this.suggestionsControl.current.handleKeyDown(keyCode)) { + ev.preventDefault(); + ev.stopPropagation(); + this._updateActiveDescendant(); + } + break; + + case KeyCodes.down: + if (this.suggestionsControl.current && this.suggestionsControl.current.handleKeyDown(keyCode)) { + ev.preventDefault(); + ev.stopPropagation(); + this._updateActiveDescendant(); + } + break; + } + }; + + private _updateActiveDescendant(): void { + if (this.props.inputElement && this.suggestionsControl.current && this.suggestionsControl.current.selectedElement) { + const selectedElId = this.suggestionsControl.current.selectedElement.getAttribute('id'); + if (selectedElId) { + this.props.inputElement.setAttribute('aria-activedescendant', selectedElId as string); + } + } + } + + private _onResolveSuggestions(updatedValue: string): void { + const suggestions: T[] | PromiseLike | null = this.props.onResolveSuggestions( + updatedValue, + this.props.selectedItems, + ); + + this._updateSuggestionsVisible(true /*shouldShow*/); + if (suggestions !== null) { + this.updateSuggestionsList(suggestions); + } + } + + private _onValidateInput = (): void => { + if (this.state.queryString && this.props.onValidateInput && this.props.createGenericItem) { + const itemToConvert: ISuggestionModel = (this.props.createGenericItem as ( + input: string, + isValid: boolean, + ) => ISuggestionModel)( + this.state.queryString, + (this.props.onValidateInput as (input: string) => boolean)(this.state.queryString), + ); + const convertedItems = this.suggestionStore.convertSuggestionsToSuggestionItems([itemToConvert]); + this.onChange(convertedItems[0].item); + } + }; + + private _updateSuggestionsVisible(shouldShow: boolean): void { + if (shouldShow) { + this.showPicker(); + } else { + this.hidePicker(); + } + } + + private _bindToInputElement(): void { + if (this.props.inputElement && !this.state.didBind) { + this.props.inputElement.addEventListener('keydown', this.onKeyDown); + this.setState({ didBind: true }); + } + } + + private _unbindFromInputElement(): void { + if (this.props.inputElement && this.state.didBind) { + this.props.inputElement.removeEventListener('keydown', this.onKeyDown); + this.setState({ didBind: false }); + } + } +} diff --git a/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.types.ts b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.types.ts new file mode 100644 index 0000000000000..c0fc01bf76952 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/BaseFloatingPicker.types.ts @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { ISuggestionModel, ISuggestionItemProps } from '../../Pickers'; +import { ISuggestionsControlProps } from './Suggestions/Suggestions.types'; +import { SuggestionsStore } from './Suggestions/SuggestionsStore'; +import { IRefObject } from '../../Utilities'; +import { ICalloutProps } from '../Callout/Callout.types'; +/* eslint-disable */ + +export interface IBaseFloatingPicker { + /** Whether the suggestions are shown */ + isSuggestionsShown: boolean; + + /** On queryString changed */ + onQueryStringChanged: (input: string) => void; + + /** Hides the picker */ + hidePicker: () => void; + + /** Shows the picker + * @param updateValue - Optional param to indicate whether to update the query string + */ + showPicker: (updateValue?: boolean) => void; + + /** Gets the suggestions */ + suggestions: any[]; + + /** Gets the input text */ + inputText: string; +} + +// Type T is the type of the item that is displayed +// and searched for by the people picker. For example, if the picker is +// displaying persona's than type T could either be of Persona or Ipersona props +export interface IBaseFloatingPickerProps extends React.ClassAttributes { + componentRef?: IRefObject; + + /** + * The suggestions store + */ + suggestionsStore: SuggestionsStore; + + /** + * The suggestions to show on zero query, return null if using as a controlled component + */ + onZeroQuerySuggestion?: (selectedItems?: T[]) => T[] | PromiseLike | null; + + /** + * The input element to listen on events + */ + inputElement?: HTMLInputElement | null; + + /** + * Function that specifies how an individual suggestion item will appear. + */ + onRenderSuggestionsItem?: (props: T, itemProps: ISuggestionItemProps) => JSX.Element; + /** + * A callback for what should happen when a person types text into the input. + * Returns the already selected items so the resolver can filter them out. + * If used in conjunction with resolveDelay this will only kick off after the delay throttle. + * Return null if using as a controlled component + */ + onResolveSuggestions: (filter: string, selectedItems?: T[]) => T[] | PromiseLike | null; + + /** + * A callback for when the input has been changed + */ + onInputChanged?: (filter: string) => void; + + /** + * The delay time in ms before resolving suggestions, which is kicked off when input has been changed. + * e.g. If a second input change happens within the resolveDelay time, the timer will start over. + * Only until after the timer completes will onResolveSuggestions be called. + */ + resolveDelay?: number; + + /** + * A callback for when a suggestion is clicked + */ + onChange?: (item: T) => void; + + /** + * ClassName for the picker. + */ + className?: string; + /** + * The properties that will get passed to the Suggestions component. + */ + pickerSuggestionsProps?: IBaseFloatingPickerSuggestionProps; + + /** + * The properties that will get passed to the Callout component. + */ + pickerCalloutProps?: ICalloutProps; + + /** + * A callback for when an item is removed from the suggestion list + */ + onRemoveSuggestion?: (item: T) => void; + /** + * A function used to validate if raw text entered into the well can be added + */ + onValidateInput?: (input: string) => boolean; + /** + * The text to display while searching for more results in a limited suggestions list + */ + searchingText?: ((props: { input: string }) => string) | string; + + /** + * Function that specifies how arbitrary text entered into the well is handled. + */ + createGenericItem?: (input: string, isValid: boolean) => ISuggestionModel; + + /** + * The callback that should be called to see if the force resolve command should be shown + */ + showForceResolve?: () => boolean; + + /** + * The items that the base picker should currently display as selected. + * If this is provided then the picker will act as a controlled component. + */ + selectedItems?: T[]; + + /** + * A callback to get text from an item. Used to autofill text in the pickers. + */ + getTextFromItem?: (item: T, currentValue?: string) => string; + + /** + * Width for the suggestions callout + */ + calloutWidth?: number; + + /** + * The callback that should be called when the suggestions are shown + */ + onSuggestionsShown?: () => void; + + /** + * The callback that should be called when the suggestions are hiden + */ + onSuggestionsHidden?: () => void; + + /** + * If using as a controlled component, the items to show in the suggestion list + */ + suggestionItems?: T[]; +} + +/** + * Props which are passed on to the inner Suggestions component + */ +export type IBaseFloatingPickerSuggestionProps = Pick< + ISuggestionsControlProps, + 'shouldSelectFirstItem' | 'headerItemsProps' | 'footerItemsProps' | 'showRemoveButtons' +>; diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/FloatingPeoplePicker.doc.tsx b/packages/react-next/src/components/FloatingPicker/PeoplePicker/FloatingPeoplePicker.doc.tsx new file mode 100644 index 0000000000000..54439fd386104 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/FloatingPeoplePicker.doc.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { FloatingPeoplePickerTypesExample } from '../PeoplePicker/examples/FloatingPeoplePicker.Basic.Example'; + +import { IDocPageProps } from '../../../common/DocPage.types'; + +const FloatingPeoplePickerBasicExampleCode = require('!raw-loader!@fluentui/react-next/src/components/FloatingPicker/PeoplePicker/examples/FloatingPeoplePicker.Basic.Example.tsx') as string; + +export const FloatingPeoplePickerPageProps: IDocPageProps = { + title: 'FloatingPeoplePicker', + componentName: 'FloatingPeoplePicker', + componentUrl: + 'https://github.com/microsoft/fluentui/tree/master/packages/react-next/src/components/FloatingPeoplePicker', + examples: [ + { + title: 'Floating People Picker', + code: FloatingPeoplePickerBasicExampleCode, + view: , + }, + ], + propertiesTablesSources: [ + require('!raw-loader!@fluentui/react-next/src/components/FloatingPicker/BaseFloatingPicker.types.ts') as string, + ], + overview: require('!raw-loader!@fluentui/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerOverview.md') as string, + bestPractices: require('!raw-loader!@fluentui/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerBestPractices.md') as string, + dos: require('!raw-loader!@fluentui/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDos.md') as string, + donts: require('!raw-loader!@fluentui/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDonts.md') as string, + isHeaderVisible: true, + isFeedbackVisible: true, +}; diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/FloatingPeoplePicker.tsx b/packages/react-next/src/components/FloatingPicker/PeoplePicker/FloatingPeoplePicker.tsx new file mode 100644 index 0000000000000..b5121e52253bb --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/FloatingPeoplePicker.tsx @@ -0,0 +1,41 @@ +import { getRTL, getInitials } from '../../../Utilities'; +import { BaseFloatingPicker } from '../BaseFloatingPicker'; +import { IBaseFloatingPickerProps } from '../BaseFloatingPicker.types'; +import { SuggestionItemNormal } from './PeoplePickerItems/SuggestionItemDefault'; +import { IPersonaProps } from '../../../Persona'; +import './PeoplePicker.scss'; +import { IBasePickerSuggestionsProps, ISuggestionModel } from '../../../Pickers'; +/* eslint-disable */ + +/** + * {@docCategory FloatingPeoplePicker} + */ +export interface IPeopleFloatingPickerProps extends IBaseFloatingPickerProps {} + +/** + * {@docCategory FloatingPeoplePicker} + */ +export class BaseFloatingPeoplePicker extends BaseFloatingPicker {} + +export class FloatingPeoplePicker extends BaseFloatingPeoplePicker { + public static defaultProps: any = { + onRenderSuggestionsItem: (props: IPersonaProps, itemProps?: IBasePickerSuggestionsProps) => + SuggestionItemNormal({ ...props }, { ...itemProps }), + createGenericItem: createItem, + }; +} + +export function createItem(name: string, isValid: boolean): ISuggestionModel { + const personaToConvert: any = { + key: name, + primaryText: name, + imageInitials: '!', + isValid: isValid, + }; + + if (!isValid) { + personaToConvert.imageInitials = getInitials(name, getRTL()); + } + + return personaToConvert; +} diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePicker.scss b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePicker.scss new file mode 100644 index 0000000000000..e807b37335d2a --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePicker.scss @@ -0,0 +1,30 @@ +@import '~@fluentui/common-styles/dist/sass/common'; + +.resultContent { + display: table-row; + .resultItem { + display: table-cell; + vertical-align: bottom; + } +} + +.peoplePickerPersona { + width: 180px; + :global(.ms-Persona-details) { + width: 100%; + } +} + +.peoplePicker { + :global(.ms-BasePicker-text) { + min-height: 40px; + } +} + +.peoplePickerPersonaContent { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + padding: 7px 12px; +} diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/PickerItemsDefault.scss b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/PickerItemsDefault.scss new file mode 100644 index 0000000000000..d97cd9cd68e10 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/PickerItemsDefault.scss @@ -0,0 +1,116 @@ +@import '~@fluentui/common-styles/dist/sass/common'; + +.personaContainer { + @include focus-border(-2px); + border-radius: 15px; + display: inline-flex; + align-items: center; + background: $ms-color-neutralLighter; + margin: 4px; + cursor: default; + user-select: none; + max-width: 300px; + vertical-align: middle; + + &:hover { + background: $ms-color-neutralLight; + + .removeButton { + color: $ms-color-neutralPrimary; + } + } + + &.personaContainerIsSelected { + background: $ms-color-blue; + + // Setting global values here to override persona's normal behavior specifically in this selected variation + :global(.ms-Persona-primaryText) { + color: $ms-color-white; + + @include high-contrast { + color: HighlightText; + } + } + + .removeButton { + :global(.ms-Button-icon) { + color: $ms-color-white; + + @include high-contrast { + color: HighlightText; + } + } + + &:hover { + color: $ms-color-white; + background: $ms-color-themeDark; + } + } + + @include high-contrast { + border-color: Highlight; + background: Highlight; + -ms-high-contrast-adjust: none; + } + } + + &.validationError { + // Setting global values here to override persona's normal behavior in the error version + :global(.ms-Persona-primaryText) { + color: $ms-color-redDark; + border-bottom: 2px dotted $ms-color-redDark; + } + + :global(.ms-Persona-initials) { + font-size: 20px; + } + + &.personaContainerIsSelected { + background: $ms-color-redDark; + + :global(.ms-Persona-primaryText) { + color: $ms-color-white; + border-bottom: 2px dotted $ms-color-white; + } + + .removeButton:hover { + background: $ms-color-red; + } + } + } + + @include high-contrast { + border: 1px solid WindowText; + } + + .itemContent { + flex: 0 1 auto; + min-width: 0px; + + /** CSS below is needed for IE 11 to properly truncate long persona names in the picker **/ + max-width: 100%; + overflow-x: hidden; + } + + .removeButton { + border-radius: 15px; + flex: 0 0 auto; + width: 28px; + height: 28px; + flex-basis: 28px; + + &:hover { + background: $ms-color-neutralTertiaryAlt; + color: $ms-color-neutralDark; + } + } + + .personaDetails { + flex: 0 1 auto; + } +} + +.itemContainer { + display: inline-block; + vertical-align: top; +} diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/SelectedItemDefault.tsx b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/SelectedItemDefault.tsx new file mode 100644 index 0000000000000..656f2e6bcd0e4 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/SelectedItemDefault.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { css, getId } from '../../../../Utilities'; +import { Persona, PersonaSize, PersonaPresence } from '../../../../Persona'; +import { IPeoplePickerItemProps } from '../../../../ExtendedPicker'; +import { IconButton } from '../../../../compat/Button'; +import * as stylesImport from './PickerItemsDefault.scss'; +/* eslint-disable */ + +const styles: any = stylesImport; + +export const SelectedItemDefault: (props: IPeoplePickerItemProps) => JSX.Element = ( + peoplePickerItemProps: IPeoplePickerItemProps, +) => { + const { item, onRemoveItem, index, selected, removeButtonAriaLabel } = peoplePickerItemProps; + + const itemId = getId(); + const onClickIconButton = (removeItem: (() => void) | undefined): (() => void) => { + return (): void => { + if (removeItem) { + removeItem(); + } + }; + }; + + return ( +
    +
    + +
    + +
    + ); +}; diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/SuggestionItemDefault.tsx b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/SuggestionItemDefault.tsx new file mode 100644 index 0000000000000..59d6ba15cabc5 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/PeoplePickerItems/SuggestionItemDefault.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { css } from '../../../../Utilities'; +import { Persona, PersonaSize, IPersonaProps, PersonaPresence } from '../../../../Persona'; +import { IBasePickerSuggestionsProps, ISuggestionItemProps } from '../../../../Pickers'; +import * as stylesImport from '../PeoplePicker.scss'; + +export const SuggestionItemNormal: ( + persona: IPersonaProps, + suggestionProps?: IBasePickerSuggestionsProps, +) => JSX.Element = (personaProps: IPersonaProps, suggestionItemProps?: ISuggestionItemProps) => { + return ( +
    + +
    + ); +}; diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerBestPractices.md b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerBestPractices.md new file mode 100644 index 0000000000000..e2ba00d00c753 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerBestPractices.md @@ -0,0 +1,4 @@ +The FloatingPeoplePicker is used to select one or more entities, such as people or groups. Entry points for PeoplePickers +are typically specialized TextField-like input fields known as a "well", which are used to search for recipients from a list. +When a recipient is selected from the list, it is added to the well as a specialized Persona that can be interacted with or +removed. Clicking on a Persona from the well should invoke a PersonaCard or open a profile pane for that recipient. diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDonts.md b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDonts.md new file mode 100644 index 0000000000000..a30903a6e23c3 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDonts.md @@ -0,0 +1,2 @@ +- Use the FloatingPeoplePicker to select something other than people +- Use the FloatingPeoplePicker without sufficient space diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDos.md b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDos.md new file mode 100644 index 0000000000000..9311f7ac2b1a5 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerDos.md @@ -0,0 +1 @@ +- Use the FloatingPeoplePicker to quickly search for a few people diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerOverview.md b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerOverview.md new file mode 100644 index 0000000000000..90b332952b8f6 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/docs/FloatingPeoplePickerOverview.md @@ -0,0 +1 @@ +FloatingPeoplePicker are used to pick recipients but do not need a well or necessarily keep track of selected people diff --git a/packages/react-next/src/components/FloatingPicker/PeoplePicker/examples/FloatingPeoplePicker.Basic.Example.tsx b/packages/react-next/src/components/FloatingPicker/PeoplePicker/examples/FloatingPeoplePicker.Basic.Example.tsx new file mode 100644 index 0000000000000..9b824c130151a --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/PeoplePicker/examples/FloatingPeoplePicker.Basic.Example.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona'; +import { + IBaseFloatingPicker, + IBaseFloatingPickerSuggestionProps, + FloatingPeoplePicker, + SuggestionsStore, +} from 'office-ui-fabric-react/lib/FloatingPicker'; +import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; +import { people } from '@uifabric/example-data'; +import { useConst } from '@uifabric/react-hooks'; +/* eslint-disable */ + +const searchBoxWrapperStyling = { width: 208 }; + +const getTextFromItem = (persona: IPersonaProps): string => { + return persona.text || ''; +}; + +const listContainsPersona = (persona: IPersonaProps, personas?: IPersonaProps[]): boolean => { + return !!personas && personas.some((item: IPersonaProps) => item.text === persona.text); +}; + +const validateInput = (input: string): boolean => { + return input.indexOf('@') !== -1; +}; + +const startsWith = (text: string, filterText: string): boolean => { + return text.toLowerCase().indexOf(filterText.toLowerCase()) === 0; +}; + +export const FloatingPeoplePickerTypesExample: React.FunctionComponent = () => { + const inputElementRef = React.useRef(null); + const suggestionsStore = useConst(() => new SuggestionsStore()); + const [peopleList, setPeopleList] = React.useState(people); + const [searchValue, setSearchValue] = React.useState(''); + const picker = React.useRef(null); + + const onFocus = (): void => { + if (picker.current) { + picker.current.showPicker(); + } + }; + + const onSearchChange = (ev: React.ChangeEvent, newValue: string): void => { + if (newValue !== searchValue && picker.current) { + setSearchValue(newValue); + picker.current.onQueryStringChanged(newValue); + } + }; + + const onPickerChange = (selectedSuggestion: IPersonaProps): void => { + setSearchValue(selectedSuggestion.text || ''); + if (picker.current) { + picker.current.hidePicker(); + } + }; + + const onRemoveSuggestion = (item: any): void => { + const itemIndex = peopleList.indexOf(item); + if (itemIndex >= 0) { + setPeopleList(peopleList.slice(0, itemIndex).concat(peopleList.slice(itemIndex + 1))); + } + }; + + const suggestionProps: IBaseFloatingPickerSuggestionProps = useConst(() => { + return { + footerItemsProps: [ + { + renderItem: () => { + return <>Showing {picker.current ? picker.current.suggestions.length : 0} results; + }, + shouldShow: () => { + return !!picker.current && picker.current.suggestions.length > 0; + }, + }, + ], + }; + }); + + const onFilterChanged = (filterText: string, currentPersonas?: IPersonaProps[]): IPersonaProps[] => { + if (filterText) { + // Filter by items starting with the current filter text, then remove duplicates + return peopleList + .filter((item: IPersonaProps) => startsWith(item.text || '', filterText)) + .filter((persona: IPersonaProps) => !listContainsPersona(persona, currentPersonas)); + } + return []; + }; + + return ( + <> +
    + +
    + + + ); +}; diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionStore.test.tsx b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionStore.test.tsx new file mode 100644 index 0000000000000..930d332b69147 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionStore.test.tsx @@ -0,0 +1,95 @@ +import { SuggestionsStore } from './SuggestionsStore'; + +type IMockSuggestion = { + customName: string; + name?: string; + primaryText?: string; +}; + +const getCustomName = (d: IMockSuggestion): string => d.customName + ' label'; + +describe('SuggestionsStore', () => { + describe('when getting the aria-label', () => { + it('uses getAriaLabel for item text when it is set', () => { + const store = new SuggestionsStore({ + getAriaLabel: getCustomName, + }); + store.updateSuggestions([ + { + customName: 'me', + }, + ]); + + expect(store.getSuggestionAtIndex(0)).toEqual({ + item: { + customName: 'me', + }, + selected: false, + ariaLabel: 'me label', + }); + }); + + it('prioritizes getAriaLabel over name', () => { + const store = new SuggestionsStore({ + getAriaLabel: getCustomName, + }); + store.updateSuggestions([ + { + customName: 'u', + name: 'name', + }, + ]); + + expect(store.getSuggestionAtIndex(0)).toEqual({ + item: { + customName: 'u', + name: 'name', + }, + selected: false, + ariaLabel: 'u label', + }); + }); + + it('prioritizes getAriaLabel over primaryText', () => { + const store = new SuggestionsStore({ + getAriaLabel: getCustomName, + }); + store.updateSuggestions([ + { + customName: 'us', + primaryText: 'primaryText', + }, + ]); + + expect(store.getSuggestionAtIndex(0)).toEqual({ + item: { + customName: 'us', + primaryText: 'primaryText', + }, + selected: false, + ariaLabel: 'us label', + }); + }); + + it('prioritizes name over primaryText if getAriaLabel is unset', () => { + const store = new SuggestionsStore(); + store.updateSuggestions([ + { + customName: 'us', + primaryText: 'primaryText', + name: 'name', + }, + ]); + + expect(store.getSuggestionAtIndex(0)).toEqual({ + item: { + customName: 'us', + primaryText: 'primaryText', + name: 'name', + }, + selected: false, + ariaLabel: 'name', + }); + }); + }); +}); diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/Suggestions.types.ts b/packages/react-next/src/components/FloatingPicker/Suggestions/Suggestions.types.ts new file mode 100644 index 0000000000000..6d3e00e9354af --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/Suggestions.types.ts @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { ISuggestionModel, ISuggestionItemProps } from '../../../Pickers'; +import { IPersonaProps } from '../../../Persona'; +import { IRefObject } from '../../../Utilities'; +/* eslint-disable */ + +export interface ISuggestionsCoreProps extends React.ClassAttributes { + /** + * Gets the component ref. + */ + componentRef?: IRefObject<{}>; + /** + * How the suggestion should look in the suggestion list. + */ + onRenderSuggestion?: (props: T, suggestionItemProps: ISuggestionItemProps) => JSX.Element; + + /** + * What should occur when a suggestion is clicked + */ + onSuggestionClick: (ev?: React.MouseEvent, item?: any, index?: number) => void; + /** + * The list of Suggestions that will be displayed + */ + suggestions: ISuggestionModel[]; + /** + * Function to fire when one of the optional remove buttons on a suggestion is clicked. + */ + onSuggestionRemove?: (ev?: React.MouseEvent, item?: IPersonaProps, index?: number) => void; + /** + * Screen reader message to read when there are suggestions available. + */ + suggestionsAvailableAlertText?: string; + /** + * An ARIA label for the container that is the parent of the suggestions. + */ + suggestionsContainerAriaLabel?: string; + /** + * the classname of the suggestionitem. + */ + suggestionsItemClassName?: string; + /** + * Maximum number of suggestions to show in the full suggestion list. + */ + resultsMaximumNumber?: number; + /** + * Indicates whether to show a button with each suggestion to remove that suggestion. + */ + showRemoveButtons?: boolean; + /** + * Indicates whether to loop around to the top or bottom of the suggestions + * on calling nextSuggestion and previousSuggestion, respectively + */ + shouldLoopSelection: boolean; +} + +export interface ISuggestionsControlProps extends React.ClassAttributes, ISuggestionsCoreProps { + /** + * An ARIA label for the container that is the parent of the suggestions header items. + */ + suggestionsHeaderContainerAriaLabel?: string; + /** + * An ARIA label for the container that is the parent of the suggestions footer items. + */ + suggestionsFooterContainerAriaLabel?: string; + /** + * The header items props + */ + headerItemsProps?: ISuggestionsHeaderFooterProps[]; + /** + * The footer items props + */ + footerItemsProps?: ISuggestionsHeaderFooterProps[]; + /** + * Whether or not the first selectable item in the suggestions list should be selected + */ + shouldSelectFirstItem?: () => boolean; + /** + * The CSS classname of the suggestions list. + */ + className?: string; + /** + * Completes the suggestion + */ + completeSuggestion: () => void; +} + +export interface ISuggestionsHeaderFooterProps { + renderItem: () => JSX.Element; + onExecute?: () => void; + className?: string; + ariaLabel?: string; + shouldShow: () => boolean; +} + +export interface ISuggestionsHeaderFooterItemProps { + componentRef?: IRefObject<{}>; + renderItem: () => JSX.Element; + onExecute?: () => void; + isSelected: boolean; + id: string; + className: string | undefined; +} diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.scss b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.scss new file mode 100644 index 0000000000000..dacc60f4cb507 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.scss @@ -0,0 +1,82 @@ +@import '~@fluentui/common-styles/dist/sass/common'; + +.root { + min-width: 260px; +} + +.actionButton { + background: none; + background-color: transparent; + border: 0; + cursor: pointer; + margin: 0; + padding: 0px; + position: relative; + @include text-align(left); + width: 100%; + font-size: 12px; + &:hover { + background-color: $ms-color-neutralLighter; + cursor: pointer; + } + // TODO: Works in Chrome, but not working in IE + &:focus, + &:active { + background-color: $ms-color-themeLight; + } + + :global(.ms-Button-icon) { + font-size: 16px; + width: 25px; + } + + :global(.ms-Button-label) { + @include margin(0, 4px, 0, 9px); + } +} + +.buttonSelected { + background-color: $ms-color-themeLighter; + + &:hover { + background-color: $ms-color-themeLight; + cursor: pointer; + } +} + +.suggestionsTitle { + font-size: $ms-font-size-s; +} + +.suggestionsSpinner { + margin: 5px 0; + @include padding-left(14px); + @include text-align(left); + white-space: nowrap; + line-height: 20px; + font-size: 12px; + + :global(.ms-Spinner-circle) { + display: inline-block; + vertical-align: middle; + } + :global(.ms-Spinner-label) { + display: inline-block; + @include margin(0px, 10px, 0, 16px); + vertical-align: middle; + } +} + +.itemButton { + height: 100%; + width: 100%; + padding: 7px 12px; + + @include high-contrast { + color: WindowText; + } +} + +.screenReaderOnly { + @include ms-screenReaderOnly; +} diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.tests.tsx b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.tests.tsx new file mode 100644 index 0000000000000..b9f6bde6a6e6b --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.tests.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import { SuggestionsControl } from './SuggestionsControl'; + +const doNothing = () => { + return; +}; + +describe('Pickers', () => { + describe('SuggestionsControl', () => { + it('renders header/footer items with the provided className', () => { + const root = document.createElement('div'); + ReactDOM.render( +
    , + shouldShow: () => true, + }, + ]} + footerItemsProps={[ + { + className: 'footer-item-wrapper', + renderItem: () =>
    , + shouldShow: () => true, + }, + ]} + completeSuggestion={doNothing} + suggestions={[]} + shouldLoopSelection={true} + onSuggestionClick={doNothing} + />, + root, + ); + + expect(root.querySelector('.header-item-wrapper .header-item-inner')).not.toBe(null); + expect(root.querySelector('.footer-item-wrapper .footer-item-inner')).not.toBe(null); + + ReactDOM.unmountComponentAtNode(root); + }); + }); +}); diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.tsx b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.tsx new file mode 100644 index 0000000000000..53ad1a66b245f --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsControl.tsx @@ -0,0 +1,495 @@ +import * as React from 'react'; +import { css, KeyCodes, initializeComponentRef } from '../../../Utilities'; +import { IButton } from '../../../compat/Button'; +import { ISuggestionModel } from '../../../Pickers'; +import { + ISuggestionsHeaderFooterItemProps, + ISuggestionsControlProps, + ISuggestionsCoreProps, + ISuggestionsHeaderFooterProps, +} from './Suggestions.types'; +import { SuggestionsCore } from './SuggestionsCore'; +import * as stylesImport from './SuggestionsControl.scss'; +import { hiddenContentStyle, mergeStyles } from '../../../Styling'; +/* eslint-disable */ + +const styles: any = stylesImport; + +export enum SuggestionItemType { + header, + suggestion, + footer, +} + +export interface ISuggestionsControlState { + selectedHeaderIndex: number; + selectedFooterIndex: number; + suggestions: ISuggestionModel[]; +} + +export class SuggestionsHeaderFooterItem extends React.Component { + constructor(props: ISuggestionsHeaderFooterItemProps) { + super(props); + + initializeComponentRef(this); + } + + public render(): JSX.Element { + const { renderItem, onExecute, isSelected, id, className } = this.props; + return onExecute ? ( +
    + {renderItem()} +
    + ) : ( +
    + {renderItem()} +
    + ); + } +} + +/** + * Class when used with SuggestionsStore, renders a suggestions control with customizable headers and footers + */ +export class SuggestionsControl extends React.Component, ISuggestionsControlState> { + protected _forceResolveButton: IButton; + protected _searchForMoreButton: IButton; + protected _selectedElement = React.createRef(); + protected _suggestions = React.createRef>(); + private SuggestionsOfProperType: new (props: ISuggestionsCoreProps) => SuggestionsCore< + T + > = SuggestionsCore as new (props: ISuggestionsCoreProps) => SuggestionsCore; + + constructor(suggestionsProps: ISuggestionsControlProps) { + super(suggestionsProps); + + initializeComponentRef(this); + this.state = { + selectedHeaderIndex: -1, + selectedFooterIndex: -1, + suggestions: suggestionsProps.suggestions, + }; + } + + public componentDidMount(): void { + this.resetSelectedItem(); + } + + public componentDidUpdate(): void { + this.scrollSelected(); + } + + public UNSAFE_componentWillReceiveProps(newProps: ISuggestionsControlProps): void { + if (newProps.suggestions) { + this.setState({ suggestions: newProps.suggestions }, () => { + this.resetSelectedItem(); + }); + } + } + + public componentWillUnmount(): void { + this._suggestions.current?.deselectAllSuggestions(); + } + + public render(): JSX.Element { + const { className, headerItemsProps, footerItemsProps, suggestionsAvailableAlertText } = this.props; + + const screenReaderTextStyles = mergeStyles(hiddenContentStyle); + const shouldAlertSuggestionsAvailableText = + this.state.suggestions && this.state.suggestions.length > 0 && suggestionsAvailableAlertText; + + return ( +
    + {headerItemsProps && this.renderHeaderItems()} + {this._renderSuggestions()} + {footerItemsProps && this.renderFooterItems()} + {shouldAlertSuggestionsAvailableText ? ( + + {suggestionsAvailableAlertText} + + ) : null} +
    + ); + } + + public get currentSuggestion(): ISuggestionModel | undefined { + return this._suggestions.current?.getCurrentItem() || undefined; + } + + public get currentSuggestionIndex(): number { + return this._suggestions.current ? this._suggestions.current.currentIndex : -1; + } + + public get selectedElement(): HTMLDivElement | undefined { + return this._selectedElement.current ? this._selectedElement.current : this._suggestions.current?.selectedElement; + } + + public hasSuggestionSelected(): boolean { + return this._suggestions.current?.hasSuggestionSelected() || false; + } + + public hasSelection(): boolean { + const { selectedHeaderIndex, selectedFooterIndex } = this.state; + return selectedHeaderIndex !== -1 || this.hasSuggestionSelected() || selectedFooterIndex !== -1; + } + + public executeSelectedAction(): void { + const { headerItemsProps, footerItemsProps } = this.props; + const { selectedHeaderIndex, selectedFooterIndex } = this.state; + + if (headerItemsProps && selectedHeaderIndex !== -1 && selectedHeaderIndex < headerItemsProps.length) { + const selectedHeaderItem = headerItemsProps[selectedHeaderIndex]; + if (selectedHeaderItem.onExecute) { + selectedHeaderItem.onExecute(); + } + } else if (this._suggestions.current?.hasSuggestionSelected()) { + this.props.completeSuggestion(); + } else if (footerItemsProps && selectedFooterIndex !== -1 && selectedFooterIndex < footerItemsProps.length) { + const selectedFooterItem = footerItemsProps[selectedFooterIndex]; + if (selectedFooterItem.onExecute) { + selectedFooterItem.onExecute(); + } + } + } + + public removeSuggestion(index?: number): void { + this._suggestions.current?.removeSuggestion(index ? index : this._suggestions.current?.currentIndex); + } + + /** + * Handles the key down, returns true, if the event was handled, false otherwise + * @param keyCode - The keyCode to handle + */ + public handleKeyDown(keyCode: number): boolean { + const { selectedHeaderIndex, selectedFooterIndex } = this.state; + let isKeyDownHandled = false; + if (keyCode === KeyCodes.down) { + if ( + selectedHeaderIndex === -1 && + !this._suggestions.current?.hasSuggestionSelected() && + selectedFooterIndex === -1 + ) { + this.selectFirstItem(); + } else if (selectedHeaderIndex !== -1) { + this.selectNextItem(SuggestionItemType.header); + isKeyDownHandled = true; + } else if (this._suggestions.current?.hasSuggestionSelected()) { + this.selectNextItem(SuggestionItemType.suggestion); + isKeyDownHandled = true; + } else if (selectedFooterIndex !== -1) { + this.selectNextItem(SuggestionItemType.footer); + isKeyDownHandled = true; + } + } else if (keyCode === KeyCodes.up) { + if ( + selectedHeaderIndex === -1 && + !this._suggestions.current?.hasSuggestionSelected() && + selectedFooterIndex === -1 + ) { + this.selectLastItem(); + } else if (selectedHeaderIndex !== -1) { + this.selectPreviousItem(SuggestionItemType.header); + isKeyDownHandled = true; + } else if (this._suggestions.current?.hasSuggestionSelected()) { + this.selectPreviousItem(SuggestionItemType.suggestion); + isKeyDownHandled = true; + } else if (selectedFooterIndex !== -1) { + this.selectPreviousItem(SuggestionItemType.footer); + isKeyDownHandled = true; + } + } else if (keyCode === KeyCodes.enter || keyCode === KeyCodes.tab) { + if (this.hasSelection()) { + this.executeSelectedAction(); + isKeyDownHandled = true; + } + } + + return isKeyDownHandled; + } + + // TODO get the element to scroll into view properly regardless of direction. + public scrollSelected(): void { + if (this._selectedElement.current) { + this._selectedElement.current.scrollIntoView(false); + } + } + + protected renderHeaderItems(): JSX.Element | null { + const { headerItemsProps, suggestionsHeaderContainerAriaLabel } = this.props; + const { selectedHeaderIndex } = this.state; + + return headerItemsProps ? ( +
    + {headerItemsProps.map((headerItemProps: ISuggestionsHeaderFooterProps, index: number) => { + const isSelected = selectedHeaderIndex !== -1 && selectedHeaderIndex === index; + return headerItemProps.shouldShow() ? ( +
    + +
    + ) : null; + })} +
    + ) : null; + } + + protected renderFooterItems(): JSX.Element | null { + const { footerItemsProps, suggestionsFooterContainerAriaLabel } = this.props; + const { selectedFooterIndex } = this.state; + return footerItemsProps ? ( +
    + {footerItemsProps.map((footerItemProps: ISuggestionsHeaderFooterProps, index: number) => { + const isSelected = selectedFooterIndex !== -1 && selectedFooterIndex === index; + return footerItemProps.shouldShow() ? ( +
    + +
    + ) : null; + })} +
    + ) : null; + } + + protected _renderSuggestions(): JSX.Element { + const TypedSuggestions = this.SuggestionsOfProperType; + + return ; + } + + /** + * Selects the next selectable item + */ + protected selectNextItem(itemType: SuggestionItemType, originalItemType?: SuggestionItemType): void { + // If the recursive calling has not found a selectable item in the other suggestion item type groups + // And the method is being called again with the original item type, + // Select the first selectable item of this suggestion item type group (could be the currently selected item) + if (itemType === originalItemType) { + this._selectNextItemOfItemType(itemType); + return; + } + + const startedItemType = originalItemType !== undefined ? originalItemType : itemType; + + // Try to set the selection to the next selectable item, of the same suggestion item type group + // If this is the original item type, use the current index + const selectionChanged = this._selectNextItemOfItemType( + itemType, + startedItemType === itemType ? this._getCurrentIndexForType(itemType) : undefined, + ); + + // If the selection did not change, try to select from the next suggestion type group + if (!selectionChanged) { + this.selectNextItem(this._getNextItemSectionType(itemType), startedItemType); + } + } + + /** + * Selects the previous selectable item + */ + protected selectPreviousItem(itemType: SuggestionItemType, originalItemType?: SuggestionItemType): void { + // If the recursive calling has not found a selectable item in the other suggestion item type groups + // And the method is being called again with the original item type, + // Select the last selectable item of this suggestion item type group (could be the currently selected item) + if (itemType === originalItemType) { + this._selectPreviousItemOfItemType(itemType); + return; + } + + const startedItemType = originalItemType !== undefined ? originalItemType : itemType; + + // Try to set the selection to the previous selectable item, of the same suggestion item type group + const selectionChanged = this._selectPreviousItemOfItemType( + itemType, + startedItemType === itemType ? this._getCurrentIndexForType(itemType) : undefined, + ); + + // If the selection did not change, try to select from the previous suggestion type group + if (!selectionChanged) { + this.selectPreviousItem(this._getPreviousItemSectionType(itemType), startedItemType); + } + } + + /** + * Resets the selected state and selects the first selectable item + */ + protected resetSelectedItem(): void { + this.setState({ selectedHeaderIndex: -1, selectedFooterIndex: -1 }); + this._suggestions.current?.deselectAllSuggestions(); + + // Select the first item if the shouldSelectFirstItem prop is not set or it is set and it returns true + if (this.props.shouldSelectFirstItem === undefined || this.props.shouldSelectFirstItem()) { + this.selectFirstItem(); + } + } + + /** + * Selects the first item + */ + protected selectFirstItem(): void { + if (this._selectNextItemOfItemType(SuggestionItemType.header)) { + return; + } + + if (this._selectNextItemOfItemType(SuggestionItemType.suggestion)) { + return; + } + + this._selectNextItemOfItemType(SuggestionItemType.footer); + } + + /** + * Selects the last item + */ + protected selectLastItem(): void { + if (this._selectPreviousItemOfItemType(SuggestionItemType.footer)) { + return; + } + + if (this._selectPreviousItemOfItemType(SuggestionItemType.suggestion)) { + return; + } + + this._selectPreviousItemOfItemType(SuggestionItemType.header); + } + + /** + * Selects the next item in the suggestion item type group, given the current index + * If none is able to be selected, returns false, otherwise returns true + * @param itemType - The suggestion item type + * @param currentIndex - The current index, default is -1 + */ + private _selectNextItemOfItemType(itemType: SuggestionItemType, currentIndex: number = -1): boolean { + if (itemType === SuggestionItemType.suggestion) { + if (this.state.suggestions.length > currentIndex + 1) { + this._suggestions.current?.setSelectedSuggestion(currentIndex + 1); + this.setState({ selectedHeaderIndex: -1, selectedFooterIndex: -1 }); + return true; + } + } else { + const isHeader = itemType === SuggestionItemType.header; + const itemProps = isHeader ? this.props.headerItemsProps : this.props.footerItemsProps; + + if (itemProps && itemProps.length > currentIndex + 1) { + for (let i = currentIndex + 1; i < itemProps.length; i++) { + const item = itemProps[i]; + if (item.onExecute && item.shouldShow()) { + this.setState({ selectedHeaderIndex: isHeader ? i : -1 }); + this.setState({ selectedFooterIndex: isHeader ? -1 : i }); + this._suggestions.current?.deselectAllSuggestions(); + return true; + } + } + } + } + + return false; + } + + /** + * Selects the previous item in the suggestion item type group, given the current index + * If none is able to be selected, returns false, otherwise returns true + * @param itemType - The suggestion item type + * @param currentIndex - The current index. If none is provided, the default is the items length of specified type + */ + private _selectPreviousItemOfItemType(itemType: SuggestionItemType, currentIndex?: number): boolean { + if (itemType === SuggestionItemType.suggestion) { + const index = currentIndex !== undefined ? currentIndex : this.state.suggestions.length; + if (index > 0) { + this._suggestions.current?.setSelectedSuggestion(index - 1); + this.setState({ selectedHeaderIndex: -1, selectedFooterIndex: -1 }); + return true; + } + } else { + const isHeader = itemType === SuggestionItemType.header; + const itemProps = isHeader ? this.props.headerItemsProps : this.props.footerItemsProps; + if (itemProps) { + const index = currentIndex !== undefined ? currentIndex : itemProps.length; + if (index > 0) { + for (let i = index - 1; i >= 0; i--) { + const item = itemProps[i]; + if (item.onExecute && item.shouldShow()) { + this.setState({ selectedHeaderIndex: isHeader ? i : -1 }); + this.setState({ selectedFooterIndex: isHeader ? -1 : i }); + this._suggestions.current?.deselectAllSuggestions(); + return true; + } + } + } + } + } + + return false; + } + + private _getCurrentIndexForType(itemType: SuggestionItemType): number { + switch (itemType) { + case SuggestionItemType.header: + return this.state.selectedHeaderIndex; + case SuggestionItemType.suggestion: + return this._suggestions.current!.currentIndex; + case SuggestionItemType.footer: + return this.state.selectedFooterIndex; + } + } + + private _getNextItemSectionType(itemType: SuggestionItemType): SuggestionItemType { + switch (itemType) { + case SuggestionItemType.header: + return SuggestionItemType.suggestion; + case SuggestionItemType.suggestion: + return SuggestionItemType.footer; + case SuggestionItemType.footer: + return SuggestionItemType.header; + } + } + + private _getPreviousItemSectionType(itemType: SuggestionItemType): SuggestionItemType { + switch (itemType) { + case SuggestionItemType.header: + return SuggestionItemType.footer; + case SuggestionItemType.suggestion: + return SuggestionItemType.header; + case SuggestionItemType.footer: + return SuggestionItemType.suggestion; + } + } +} diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsCore.scss b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsCore.scss new file mode 100644 index 0000000000000..137dad4f464de --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsCore.scss @@ -0,0 +1,23 @@ +@import '~@fluentui/common-styles/dist/sass/common'; + +.suggestionsContainer { + overflow-y: auto; + overflow-x: hidden; + max-height: 300px; + + :global(.ms-Suggestion-item) { + &:hover { + background-color: $ms-color-neutralLighter; + cursor: pointer; + } + } + + :global(.is-suggested) { + background-color: $ms-color-themeLighter; + + &:hover { + background-color: $ms-color-themeLight; + cursor: pointer; + } + } +} diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsCore.tsx b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsCore.tsx new file mode 100644 index 0000000000000..0aad8c943aa66 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsCore.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { initializeComponentRef, css } from '../../../Utilities'; +import { ISuggestionItemProps, SuggestionsItem, ISuggestionModel } from '../../../Pickers'; +import { ISuggestionsCoreProps } from './Suggestions.types'; +import * as stylesImport from './SuggestionsCore.scss'; + +/* eslint-disable */ +const styles: any = stylesImport; + +/** + * Class when used with SuggestionsStore, renders a basic suggestions control + */ +export class SuggestionsCore extends React.Component, {}> { + public currentIndex: number; + public currentSuggestion: ISuggestionModel | undefined; + protected _selectedElement = React.createRef(); + private SuggestionsItemOfProperType: new (props: ISuggestionItemProps) => SuggestionsItem = SuggestionsItem; + + constructor(suggestionsProps: ISuggestionsCoreProps) { + super(suggestionsProps); + + initializeComponentRef(this); + this.currentIndex = -1; + } + + /** + * Increments the selected suggestion index + */ + public nextSuggestion(): boolean { + const { suggestions } = this.props; + + if (suggestions && suggestions.length > 0) { + if (this.currentIndex === -1) { + this.setSelectedSuggestion(0); + return true; + } else if (this.currentIndex < suggestions.length - 1) { + this.setSelectedSuggestion(this.currentIndex + 1); + return true; + } else if (this.props.shouldLoopSelection && this.currentIndex === suggestions.length - 1) { + this.setSelectedSuggestion(0); + return true; + } + } + + return false; + } + + /** + * Decrements the selected suggestion index + */ + public previousSuggestion(): boolean { + const { suggestions } = this.props; + + if (suggestions && suggestions.length > 0) { + if (this.currentIndex === -1) { + this.setSelectedSuggestion(suggestions.length - 1); + return true; + } else if (this.currentIndex > 0) { + this.setSelectedSuggestion(this.currentIndex - 1); + return true; + } else if (this.props.shouldLoopSelection && this.currentIndex === 0) { + this.setSelectedSuggestion(suggestions.length - 1); + return true; + } + } + + return false; + } + + public get selectedElement(): HTMLDivElement | undefined { + return this._selectedElement.current || undefined; + } + + public getCurrentItem(): ISuggestionModel { + return this.props.suggestions[this.currentIndex]; + } + + public getSuggestionAtIndex(index: number): ISuggestionModel { + return this.props.suggestions[index]; + } + + public hasSuggestionSelected(): boolean { + return this.currentIndex !== -1 && this.currentIndex < this.props.suggestions.length; + } + + public removeSuggestion(index: number): void { + this.props.suggestions.splice(index, 1); + } + + public deselectAllSuggestions(): void { + if (this.currentIndex > -1 && this.props.suggestions[this.currentIndex]) { + this.props.suggestions[this.currentIndex].selected = false; + this.currentIndex = -1; + this.forceUpdate(); + } + } + + public setSelectedSuggestion(index: number): void { + const { suggestions } = this.props; + + if (index > suggestions.length - 1 || index < 0) { + this.currentIndex = 0; + this.currentSuggestion!.selected = false; + this.currentSuggestion = suggestions[0]; + this.currentSuggestion.selected = true; + } else { + if (this.currentIndex > -1 && suggestions[this.currentIndex]) { + suggestions[this.currentIndex].selected = false; + } + suggestions[index].selected = true; + this.currentIndex = index; + this.currentSuggestion = suggestions[index]; + } + + this.forceUpdate(); + } + + public componentDidUpdate(): void { + this.scrollSelected(); + } + + public render(): JSX.Element { + const { + onRenderSuggestion, + suggestionsItemClassName, + resultsMaximumNumber, + showRemoveButtons, + suggestionsContainerAriaLabel, + } = this.props; + const TypedSuggestionsItem = this.SuggestionsItemOfProperType; + let { suggestions } = this.props; + + if (resultsMaximumNumber) { + suggestions = suggestions.slice(0, resultsMaximumNumber); + } + + return ( +
    + {suggestions.map((suggestion: ISuggestionModel, index: number) => ( +
    + +
    + ))} +
    + ); + } + + // TODO get the element to scroll into view properly regardless of direction. + public scrollSelected(): void { + if (this._selectedElement.current?.scrollIntoView !== undefined) { + this._selectedElement.current.scrollIntoView(false); + } + } + + private _onClickTypedSuggestionsItem = (item: T, index: number): ((ev: React.MouseEvent) => void) => { + return (ev: React.MouseEvent): void => { + this.props.onSuggestionClick(ev, item, index); + }; + }; + + private _onRemoveTypedSuggestionsItem = (item: T, index: number): ((ev: React.MouseEvent) => void) => { + return (ev: React.MouseEvent): void => { + const onSuggestionRemove = this.props.onSuggestionRemove!; + onSuggestionRemove(ev, item, index); + ev.stopPropagation(); + }; + }; +} diff --git a/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsStore.ts b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsStore.ts new file mode 100644 index 0000000000000..58c3e15d5a8a4 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/Suggestions/SuggestionsStore.ts @@ -0,0 +1,62 @@ +import { ISuggestionModel, ITag } from '../../../Pickers'; +import { IPersonaProps } from '../../../Persona'; +/* eslint-disable */ + +export type SuggestionsStoreOptions = { + getAriaLabel?: (item: T) => string; +}; + +export class SuggestionsStore { + public suggestions: ISuggestionModel[]; + private getAriaLabel?: (item: T) => string; + + constructor(options?: SuggestionsStoreOptions) { + this.suggestions = []; + this.getAriaLabel = options && options.getAriaLabel; + } + + public updateSuggestions(newSuggestions: T[]): void { + if (newSuggestions && newSuggestions.length > 0) { + this.suggestions = this.convertSuggestionsToSuggestionItems(newSuggestions); + } else { + this.suggestions = []; + } + } + + public getSuggestions(): ISuggestionModel[] { + return this.suggestions; + } + + public getSuggestionAtIndex(index: number): ISuggestionModel { + return this.suggestions[index]; + } + + public removeSuggestion(index: number): void { + this.suggestions.splice(index, 1); + } + + public convertSuggestionsToSuggestionItems(suggestions: Array | T>): ISuggestionModel[] { + return Array.isArray(suggestions) ? suggestions.map(this._ensureSuggestionModel) : []; + } + + private _isSuggestionModel = (value: ISuggestionModel | T): value is ISuggestionModel => { + return (>value).item !== undefined; + }; + + private _ensureSuggestionModel = (suggestion: ISuggestionModel | T): ISuggestionModel => { + if (this._isSuggestionModel(suggestion)) { + return suggestion; + } else { + return { + item: suggestion, + selected: false, + ariaLabel: + this.getAriaLabel !== undefined + ? this.getAriaLabel(suggestion) + : ((suggestion as any) as ITag).name || + (suggestion).text || + (suggestion).primaryText, // eslint-disable-line deprecation/deprecation + }; + } + }; +} diff --git a/packages/react-next/src/components/FloatingPicker/__snapshots__/BaseFloatingPicker.test.tsx.snap b/packages/react-next/src/components/FloatingPicker/__snapshots__/BaseFloatingPicker.test.tsx.snap new file mode 100644 index 0000000000000..bfa9169f03c21 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/__snapshots__/BaseFloatingPicker.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pickers BaseFloatingPicker renders BaseFloatingPicker correctly 1`] = ` +
    +`; diff --git a/packages/react-next/src/components/FloatingPicker/index.ts b/packages/react-next/src/components/FloatingPicker/index.ts new file mode 100644 index 0000000000000..f9d1ffab972d5 --- /dev/null +++ b/packages/react-next/src/components/FloatingPicker/index.ts @@ -0,0 +1,7 @@ +export * from './BaseFloatingPicker'; +export * from './BaseFloatingPicker.types'; +export * from './PeoplePicker/FloatingPeoplePicker'; +export * from './Suggestions/SuggestionsStore'; +export * from './Suggestions/SuggestionsControl'; +export * from './Suggestions/SuggestionsCore'; +export * from './Suggestions/Suggestions.types'; diff --git a/packages/react-next/src/components/__snapshots__/Breadcrumb.Basic.Example.tsx.shot b/packages/react-next/src/components/__snapshots__/Breadcrumb.Basic.Example.tsx.shot new file mode 100644 index 0000000000000..b8c0fd77f2037 --- /dev/null +++ b/packages/react-next/src/components/__snapshots__/Breadcrumb.Basic.Example.tsx.shot @@ -0,0 +1,3502 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component Examples renders Breadcrumb.Basic.Example.tsx correctly 1`] = ` +
    + +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + + +  + +
    4. +
    5. + + +  + +
    6. +
    7. + +
      + Folder 4 (non-clickable) +
      +
      + +  + +
    8. +
    9. + + +  + +
    10. +
    11. + + +  + +
    12. +
    13. + + +  + +
    14. +
    15. + + +  + +
    16. +
    17. + + +  + +
    18. +
    19. + + +  + +
    20. +
    21. + +
    22. +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + +
      + Folder 3 +
      +
      + +  + +
    4. +
    5. + +
      + Folder 4 (non-clickable) +
      +
      + +  + +
    6. +
    7. + +
      + Folder 5 +
      +
      +
    8. +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + + +  + +
    4. +
    5. +

      +
      + Folder 2 +
      +

      +
    6. +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
    1. + +
      + +
      +
    2. +
    3. + +
      + +
      +
    4. +
    5. +

      +
      + Folder 2 +
      +

      +
    6. +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/packages/react-next/src/components/__snapshots__/Breadcrumb.Collapsing.Example.tsx.shot b/packages/react-next/src/components/__snapshots__/Breadcrumb.Collapsing.Example.tsx.shot new file mode 100644 index 0000000000000..e970b1eae3824 --- /dev/null +++ b/packages/react-next/src/components/__snapshots__/Breadcrumb.Collapsing.Example.tsx.shot @@ -0,0 +1,2290 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component Examples renders Breadcrumb.Collapsing.Example.tsx correctly 1`] = ` +
    + +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + + +  + +
    4. +
    5. + + +  + +
    6. +
    7. + + +  + +
    8. +
    9. + +
      + This is non-clickable folder 4 +
      +
      + +  + +
    10. +
    11. + +
    12. +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + + +  + +
    4. +
    5. + +
      + This is non-clickable folder 4 +
      +
      + +  + +
    6. +
    7. + +
    8. +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + + +  + +
    4. +
    5. + +
    6. +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/packages/react-next/src/components/__snapshots__/Breadcrumb.Static.Example.tsx.shot b/packages/react-next/src/components/__snapshots__/Breadcrumb.Static.Example.tsx.shot new file mode 100644 index 0000000000000..3ff19bd59501e --- /dev/null +++ b/packages/react-next/src/components/__snapshots__/Breadcrumb.Static.Example.tsx.shot @@ -0,0 +1,669 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component Examples renders Breadcrumb.Static.Example.tsx correctly 1`] = ` +
    +
    +
    +
    +
    +
    +
      +
    1. + + +  + +
    2. +
    3. + + +  + +
    4. +
    5. + +
      + This is non-clickable folder 4 +
      +
      + +  + +
    6. +
    7. + +
    8. +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/packages/react-next/src/components/__snapshots__/ExtendedPeoplePicker.Basic.Example.tsx.shot b/packages/react-next/src/components/__snapshots__/ExtendedPeoplePicker.Basic.Example.tsx.shot new file mode 100644 index 0000000000000..037a8a83fbd7f --- /dev/null +++ b/packages/react-next/src/components/__snapshots__/ExtendedPeoplePicker.Basic.Example.tsx.shot @@ -0,0 +1,217 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component Examples renders ExtendedPeoplePicker.Basic.Example.tsx correctly 1`] = ` +
    +
    +
    +
    +
    +
    + To: +
    + +
    +
    +
    +
    +
    + +
    +`; diff --git a/packages/react-next/src/components/__snapshots__/ExtendedPeoplePicker.Controlled.Example.tsx.shot b/packages/react-next/src/components/__snapshots__/ExtendedPeoplePicker.Controlled.Example.tsx.shot new file mode 100644 index 0000000000000..20388ebe975ca --- /dev/null +++ b/packages/react-next/src/components/__snapshots__/ExtendedPeoplePicker.Controlled.Example.tsx.shot @@ -0,0 +1,217 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component Examples renders ExtendedPeoplePicker.Controlled.Example.tsx correctly 1`] = ` +
    +
    +
    +
    +
    +
    + To: +
    + +
    +
    +
    +
    +
    + +
    +`; diff --git a/packages/react-next/src/components/__snapshots__/FloatingPeoplePicker.Basic.Example.tsx.shot b/packages/react-next/src/components/__snapshots__/FloatingPeoplePicker.Basic.Example.tsx.shot new file mode 100644 index 0000000000000..12c9cd09dd754 --- /dev/null +++ b/packages/react-next/src/components/__snapshots__/FloatingPeoplePicker.Basic.Example.tsx.shot @@ -0,0 +1,150 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Component Examples renders FloatingPeoplePicker.Basic.Example.tsx correctly 1`] = ` +Array [ +
    +
    +
    + +  + +
    + +
    +
    , +
    , +] +`; diff --git a/packages/react-slider/package.json b/packages/react-slider/package.json index 21d370c8d547a..01063acaf58f5 100644 --- a/packages/react-slider/package.json +++ b/packages/react-slider/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/react-slider", - "version": "0.3.0", + "version": "0.3.2", "description": "Fluent UI React Slider component", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -29,7 +29,7 @@ "devDependencies": { "@fluentui/eslint-plugin": "^0.54.1", "@fluentui/react-conformance": "^0.1.2", - "@fluentui/storybook": "^0.4.13", + "@fluentui/storybook": "^0.4.15", "@types/enzyme": "3.10.3", "@types/enzyme-adapter-react-16": "1.0.3", "@types/jest": "~24.9.0", @@ -45,11 +45,11 @@ "react-dom": "16.8.6" }, "dependencies": { - "@uifabric/react-hooks": "^7.13.2", + "@uifabric/react-hooks": "^7.13.3", "@uifabric/set-version": "^7.0.23", - "@uifabric/styling": "^7.16.2", - "@uifabric/utilities": "^7.32.0", - "office-ui-fabric-react": "^7.137.3", + "@uifabric/styling": "^7.16.4", + "@uifabric/utilities": "^7.32.1", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" }, "peerDependencies": { diff --git a/packages/react-spinbutton/.eslintrc.json b/packages/react-spinbutton/.eslintrc.json new file mode 100644 index 0000000000000..ceea884c70dcc --- /dev/null +++ b/packages/react-spinbutton/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["plugin:@fluentui/eslint-plugin/react"], + "root": true +} diff --git a/packages/react-spinbutton/.npmignore b/packages/react-spinbutton/.npmignore new file mode 100644 index 0000000000000..8f524f038059e --- /dev/null +++ b/packages/react-spinbutton/.npmignore @@ -0,0 +1,34 @@ +*.api.json +*.config.js +*.log +*.nuspec +*.test.* +*.yml +.editorconfig +.eslintrc* +.eslintcache +.gitattributes +.gitignore +.vscode +coverage +dist-storybook +dist/*.stats.html +dist/*.stats.json +dist/demo*.* +fabric-test* +gulpfile.js +images +index.html +jsconfig.json +node_modules +results +src/**/* +!src/**/examples/*.tsx +!src/**/docs/**/*.md +!src/**/*.types.ts +temp +tsconfig.json +tsd.json +tslint.json +typings +visualtests diff --git a/packages/react-spinbutton/.npmrc b/packages/react-spinbutton/.npmrc new file mode 100644 index 0000000000000..825c83e09df4d --- /dev/null +++ b/packages/react-spinbutton/.npmrc @@ -0,0 +1,2 @@ +registry=https://registry.npmjs.org/ + diff --git a/packages/react-spinbutton/LICENSE b/packages/react-spinbutton/LICENSE new file mode 100644 index 0000000000000..da1fbf31d372d --- /dev/null +++ b/packages/react-spinbutton/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-spinbutton + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-spinbutton/README.md b/packages/react-spinbutton/README.md new file mode 100644 index 0000000000000..1064982a839bb --- /dev/null +++ b/packages/react-spinbutton/README.md @@ -0,0 +1,11 @@ +# @fluentui/react-spinbutton + +**React Spinbutton components for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + +To import React Spinbutton components: + +```js +import { ComponentName } from '@fluentui/react-spinbutton'; +``` diff --git a/packages/react-spinbutton/config/api-extractor.json b/packages/react-spinbutton/config/api-extractor.json new file mode 100644 index 0000000000000..69c892ee6067b --- /dev/null +++ b/packages/react-spinbutton/config/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "@uifabric/build/api-extractor/api-extractor.common.json" +} diff --git a/packages/react-spinbutton/config/tests.js b/packages/react-spinbutton/config/tests.js new file mode 100644 index 0000000000000..981841f84e03f --- /dev/null +++ b/packages/react-spinbutton/config/tests.js @@ -0,0 +1,11 @@ +/** Jest test setup file. */ +const { configure } = require('enzyme'); +const Adapter = require('enzyme-adapter-react-16'); + +// Mock requestAnimationFrame for React 16+. +global.requestAnimationFrame = callback => { + setTimeout(callback, 0); +}; + +// Configure enzyme. +configure({ adapter: new Adapter() }); diff --git a/packages/react-spinbutton/etc/react-spinbutton.api.md b/packages/react-spinbutton/etc/react-spinbutton.api.md new file mode 100644 index 0000000000000..333140251a9c6 --- /dev/null +++ b/packages/react-spinbutton/etc/react-spinbutton.api.md @@ -0,0 +1,10 @@ +## API Report File for "@fluentui/react-spinbutton" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-spinbutton/just.config.ts b/packages/react-spinbutton/just.config.ts new file mode 100644 index 0000000000000..8e468e33d56c6 --- /dev/null +++ b/packages/react-spinbutton/just.config.ts @@ -0,0 +1,3 @@ +const { preset } = require('@uifabric/build'); + +preset(); diff --git a/packages/react-spinbutton/package.json b/packages/react-spinbutton/package.json new file mode 100644 index 0000000000000..088c6b7a22626 --- /dev/null +++ b/packages/react-spinbutton/package.json @@ -0,0 +1,48 @@ +{ + "name": "@fluentui/react-spinbutton", + "version": "0.1.0", + "description": "Package to hold SpinButton using button v8", + "private": true, + "main": "lib-commonjs/index.js", + "module": "lib/index.js", + "typings": "lib/index.d.ts", + "sideEffects": [ + "lib/version.js" + ], + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "scripts": { + "build": "just-scripts build", + "bundle": "just-scripts bundle", + "clean": "just-scripts clean", + "code-style": "just-scripts code-style", + "just": "just-scripts", + "lint": "just-scripts lint", + "start": "just-scripts dev:storybook", + "update-api": "just-scripts update-api" + }, + "devDependencies": { + "@fluentui/eslint-plugin": "^0.54.1", + "@fluentui/storybook": "", + "@types/react": "16.8.25", + "@types/react-dom": "16.8.4", + "@types/webpack-env": "1.15.1", + "@uifabric/build": "^7.0.0", + "react": "16.8.6", + "react-app-polyfill": "~1.0.1", + "react-dom": "16.8.6" + }, + "dependencies": { + "@uifabric/set-version": "^7.0.23", + "tslib": "^1.10.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <17.0.0", + "@types/react-dom": ">=16.8.0 <17.0.0", + "react": ">=16.8.0 <17.0.0", + "react-dom": ">=16.8.0 <17.0.0" + } +} diff --git a/packages/react-spinbutton/src/index.ts b/packages/react-spinbutton/src/index.ts new file mode 100644 index 0000000000000..107367257abe5 --- /dev/null +++ b/packages/react-spinbutton/src/index.ts @@ -0,0 +1 @@ +import './version'; diff --git a/packages/react-spinbutton/src/version.ts b/packages/react-spinbutton/src/version.ts new file mode 100644 index 0000000000000..b26a17788baf8 --- /dev/null +++ b/packages/react-spinbutton/src/version.ts @@ -0,0 +1,5 @@ +// Do not modify this file; it is generated as part of publish. +// The checked in version is a placeholder only and will not be updated. +import { setVersion } from '@uifabric/set-version'; +setVersion('@fluentui/react-spinbutton', '0.0.0'); + diff --git a/packages/react-spinbutton/tsconfig.json b/packages/react-spinbutton/tsconfig.json new file mode 100644 index 0000000000000..050abfdc82b77 --- /dev/null +++ b/packages/react-spinbutton/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "target": "es5", + "module": "commonjs", + "jsx": "react", + "declaration": true, + "sourceMap": true, + "experimentalDecorators": true, + "importHelpers": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", + "preserveConstEnums": true, + "lib": ["es5", "dom"], + "typeRoots": ["../../node_modules/@types", "../../typings"], + "types": ["webpack-env", "custom-global"], + "paths": { + "@fluentui/react-spinbutton/lib/*": ["./src/*"], + "@fluentui/react-spinbutton": ["./src"] + } + }, + "include": ["src"] +} diff --git a/packages/react-tabs/package.json b/packages/react-tabs/package.json index 69e7955ec3556..e77c6a820d990 100644 --- a/packages/react-tabs/package.json +++ b/packages/react-tabs/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/react-tabs", - "version": "0.6.0", + "version": "0.6.2", "description": "Fluent UI React Tabs component", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -36,7 +36,7 @@ "@types/react-dom": "16.8.4", "@types/react-test-renderer": "^16.0.0", "@types/webpack-env": "1.15.1", - "@fluentui/storybook": "^0.4.13", + "@fluentui/storybook": "^0.4.15", "@uifabric/build": "^7.0.0", "enzyme": "~3.10.0", "react": "16.8.6", @@ -44,13 +44,13 @@ "react-dom": "16.8.6" }, "dependencies": { - "@fluentui/react-icons": "^0.3.2", - "@fluentui/react-theme-provider": "^0.12.1", - "@uifabric/react-hooks": "^7.13.2", + "@fluentui/react-icons": "^0.3.3", + "@fluentui/react-theme-provider": "^0.13.1", + "@uifabric/react-hooks": "^7.13.3", "@uifabric/set-version": "^7.0.23", - "@uifabric/styling": "^7.16.2", - "@uifabric/utilities": "^7.32.0", - "office-ui-fabric-react": "^7.137.3", + "@uifabric/styling": "^7.16.4", + "@uifabric/utilities": "^7.32.1", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" }, "peerDependencies": { diff --git a/packages/react-theme-provider/CHANGELOG.json b/packages/react-theme-provider/CHANGELOG.json index 0b23d6d3179fc..5c38c94c54e50 100644 --- a/packages/react-theme-provider/CHANGELOG.json +++ b/packages/react-theme-provider/CHANGELOG.json @@ -1,6 +1,42 @@ { "name": "@fluentui/react-theme-provider", "entries": [ + { + "date": "Thu, 17 Sep 2020 15:52:49 GMT", + "tag": "@fluentui/react-theme-provider_v0.13.1", + "version": "0.13.1", + "comments": { + "patch": [ + { + "comment": "Fix button styles to match v7 buttons.", + "author": "xgao@microsoft.com", + "commit": "70025f8282c9ba918580a1679ef3ae2ee217ecc9", + "package": "@fluentui/react-theme-provider" + } + ] + } + }, + { + "date": "Thu, 17 Sep 2020 12:25:04 GMT", + "tag": "@fluentui/react-theme-provider_v0.13.0", + "version": "0.13.0", + "comments": { + "minor": [ + { + "comment": "Support applyTo prop and align styles with Fabric component.", + "author": "xgao@microsoft.com", + "commit": "730c61559ee97a7cfc2f84d40c96fab3077e5fb0", + "package": "@fluentui/react-theme-provider" + }, + { + "comment": "Updating color token references to use `color` prefix, and `accent` tokens have been renamed to `brand`.", + "author": "dzearing@hotmail.com", + "commit": "63a9ede0bb731c8dad96c12e6a5d4d0a10aa493e", + "package": "@fluentui/react-theme-provider" + } + ] + } + }, { "date": "Wed, 16 Sep 2020 12:27:22 GMT", "tag": "@fluentui/react-theme-provider_v0.12.1", diff --git a/packages/react-theme-provider/CHANGELOG.md b/packages/react-theme-provider/CHANGELOG.md index 6fc33f4979925..33b131c7f39e5 100644 --- a/packages/react-theme-provider/CHANGELOG.md +++ b/packages/react-theme-provider/CHANGELOG.md @@ -1,9 +1,28 @@ # Change Log - @fluentui/react-theme-provider -This log was last generated on Tue, 15 Sep 2020 12:26:06 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2020 15:52:49 GMT and should not be manually modified. +## [0.13.1](https://github.com/microsoft/fluentui/tree/@fluentui/react-theme-provider_v0.13.1) + +Thu, 17 Sep 2020 15:52:49 GMT +[Compare changes](https://github.com/microsoft/fluentui/compare/@fluentui/react-theme-provider_v0.13.0..@fluentui/react-theme-provider_v0.13.1) + +### Patches + +- Fix button styles to match v7 buttons. ([PR #14972](https://github.com/microsoft/fluentui/pull/14972) by xgao@microsoft.com) + +## [0.13.0](https://github.com/microsoft/fluentui/tree/@fluentui/react-theme-provider_v0.13.0) + +Thu, 17 Sep 2020 12:25:04 GMT +[Compare changes](https://github.com/microsoft/fluentui/compare/@fluentui/react-theme-provider_v0.12.1..@fluentui/react-theme-provider_v0.13.0) + +### Minor changes + +- Support applyTo prop and align styles with Fabric component. ([PR #14696](https://github.com/microsoft/fluentui/pull/14696) by xgao@microsoft.com) +- Updating color token references to use `color` prefix, and `accent` tokens have been renamed to `brand`. ([PR #15070](https://github.com/microsoft/fluentui/pull/15070) by dzearing@hotmail.com) + ## [0.12.0](https://github.com/microsoft/fluentui/tree/@fluentui/react-theme-provider_v0.12.0) Tue, 15 Sep 2020 12:26:06 GMT diff --git a/packages/react-theme-provider/etc/react-theme-provider.api.md b/packages/react-theme-provider/etc/react-theme-provider.api.md index 729c8a7784b6d..d2ea9c664dfab 100644 --- a/packages/react-theme-provider/etc/react-theme-provider.api.md +++ b/packages/react-theme-provider/etc/react-theme-provider.api.md @@ -107,9 +107,9 @@ export const ThemeProvider: React.ForwardRefExoticComponent { + applyTo?: 'element' | 'body' | 'none'; ref?: React.Ref; renderer?: StyleRenderer; - targetWindow?: Window | null; theme?: PartialTheme | Theme; } @@ -140,7 +140,7 @@ export const useThemeProvider: (props: ThemeProviderProps, ref: React.Ref void; +export function useThemeProviderClasses(state: ThemeProviderState): void; // @public (undocumented) export const useThemeProviderState: (draftState: ThemeProviderState) => void; diff --git a/packages/react-theme-provider/package.json b/packages/react-theme-provider/package.json index f6735902610f6..f6daedded1dd2 100644 --- a/packages/react-theme-provider/package.json +++ b/packages/react-theme-provider/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/react-theme-provider", - "version": "0.12.1", + "version": "0.13.1", "description": "Fluent UI React theme provider component, hook, and theme related utilities.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -44,14 +44,14 @@ "react-test-renderer": "^16.3.0" }, "dependencies": { - "@fluentui/react-compose": "^0.19.1", + "@fluentui/react-compose": "^0.19.2", "@fluentui/react-stylesheets": "^0.2.2", - "@fluentui/theme": "^0.2.2", + "@fluentui/theme": "^0.3.1", "@fluentui/react-window-provider": "^0.3.2", "@uifabric/set-version": "^7.0.23", "@uifabric/merge-styles": "^7.19.1", - "@uifabric/styling": "^7.16.2", - "@uifabric/utilities": "^7.32.0", + "@uifabric/styling": "^7.16.4", + "@uifabric/utilities": "^7.32.1", "classnames": "^2.2.6", "tslib": "^1.10.0" }, diff --git a/packages/react-theme-provider/src/ThemeProvider.scss b/packages/react-theme-provider/src/ThemeProvider.scss index f3b216bbe757b..ed7e626574c2d 100644 --- a/packages/react-theme-provider/src/ThemeProvider.scss +++ b/packages/react-theme-provider/src/ThemeProvider.scss @@ -6,9 +6,9 @@ // TODO: ensure correct styles are being set here .root { // TODO: apply body theme based on `applyTo` prop. - // background: var(--body-background); + // background: var(--color-body-background); - color: var(--body-contentColor); + color: var(--color-body-contentColor); font-family: var(--body-fontFamily); font-weight: var(--body-fontWeight); font-size: var(--body-fontSize); diff --git a/packages/react-theme-provider/src/ThemeProvider.stories.tsx b/packages/react-theme-provider/src/ThemeProvider.stories.tsx index aee6a9306fa73..b134e819b99b2 100644 --- a/packages/react-theme-provider/src/ThemeProvider.stories.tsx +++ b/packages/react-theme-provider/src/ThemeProvider.stories.tsx @@ -10,19 +10,23 @@ export default { const lightTheme: PartialTheme = { tokens: { - body: { - background: 'white', - contentColor: 'black', - fontFamily: 'Segoe UI', + color: { + body: { + background: 'white', + contentColor: 'black', + fontFamily: 'Segoe UI', + }, }, }, }; const darkTheme: PartialTheme = { tokens: { - body: { - background: 'black', - contentColor: 'white', + color: { + body: { + background: 'black', + contentColor: 'white', + }, }, }, }; @@ -40,7 +44,7 @@ export const NestedTheming = () => { const [isLight, setIsLight] = React.useState(true); return ( - +
    I am {isLight ? 'light theme' : 'dark theme'}
    diff --git a/packages/react-theme-provider/src/ThemeProvider.test.tsx b/packages/react-theme-provider/src/ThemeProvider.test.tsx index 47a00e42974c3..a60389fd9c765 100644 --- a/packages/react-theme-provider/src/ThemeProvider.test.tsx +++ b/packages/react-theme-provider/src/ThemeProvider.test.tsx @@ -6,6 +6,7 @@ import { useTheme } from './useTheme'; import { mount } from 'enzyme'; import { mergeThemes } from '@fluentui/theme'; import { createDefaultTheme } from './createDefaultTheme'; +import { Stylesheet } from '@uifabric/merge-styles'; const lightTheme = mergeThemes({ stylesheets: [], @@ -27,6 +28,12 @@ const darkTheme = mergeThemes({ }); describe('ThemeProvider', () => { + const stylesheet: Stylesheet = Stylesheet.getInstance(); + + beforeEach(() => { + stylesheet.reset(); + }); + it('renders a div', () => { const component = renderer.create(Hello); const tree = component.toJSON(); @@ -82,4 +89,44 @@ describe('ThemeProvider', () => { const expectedTheme = mergeThemes(createDefaultTheme(), lightTheme); expect(resolvedTheme).toEqual(expectedTheme); }); + + it('can apply body theme to none', () => { + expect(document.body.className).toBe(''); + const component = renderer.create( + + app + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + + expect(document.body.className).toBe(''); + }); + + it('can apply body theme to body', () => { + expect(document.body.className).toBe(''); + const testClass = 'foo'; + const TestComponent = ( + + app + + ); + + const wrapper = mount(TestComponent); + expect(document.body.className).not.toBe(''); + + const bodyStyles = document.body.className + .split(' ') + .map(bodyClass => stylesheet.insertedRulesFromClassName(bodyClass)); + + expect(bodyStyles).toMatchSnapshot(); + + wrapper.unmount(); + + expect(document.body.className).toBe(''); + + const component = renderer.create(TestComponent); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); }); diff --git a/packages/react-theme-provider/src/ThemeProvider.tsx b/packages/react-theme-provider/src/ThemeProvider.tsx index c6f95f7639733..a6a1abdb3b951 100644 --- a/packages/react-theme-provider/src/ThemeProvider.tsx +++ b/packages/react-theme-provider/src/ThemeProvider.tsx @@ -4,6 +4,7 @@ import { useThemeProviderClasses } from './useThemeProviderClasses'; import { useThemeProvider } from './useThemeProvider'; import { mergeStylesRenderer } from './styleRenderers/mergeStylesRenderer'; import { useStylesheet } from '@fluentui/react-stylesheets'; +import { useFocusRects } from '@uifabric/utilities'; /** * ThemeProvider, used for providing css variables and registering stylesheets. @@ -14,13 +15,16 @@ export const ThemeProvider = React.forwardRef + app +
    +`; + +exports[`ThemeProvider can apply body theme to none 1`] = ` +
    + app +
    +`; + exports[`ThemeProvider can handle a partial theme 1`] = `
    { return defaultTheme; }; +// TODO: use default fonts from `theme` package. +const defaultFonts = { + // eslint-disable-next-line @fluentui/max-len + fontFamily: `'Segoe UI', 'Segoe UI Web (West European)', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', sans-serif`, + fontSize: '14px', + fontWeight: 400, + mozOsxFontSmoothing: 'grayscale', + webkitFontSmoothing: 'antialiased', +}; + export const defaultTokens: Tokens = { - body: { background: '#ffffff', contentColor: '#323130' }, - accent: { - background: '#0078d4', - contentColor: '#ffffff', - borderColor: 'transparent', - iconColor: '#ffffff', - dividerColor: '#ffffff', - secondaryContentColor: '#ffffff', - disabled: { - background: '#f3f2f1', - contentColor: '#c8c6c4', - borderColor: 'var(--accent-disabled-background)', - iconColor: 'var(--accent-disabled-contentColor)', - dividerColor: '#c8c6c4', - secondaryContentColor: 'var(--accent-disabled-contentColor)', - }, - hovered: { - background: '#106ebe', + color: { + body: { background: '#ffffff', contentColor: '#323130', ...defaultFonts }, + + brand: { + background: '#0078d4', contentColor: '#ffffff', - borderColor: 'var(--accent-borderColor)', + borderColor: 'transparent', iconColor: '#ffffff', - secondaryContentColor: 'var(--accent-hovered-contentColor)', - }, - pressed: { - background: '#005a9e', - contentColor: 'var(--accent-contentColor)', - borderColor: 'var(--accent-borderColor)', - iconColor: 'var(--accent-iconColor)', - secondaryContentColor: 'var(--accent-pressed-contentColor)', - }, - focused: { - background: 'var(--accent-background)', - borderColor: 'var(--accent-borderColor)', - contentColor: 'var(--accent-contentColor)', - iconColor: 'var(--accent-iconColor)', - secondaryContentColor: 'var(--accent-focused-contentColor)', - }, - checked: { - background: 'var(--acent-pressed-background)', - contentColor: 'var(--acent-pressed-contentColor)', - }, - checkedHovered: { - background: 'var(--acent-pressed-background)', - contentColor: 'var(--acent-pressed-contentColor)', + dividerColor: '#ffffff', + secondaryContentColor: '#ffffff', + disabled: { + background: '#f3f2f1', + contentColor: '#c8c6c4', + borderColor: 'var(--color-brand-disabled-background)', + iconColor: 'var(--color-brand-disabled-contentColor)', + dividerColor: '#c8c6c4', + secondaryContentColor: 'var(--color-brand-disabled-contentColor)', + }, + hovered: { + background: '#106ebe', + contentColor: '#ffffff', + borderColor: 'var(--color-brand-borderColor)', + iconColor: '#ffffff', + secondaryContentColor: 'var(--color-brand-hovered-contentColor)', + }, + pressed: { + background: '#005a9e', + contentColor: 'var(--color-brand-contentColor)', + borderColor: 'var(--color-brand-borderColor)', + iconColor: 'var(--color-brand-iconColor)', + secondaryContentColor: 'var(--color-brand-pressed-contentColor)', + }, + focused: { + background: 'var(--color-brand-background)', + borderColor: 'var(--color-brand-borderColor)', + contentColor: 'var(--color-brand-contentColor)', + iconColor: 'var(--color-brand-iconColor)', + secondaryContentColor: 'var(--color-brand-focused-contentColor)', + }, + checked: { + background: 'var(--color-brand-pressed-background)', + contentColor: 'var(--color-brand-pressed-contentColor)', + }, + checkedHovered: { + background: 'var(--color-brand-pressed-background)', + contentColor: 'var(--color-brand-pressed-contentColor)', + }, }, }, // TODO: this should be a variant. ghost: { - background: 'var(--body-background)', + fontWeight: 'normal', + background: 'transparent', borderColor: 'transparent', contentColor: '#323130', iconColor: '#106ebe', @@ -70,19 +84,19 @@ export const defaultTokens: Tokens = { secondaryContentColor: 'var(--ghost-contentColor)', checked: { - background: 'var(--ghost-background)', + background: '#edebe9', borderColor: 'var(--ghost-borderColor)', contentColor: '#000000', iconColor: '#004578', }, checkedHovered: { - background: 'var(--ghost-background)', + background: '#e1dfdd', borderColor: 'var(--ghost-borderColor)', contentColor: 'var(--ghost-hovered-contentColor)', iconColor: 'var(--ghost-hovered-iconColor)', }, disabled: { - background: 'var(--ghost-background)', + background: '#f3f2f1', borderColor: 'var(--ghost-borderColor)', contentColor: '#a19f9d', iconColor: 'inherit', @@ -99,14 +113,14 @@ export const defaultTokens: Tokens = { secondaryContentColor: 'var(--ghost-focused-contentColor)', }, hovered: { - background: 'var(--ghost-background)', + background: '#f3f2f1', borderColor: 'var(--ghost-borderColor)', contentColor: '#0078d4', iconColor: '#0078d4', secondaryContentColor: 'var(--ghost-hovered-contentColor)', }, pressed: { - background: 'var(--ghost-background)', + background: '#edebe9', borderColor: 'var(--ghost-borderColor)', contentColor: '#000000', iconColor: '#004578', @@ -126,20 +140,17 @@ export const defaultTokens: Tokens = { larger: '48px', largest: '64px', }, + ...defaultFonts, paddingLeft: '20px', paddingRight: '20px', paddingTop: '0', paddingBottom: '0', - minWiedth: '80px', + height: 'var(--button-size-regular)', minHeight: 'var(--button-size-regular)', - contentGap: '10px', + contentGap: '8px', iconSize: '16px', borderRadius: '2px', borderWidth: '1px', - // eslint-disable-next-line @fluentui/max-len - fontFamily: `'Segoe UI', 'Segoe UI Web (West European)', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', sans-serif`, - fontSize: '14px', - fontWeight: 400, focusColor: '#605e5c', focusInnerColor: '#ffffff', focusWidth: '2px', @@ -149,11 +160,13 @@ export const defaultTokens: Tokens = { contentColor: '#323130', iconColor: 'inherit', menuIconColor: 'inherit', + menuIconSize: '12px', dividerColor: 'rgba(0, 0, 0, 0.1)', - dividerLength: 'var(--button-minHeight)', dividerThickness: 'var(--button-borderWidth)', secondaryContentColor: '#605e5c', secondaryContentFontSize: '12px', + secondaryContentFontWeight: 'normal', + secondaryContentMarginTop: '5px', disabled: { background: '#f3f2f1', diff --git a/packages/react-theme-provider/src/getTokens.ts b/packages/react-theme-provider/src/getTokens.ts index 5b849d1d543fb..ea9e85d7d6681 100644 --- a/packages/react-theme-provider/src/getTokens.ts +++ b/packages/react-theme-provider/src/getTokens.ts @@ -23,74 +23,81 @@ export function getTokens(theme: Theme): Tokens { {}, defaultTokens, { - // accent is currently only mapped for primary button to use. - accent: { - background: semanticColors?.primaryButtonBackground, - borderColor: semanticColors?.primaryButtonBorder, - contentColor: semanticColors?.primaryButtonText, - iconColor: palette?.white, - dividerColor: palette?.white, - secondaryContentColor: palette?.white, - - hovered: { - background: semanticColors?.primaryButtonBackgroundHovered, - contentColor: semanticColors?.primaryButtonTextHovered, + color: { + body: { + background: semanticColors?.bodyBackground, + contentColor: semanticColors?.bodyText, + fontFamily: fonts?.medium.fontFamily, + fontWeight: fonts?.medium.fontWeight, + fontSize: fonts?.medium.fontSize, + mozOsxFontSmoothing: fonts?.medium.MozOsxFontSmoothing, + webkitFontSmoothing: fonts?.medium.WebkitFontSmoothing, + }, + + // accent is currently only mapped for primary button to use. + brand: { + background: semanticColors?.primaryButtonBackground, + borderColor: semanticColors?.primaryButtonBorder, + contentColor: semanticColors?.primaryButtonText, + iconColor: palette?.white, + dividerColor: palette?.white, secondaryContentColor: palette?.white, - }, - - pressed: { - background: semanticColors?.primaryButtonBackgroundPressed, - contentColor: semanticColors?.primaryButtonTextPressed, - secondaryContentColor: semanticColors?.primaryButtonTextPressed, - }, - - disabled: { - background: semanticColors?.primaryButtonBackgroundDisabled, - contentColor: semanticColors?.buttonTextDisabled, - dividerColor: palette?.neutralTertiaryAlt, - secondaryContentColor: semanticColors?.buttonTextDisabled, - }, - checked: { - background: semanticColors?.primaryButtonBackgroundPressed, - contentColor: semanticColors?.primaryButtonTextPressed, - }, - - checkedHovered: { - background: semanticColors?.primaryButtonBackgroundPressed, - contentColor: semanticColors?.primaryButtonTextPressed, + hovered: { + background: semanticColors?.primaryButtonBackgroundHovered, + contentColor: semanticColors?.primaryButtonTextHovered, + secondaryContentColor: palette?.white, + }, + + pressed: { + background: semanticColors?.primaryButtonBackgroundPressed, + contentColor: semanticColors?.primaryButtonTextPressed, + secondaryContentColor: semanticColors?.primaryButtonTextPressed, + }, + + disabled: { + background: semanticColors?.primaryButtonBackgroundDisabled, + contentColor: semanticColors?.buttonTextDisabled, + dividerColor: palette?.neutralTertiaryAlt, + secondaryContentColor: semanticColors?.buttonTextDisabled, + }, + + checked: { + background: semanticColors?.primaryButtonBackgroundPressed, + contentColor: semanticColors?.primaryButtonTextPressed, + }, + + checkedHovered: { + background: semanticColors?.primaryButtonBackgroundPressed, + contentColor: semanticColors?.primaryButtonTextPressed, + }, }, }, - // ghost is currently only mapped for ghost button to use. + // TODO: This will be moved out as a button variant. ghost: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', contentColor: palette?.neutralPrimary, iconColor: palette?.themeDarkAlt, menuIconColor: palette?.neutralSecondary, secondaryContentColor: palette?.neutralPrimary, checked: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', + background: palette?.neutralLight, contentColor: palette?.black, iconColor: palette?.themeDarker, }, checkedHovered: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', + background: palette?.neutralQuaternaryAlt, contentColor: palette?.themePrimary, iconColor: palette?.themePrimary, }, disabled: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', contentColor: palette?.neutralTertiary, iconColor: 'inherit', secondaryContentColor: palette?.neutralTertiary, + background: semanticColors?.disabledBackground, }, expanded: { @@ -98,35 +105,27 @@ export function getTokens(theme: Theme): Tokens { }, focused: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', contentColor: palette?.neutralPrimary, iconColor: palette?.themeDarkAlt, secondaryContentColor: palette?.neutralPrimary, }, hovered: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', + background: palette?.neutralLighter, contentColor: palette?.themePrimary, iconColor: palette?.themePrimary, secondaryContentColor: palette?.themePrimary, }, pressed: { - background: semanticColors?.bodyBackground, - borderColor: 'transparent', + background: palette?.neutralLight, contentColor: palette?.black, iconColor: palette?.themeDarker, secondaryContentColor: palette?.black, }, }, - body: { - background: semanticColors?.bodyBackground, - contentColor: semanticColors?.bodyText, - }, - + // TODO: This will be moved out as a button variant. button: { fontWeight: '600', fontSize: fonts?.medium?.fontSize, diff --git a/packages/react-theme-provider/src/themes/TeamsTheme.ts b/packages/react-theme-provider/src/themes/TeamsTheme.ts index ec498259a7fb7..b577429a1ec1e 100644 --- a/packages/react-theme-provider/src/themes/TeamsTheme.ts +++ b/packages/react-theme-provider/src/themes/TeamsTheme.ts @@ -2,24 +2,26 @@ import { PartialTheme } from '../types'; export const TeamsTheme: PartialTheme = { tokens: { - accent: { - background: '#6264a7', + color: { + brand: { + background: '#6264a7', - disabled: { - background: '#edebe9', - contentColor: '#c8c6c4', - borderColor: 'var(--accent-disabled-background)', - }, - pressed: { - background: '#464775', - }, + disabled: { + background: '#edebe9', + contentColor: '#c8c6c4', + borderColor: 'var(--color-brand-disabled-background)', + }, + pressed: { + background: '#464775', + }, - focused: { - background: '#585a96', - }, + focused: { + background: '#585a96', + }, - hovered: { - background: '#585a96', + hovered: { + background: '#585a96', + }, }, }, diff --git a/packages/react-theme-provider/src/useThemeProviderClasses.tsx b/packages/react-theme-provider/src/useThemeProviderClasses.tsx index 5bb1e494a7f34..243707fcbc57a 100644 --- a/packages/react-theme-provider/src/useThemeProviderClasses.tsx +++ b/packages/react-theme-provider/src/useThemeProviderClasses.tsx @@ -1,19 +1,69 @@ -import { makeClasses } from './makeClasses'; +import * as React from 'react'; +import { css } from '@uifabric/utilities'; +import { useDocument } from '@fluentui/react-window-provider'; +import { IRawStyle } from '@uifabric/styling'; +import { makeStyles } from './makeStyles'; +import { ThemeProviderState } from './ThemeProvider.types'; import { tokensToStyleObject } from './tokensToStyleObject'; -export const useThemeProviderClasses = makeClasses(theme => { +const useThemeProviderStyles = makeStyles(theme => { const { tokens } = theme; + const tokenStyles = tokensToStyleObject(tokens) as IRawStyle; return { - root: [ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tokensToStyleObject(tokens) as any, + root: tokenStyles, + body: [ { - color: 'var(--body-contentColor)', + color: 'var(--color-body-contentColor)', + background: 'var(--color-body-background)', fontFamily: 'var(--body-fontFamily)', fontWeight: 'var(--body-fontWeight)', fontSize: 'var(--body-fontSize)', + MozOsxFontSmoothing: 'var(--body-mozOsxFontSmoothing)', + WebkitFontSmoothing: 'var(--body-webkitFontSmoothing)', }, ], }; }); + +/** + * Hook to add class to body element. + */ +function useApplyClassToBody(state: ThemeProviderState, classesToApply: string[]): void { + const { applyTo } = state; + + const applyToBody = applyTo === 'body'; + const body = useDocument()?.body; + + React.useEffect(() => { + if (!applyToBody || !body) { + return; + } + + for (const classToApply of classesToApply) { + if (classToApply) { + body.classList.add(classToApply); + } + } + + return () => { + if (!applyToBody || !body) { + return; + } + + for (const classToApply of classesToApply) { + if (classToApply) { + body.classList.remove(classToApply); + } + } + }; + }, [applyToBody, body, classesToApply]); +} + +export function useThemeProviderClasses(state: ThemeProviderState): void { + const classes = useThemeProviderStyles(state.theme, state.renderer); + useApplyClassToBody(state, [classes.root, classes.body]); + + const { className, applyTo } = state; + state.className = css(className, classes.root, applyTo === 'element' && classes.body); +} diff --git a/packages/react-toggle/package.json b/packages/react-toggle/package.json index ff59dff51c096..e46226f4af91b 100644 --- a/packages/react-toggle/package.json +++ b/packages/react-toggle/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/react-toggle", - "version": "0.3.0", + "version": "0.3.2", "description": "Fluent UI React Toggle component", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -28,7 +28,7 @@ }, "devDependencies": { "@fluentui/eslint-plugin": "^0.54.1", - "@fluentui/storybook": "^0.4.13", + "@fluentui/storybook": "^0.4.15", "@types/enzyme": "3.10.3", "@types/enzyme-adapter-react-16": "1.0.3", "@types/jest": "~24.9.0", @@ -43,12 +43,12 @@ "sinon": "^4.1.3" }, "dependencies": { - "@uifabric/react-hooks": "^7.13.2", - "@fluentui/react-compose": "^0.19.1", + "@uifabric/react-hooks": "^7.13.3", + "@fluentui/react-compose": "^0.19.2", "@uifabric/set-version": "^7.0.23", - "@uifabric/styling": "^7.16.2", - "@uifabric/utilities": "^7.32.0", - "office-ui-fabric-react": "^7.137.3", + "@uifabric/styling": "^7.16.4", + "@uifabric/utilities": "^7.32.1", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" }, "peerDependencies": { diff --git a/packages/react/package.json b/packages/react/package.json index 79d22a9e44579..c2e1c942c0704 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/react", - "version": "7.137.3", + "version": "7.137.5", "description": "Reusable React components for building web experiences.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -35,7 +35,7 @@ }, "dependencies": { "@uifabric/set-version": "^7.0.23", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" }, "peerDependencies": { diff --git a/packages/react/src/compat/Button.ts b/packages/react/src/compat/Button.ts new file mode 100644 index 0000000000000..83c06736387db --- /dev/null +++ b/packages/react/src/compat/Button.ts @@ -0,0 +1 @@ +export * from 'office-ui-fabric-react/lib/Button'; diff --git a/packages/react/src/compat/Calendar.ts b/packages/react/src/compat/Calendar.ts new file mode 100644 index 0000000000000..d357b448d7ef9 --- /dev/null +++ b/packages/react/src/compat/Calendar.ts @@ -0,0 +1 @@ +export * from 'office-ui-fabric-react/lib/Calendar'; diff --git a/packages/react/src/compat/DatePicker.ts b/packages/react/src/compat/DatePicker.ts new file mode 100644 index 0000000000000..8710999cf02dc --- /dev/null +++ b/packages/react/src/compat/DatePicker.ts @@ -0,0 +1 @@ +export * from 'office-ui-fabric-react/lib/DatePicker'; diff --git a/packages/react/src/compat/index.ts b/packages/react/src/compat/index.ts new file mode 100644 index 0000000000000..af6b44053b0ac --- /dev/null +++ b/packages/react/src/compat/index.ts @@ -0,0 +1,3 @@ +export * from './Button'; +export * from './Calendar'; +export * from './DatePicker'; diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 6a221cbe87c21..2bcff5da7013a 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/storybook", - "version": "0.4.13", + "version": "0.4.15", "description": "Storybook addons for Fluent UI React", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -26,12 +26,12 @@ }, "dependencies": { "tslib": "^1.10.0", - "@fluentui/react-theme-provider": "^0.12.1", + "@fluentui/react-theme-provider": "^0.13.1", "@storybook/addon-knobs": "^5.3.8", "@storybook/addons": "^5.3.8", - "@uifabric/azure-themes": "^7.5.10", - "@uifabric/mdl2-theme": "^0.3.9", - "@uifabric/theme-samples": "^7.1.14" + "@uifabric/azure-themes": "^7.5.12", + "@uifabric/mdl2-theme": "^0.3.11", + "@uifabric/theme-samples": "^7.1.16" }, "peerDependencies": { "@types/react": ">=16.8.0 <17.0.0", diff --git a/packages/styling/package.json b/packages/styling/package.json index c1b5185ec83e2..b649e3bb321e3 100644 --- a/packages/styling/package.json +++ b/packages/styling/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/styling", - "version": "7.16.2", + "version": "7.16.4", "description": "Styling helpers for Fluent UI React.", "repository": { "type": "git", @@ -34,10 +34,10 @@ }, "dependencies": { "@microsoft/load-themed-styles": "^1.10.26", - "@fluentui/theme": "^0.2.2", + "@fluentui/theme": "^0.3.1", "@uifabric/merge-styles": "^7.19.1", "@uifabric/set-version": "^7.0.23", - "@uifabric/utilities": "^7.32.0", + "@uifabric/utilities": "^7.32.1", "tslib": "^1.10.0" } } diff --git a/packages/test-utilities/src/safeCreate.ts b/packages/test-utilities/src/safeCreate.ts index 2d133d07f0199..676024612fbba 100644 --- a/packages/test-utilities/src/safeCreate.ts +++ b/packages/test-utilities/src/safeCreate.ts @@ -12,10 +12,11 @@ import * as renderer from 'react-test-renderer'; export function safeCreate( content: React.ReactElement, callback: (wrapper: renderer.ReactTestRenderer) => void, + options?: renderer.TestRendererOptions, ): void { let wrapper: renderer.ReactTestRenderer; renderer.act(() => { - wrapper = renderer.create(content); + wrapper = renderer.create(content, options); }); callback(wrapper!); diff --git a/packages/theme-samples/package.json b/packages/theme-samples/package.json index 917d5d8a46999..f3215e76baaed 100644 --- a/packages/theme-samples/package.json +++ b/packages/theme-samples/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/theme-samples", - "version": "7.1.14", + "version": "7.1.16", "description": "Sample themes for use with Fabric components.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -27,8 +27,8 @@ }, "dependencies": { "@uifabric/set-version": "^7.0.23", - "@uifabric/variants": "^7.2.14", - "office-ui-fabric-react": "^7.137.3", + "@uifabric/variants": "^7.2.16", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" } } diff --git a/packages/theme/CHANGELOG.json b/packages/theme/CHANGELOG.json index 423421590507f..d8e9592a6e8de 100644 --- a/packages/theme/CHANGELOG.json +++ b/packages/theme/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@fluentui/theme", "entries": [ + { + "date": "Thu, 17 Sep 2020 12:25:04 GMT", + "tag": "@fluentui/theme_v0.3.0", + "version": "0.3.0", + "comments": { + "minor": [ + { + "comment": "Updating color token references to use `color` prefix, and `accent` tokens have been renamed to `brand`.", + "author": "dzearing@hotmail.com", + "commit": "63a9ede0bb731c8dad96c12e6a5d4d0a10aa493e", + "package": "@fluentui/theme" + } + ] + } + }, { "date": "Wed, 26 Aug 2020 12:35:38 GMT", "tag": "@fluentui/theme_v0.1.2", diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index ca8c033c7835c..fbca514d80b2f 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,9 +1,18 @@ # Change Log - @fluentui/theme -This log was last generated on Wed, 26 Aug 2020 12:35:38 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2020 12:25:04 GMT and should not be manually modified. +## [0.3.0](https://github.com/microsoft/fluentui/tree/@fluentui/theme_v0.3.0) + +Thu, 17 Sep 2020 12:25:04 GMT +[Compare changes](https://github.com/microsoft/fluentui/compare/@fluentui/theme_v0.1.2..@fluentui/theme_v0.3.0) + +### Minor changes + +- Updating color token references to use `color` prefix, and `accent` tokens have been renamed to `brand`. ([PR #15070](https://github.com/microsoft/fluentui/pull/15070) by dzearing@hotmail.com) + ## [0.1.2](https://github.com/microsoft/fluentui/tree/@fluentui/theme_v0.1.2) Wed, 26 Aug 2020 12:35:38 GMT diff --git a/packages/theme/package.json b/packages/theme/package.json index c18c8bc7fbceb..9993c502b722f 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,6 +1,6 @@ { "name": "@fluentui/theme", - "version": "0.2.2", + "version": "0.3.1", "description": "Basic building blocks for Fluent UI React Component themes", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -43,7 +43,7 @@ }, "dependencies": { "@uifabric/merge-styles": "^7.19.1", - "@uifabric/utilities": "^7.32.0", + "@uifabric/utilities": "^7.32.1", "@uifabric/set-version": "^7.0.23", "tslib": "^1.10.0" }, diff --git a/packages/theme/src/types/Theme.ts b/packages/theme/src/types/Theme.ts index d3ac8a44d9153..c416aca0b01e9 100644 --- a/packages/theme/src/types/Theme.ts +++ b/packages/theme/src/types/Theme.ts @@ -7,7 +7,7 @@ import { IStyleFunctionOrObject } from '@uifabric/utilities'; export type ColorTokens = Partial<{ background: string; contentColor: string; - subTextColor: string; + secondaryContentColor: string; linkColor: string; iconColor: string; borderColor: string; @@ -67,7 +67,12 @@ export type RecursivePartial = { }; export interface Tokens { - body: ColorTokenSet & TokenSetType; + color: { + body: ColorTokenSet & TokenSetType; + brand: ColorTokenSet & TokenSetType; + [key: string]: TokenSetType; + }; + [key: string]: TokenSetType; } diff --git a/packages/tsx-editor/package.json b/packages/tsx-editor/package.json index 72bbd17cbf3e0..6dfdda5012005 100644 --- a/packages/tsx-editor/package.json +++ b/packages/tsx-editor/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/tsx-editor", - "version": "0.13.10", + "version": "0.13.12", "description": "Live tsx editor for Fabric website.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -40,8 +40,8 @@ "@microsoft/load-themed-styles": "^1.10.26", "@uifabric/example-data": "^7.1.4", "@uifabric/monaco-editor": "^0.5.18", - "@uifabric/react-hooks": "^7.13.2", - "office-ui-fabric-react": "^7.137.3", + "@uifabric/react-hooks": "^7.13.3", + "office-ui-fabric-react": "^7.137.5", "raw-loader": "^0.5.1", "react-syntax-highlighter": "^10.1.3", "tslib": "^1.10.0", diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 0d2be5f8cf30e..898f11871110f 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/utilities", - "version": "7.32.0", + "version": "7.32.1", "description": "Fluent UI React utilities for building components.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -48,7 +48,7 @@ "sinon": "^4.1.3" }, "dependencies": { - "@fluentui/dom-utilities": "^1.1.0", + "@fluentui/dom-utilities": "^1.1.1", "@uifabric/merge-styles": "^7.19.1", "@uifabric/set-version": "^7.0.23", "prop-types": "^15.7.2", diff --git a/packages/variants/package.json b/packages/variants/package.json index c43bdd5270cba..2c403813e8085 100644 --- a/packages/variants/package.json +++ b/packages/variants/package.json @@ -1,6 +1,6 @@ { "name": "@uifabric/variants", - "version": "7.2.14", + "version": "7.2.16", "description": "Fluent UI React subtheme generator.", "main": "lib-commonjs/index.js", "module": "lib/index.js", @@ -28,7 +28,7 @@ }, "dependencies": { "@uifabric/set-version": "^7.0.23", - "office-ui-fabric-react": "^7.137.3", + "office-ui-fabric-react": "^7.137.5", "tslib": "^1.10.0" } } diff --git a/packages/web-components/CHANGELOG.json b/packages/web-components/CHANGELOG.json index dff32d2361079..d6191ceffe4ba 100644 --- a/packages/web-components/CHANGELOG.json +++ b/packages/web-components/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@fluentui/web-components", "entries": [ + { + "date": "Thu, 17 Sep 2020 12:25:04 GMT", + "tag": "@fluentui/web-components_v0.2.0", + "version": "0.2.0", + "comments": { + "minor": [ + { + "comment": "Feat: convert card to be design system provider", + "author": "jes@microsoft.com", + "commit": "7ea84b6f135e4193b28495ef120f289016e392e4", + "package": "@fluentui/web-components" + } + ] + } + }, { "date": "Tue, 01 Sep 2020 12:27:02 GMT", "tag": "@fluentui/web-components_v0.1.8", diff --git a/packages/web-components/CHANGELOG.md b/packages/web-components/CHANGELOG.md index 635a0d7f822b1..e9907940b9398 100644 --- a/packages/web-components/CHANGELOG.md +++ b/packages/web-components/CHANGELOG.md @@ -1,9 +1,18 @@ # Change Log - @fluentui/web-components -This log was last generated on Tue, 01 Sep 2020 12:27:02 GMT and should not be manually modified. +This log was last generated on Thu, 17 Sep 2020 12:25:04 GMT and should not be manually modified. +## [0.2.0](https://github.com/microsoft/fluentui/tree/@fluentui/web-components_v0.2.0) + +Thu, 17 Sep 2020 12:25:04 GMT +[Compare changes](https://github.com/microsoft/fluentui/compare/@fluentui/web-components_v0.1.8..@fluentui/web-components_v0.2.0) + +### Minor changes + +- Feat: convert card to be design system provider ([PR #15068](https://github.com/microsoft/fluentui/pull/15068) by jes@microsoft.com) + ## [0.1.8](https://github.com/microsoft/fluentui/tree/@fluentui/web-components_v0.1.8) Tue, 01 Sep 2020 12:27:02 GMT diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 938d63dd1be0c..214525f056b8e 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -2,7 +2,7 @@ "name": "@fluentui/web-components", "description": "A library of Fluent Web Components", "sideEffects": false, - "version": "0.1.8", + "version": "0.2.0", "author": { "name": "Microsoft", "url": "https://discord.gg/FcSNfg4" diff --git a/packages/web-components/src/card/card.styles.ts b/packages/web-components/src/card/card.styles.ts index 27d49e8af8ece..fc86dc88e2083 100644 --- a/packages/web-components/src/card/card.styles.ts +++ b/packages/web-components/src/card/card.styles.ts @@ -11,7 +11,7 @@ export const CardStyles = css` height: var(--card-height, 100%); width: var(--card-width, 100%); box-sizing: border-box; - background: ${neutralFillCardRestBehavior.var}; + background: var(--background-color); border-radius: calc(var(--elevated-corner-radius) * 1px); ${elevation} } diff --git a/packages/web-components/src/card/fixtures/card.html b/packages/web-components/src/card/fixtures/card.html index 226e0175a242e..f6d0edb7450ff 100644 --- a/packages/web-components/src/card/fixtures/card.html +++ b/packages/web-components/src/card/fixtures/card.html @@ -8,6 +8,12 @@ .state-override:hover { --elevation: 12; } + + .controls { + display: flex; + margin: 20px; + flex-direction: column; + }
    @@ -19,5 +25,15 @@
    Custom depth on hover using CSS +
    + + Custom background +
    + Accent + Stealth + Outline + Lightweight +
    +
    diff --git a/packages/web-components/src/card/index.ts b/packages/web-components/src/card/index.ts index 81daae674d3d4..78a92719ddf22 100644 --- a/packages/web-components/src/card/index.ts +++ b/packages/web-components/src/card/index.ts @@ -1,5 +1,7 @@ import { customElement } from '@microsoft/fast-element'; -import { Card, CardTemplate as template } from '@microsoft/fast-foundation'; +import { ColorRGBA64, parseColorHexRGB } from '@microsoft/fast-colors'; +import { designSystemProperty, DesignSystemProvider, CardTemplate as template } from '@microsoft/fast-foundation'; +import { createColorPalette, DesignSystem } from '@microsoft/fast-components-styles-msft'; import { CardStyles as styles } from './card.styles'; /** @@ -16,7 +18,43 @@ import { CardStyles as styles } from './card.styles'; template, styles, }) -export class FluentCard extends Card {} +export class FluentCard extends DesignSystemProvider + implements Pick { + /** + * Background color for the banner component. Sets context for the design system. + * @public + * @remarks + * HTML Attribute: background-color + */ + @designSystemProperty({ + attribute: 'background-color', + default: '#FFFFFF', + }) + public backgroundColor: string; + private backgroundColorChanged(): void { + const parsedColor = parseColorHexRGB(this.backgroundColor); + this.neutralPalette = createColorPalette(parsedColor as ColorRGBA64); + } + + /** + * Neutral pallette for the the design system provider. + * @internal + */ + @designSystemProperty({ + attribute: false, + default: createColorPalette(parseColorHexRGB('#FFFFFF')!), + cssCustomProperty: false, + }) + public neutralPalette: string[]; + + connectedCallback(): void { + super.connectedCallback(); + + if (this.backgroundColor === undefined) { + this.setAttribute('use-defaults', ''); + } + } +} /** * Styles for Card