From 3d3b429d1aeec7340a0072b41139e9fff78a01d5 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Fri, 3 Jan 2020 17:05:15 +0100 Subject: [PATCH 01/15] Prototype: Context usage for collections --- .../List/Content/ListExampleContent.tsx | 12 +- .../List/Content/ListExampleContentMedia.tsx | 3 + .../List/Content/ListExampleEndMedia.tsx | 3 + .../List/Content/ListExampleHeader.tsx | 6 +- .../List/Content/ListExampleHeaderContent.tsx | 3 + .../List/Content/ListExampleHeaderMedia.tsx | 6 +- .../List/Content/ListExampleMedia.tsx | 3 + .../List/Performance/ListNested.perf.tsx | 865 ++++++++++++++++++ .../components/List/Performance/index.tsx | 5 + .../components/List/Types/ListExample.tsx | 3 + .../List/Types/ListExampleNavigable.tsx | 3 + .../List/Types/ListExampleSelectable.tsx | 6 +- .../components/List/Usage/ListExampleMemo.tsx | 59 ++ .../examples/components/List/Usage/index.tsx | 19 + .../List/Variations/ListExampleTruncate.tsx | 3 + docs/src/examples/components/List/index.tsx | 2 + .../docs-components/src/knobs/KnobContexts.ts | 8 +- .../src/knobs/KnobProvider.tsx | 17 +- .../src/knobs/LogInspector.tsx | 6 +- packages/docs-components/src/knobs/types.ts | 6 +- .../docs-components/src/knobs/useLogKnob.ts | 11 +- .../src/hooks/useAccessibility.ts | 1 + .../src/hooks/useStateManager.ts | 15 +- .../test/hooks/useStateManager-test.tsx | 31 +- packages/react-context-selector/.gulp.js | 1 + packages/react-context-selector/README.md | 45 + .../react-context-selector/babel.config.js | 1 + packages/react-context-selector/gulpfile.ts | 1 + packages/react-context-selector/package.json | 32 + .../src/createContext.ts | 57 ++ packages/react-context-selector/src/index.ts | 4 + packages/react-context-selector/src/types.ts | 15 + .../src/useContextSelector.ts | 69 ++ .../src/useContextSelectors.ts | 96 ++ packages/react-context-selector/src/utils.ts | 13 + packages/react-context-selector/tsconfig.json | 14 + packages/react/src/components/List/List.tsx | 63 +- .../react/src/components/List/ListItem.tsx | 30 +- packages/react/src/components/List/context.ts | 18 + .../react/src/components/Menu/MenuDivider.tsx | 97 +- packages/react/tsconfig.json | 1 + packages/state/src/managers/menuManager.ts | 26 + 42 files changed, 1570 insertions(+), 109 deletions(-) create mode 100644 docs/src/examples/components/List/Performance/ListNested.perf.tsx create mode 100644 docs/src/examples/components/List/Usage/ListExampleMemo.tsx create mode 100644 docs/src/examples/components/List/Usage/index.tsx create mode 100644 packages/react-context-selector/.gulp.js create mode 100644 packages/react-context-selector/README.md create mode 100644 packages/react-context-selector/babel.config.js create mode 100644 packages/react-context-selector/gulpfile.ts create mode 100644 packages/react-context-selector/package.json create mode 100644 packages/react-context-selector/src/createContext.ts create mode 100644 packages/react-context-selector/src/index.ts create mode 100644 packages/react-context-selector/src/types.ts create mode 100644 packages/react-context-selector/src/useContextSelector.ts create mode 100644 packages/react-context-selector/src/useContextSelectors.ts create mode 100644 packages/react-context-selector/src/utils.ts create mode 100644 packages/react-context-selector/tsconfig.json create mode 100644 packages/react/src/components/List/context.ts create mode 100644 packages/state/src/managers/menuManager.ts diff --git a/docs/src/examples/components/List/Content/ListExampleContent.tsx b/docs/src/examples/components/List/Content/ListExampleContent.tsx index d1287a2b6e..0af74a9f5b 100644 --- a/docs/src/examples/components/List/Content/ListExampleContent.tsx +++ b/docs/src/examples/components/List/Content/ListExampleContent.tsx @@ -3,9 +3,15 @@ import { List } from '@fluentui/react' const ListExample = () => ( - - - + + + ) diff --git a/docs/src/examples/components/List/Content/ListExampleContentMedia.tsx b/docs/src/examples/components/List/Content/ListExampleContentMedia.tsx index b7b58bf498..777b5c0131 100644 --- a/docs/src/examples/components/List/Content/ListExampleContentMedia.tsx +++ b/docs/src/examples/components/List/Content/ListExampleContentMedia.tsx @@ -6,14 +6,17 @@ const ListExample = () => ( ) diff --git a/docs/src/examples/components/List/Content/ListExampleEndMedia.tsx b/docs/src/examples/components/List/Content/ListExampleEndMedia.tsx index 2aa902d2ce..e0b855c182 100644 --- a/docs/src/examples/components/List/Content/ListExampleEndMedia.tsx +++ b/docs/src/examples/components/List/Content/ListExampleEndMedia.tsx @@ -9,16 +9,19 @@ const ListExample = () => ( content="Program the sensor to the SAS alarm through the haptic SQL card!" endMedia={ellipsis} selectable + index={0} /> ) diff --git a/docs/src/examples/components/List/Content/ListExampleHeader.tsx b/docs/src/examples/components/List/Content/ListExampleHeader.tsx index 0839a297d1..54d707793b 100644 --- a/docs/src/examples/components/List/Content/ListExampleHeader.tsx +++ b/docs/src/examples/components/List/Content/ListExampleHeader.tsx @@ -3,9 +3,9 @@ import { List } from '@fluentui/react' const ListExample = () => ( - - - + + + ) diff --git a/docs/src/examples/components/List/Content/ListExampleHeaderContent.tsx b/docs/src/examples/components/List/Content/ListExampleHeaderContent.tsx index b1822eb1fe..a9bbceea56 100644 --- a/docs/src/examples/components/List/Content/ListExampleHeaderContent.tsx +++ b/docs/src/examples/components/List/Content/ListExampleHeaderContent.tsx @@ -6,14 +6,17 @@ const ListExample = () => ( ) diff --git a/docs/src/examples/components/List/Content/ListExampleHeaderMedia.tsx b/docs/src/examples/components/List/Content/ListExampleHeaderMedia.tsx index 91b19c8d95..971cd68712 100644 --- a/docs/src/examples/components/List/Content/ListExampleHeaderMedia.tsx +++ b/docs/src/examples/components/List/Content/ListExampleHeaderMedia.tsx @@ -3,9 +3,9 @@ import { List } from '@fluentui/react' const ListExample = () => ( - - - + + + ) diff --git a/docs/src/examples/components/List/Content/ListExampleMedia.tsx b/docs/src/examples/components/List/Content/ListExampleMedia.tsx index 6028eef957..3da3a4c7d9 100644 --- a/docs/src/examples/components/List/Content/ListExampleMedia.tsx +++ b/docs/src/examples/components/List/Content/ListExampleMedia.tsx @@ -8,18 +8,21 @@ const ListExampleMedia = () => ( header="Irving Kuhic" headerMedia="7:26:56 AM" content="Program the sensor to the SAS alarm through the haptic SQL card!" + index={0} /> } header="Skyler Parks" headerMedia="11:30:17 PM" content="Use the online FTP application to input the multi-byte application!" + index={1} /> } header="Dante Schneider" headerMedia="5:22:40 PM" content="The GB pixel is down, navigate the virtual interface!" + index={2} /> ) diff --git a/docs/src/examples/components/List/Performance/ListNested.perf.tsx b/docs/src/examples/components/List/Performance/ListNested.perf.tsx new file mode 100644 index 0000000000..946aa70b2b --- /dev/null +++ b/docs/src/examples/components/List/Performance/ListNested.perf.tsx @@ -0,0 +1,865 @@ +import { List } from '@fluentui/react' +import * as React from 'react' + +const items = [ + { + key: 'item-0', + media: 'ecd4:f115:0db5:e490:4680:e0bb:ed31:ef38', + header: 'Golden19', + headerMedia: '2/11/2019', + content: 'Veritatis labore tenetur eius similique voluptatem qui labore consequuntur eaque.', + contentMedia: 'Molestiae modi qui ipsam odio unde praesentium.', + }, + { + key: 'item-1', + media: '812d:173e:02c2:3c33:d0a8:d86c:327b:4356', + header: 'Christine.Feeney', + headerMedia: '3/8/2019', + content: 'Sequi voluptas corporis vel deleniti exercitationem ipsam rem hic.', + contentMedia: 'Nulla quae minima.', + }, + { + key: 'item-2', + media: '2f5e:5dab:e88c:b583:b512:b2c3:ecf6:f860', + header: 'Amaya.Ritchie', + headerMedia: '6/22/2019', + content: 'Neque nobis totam rerum.', + contentMedia: 'Aut tempora eaque rem blanditiis.', + }, + { + key: 'item-3', + media: 'b072:ad2d:9ee1:f790:5e46:7b8c:75d1:4140', + header: 'Cora.Rippin22', + headerMedia: '7/13/2019', + content: 'Alias error tempore dolorem laudantium sapiente.', + contentMedia: 'Quis ducimus explicabo quia quia rerum dolor deserunt unde.', + }, + { + key: 'item-4', + media: '9fbb:5c11:582f:e8dd:4e79:f56a:0acb:6bf9', + header: 'Melyssa_Walker3', + headerMedia: '9/26/2019', + content: 'Eum quo non eum repellendus facere sint.', + contentMedia: 'Fuga explicabo et ad labore voluptas voluptatibus et perspiciatis.', + }, + { + key: 'item-5', + media: 'a6ba:8322:b8e2:98ad:f86d:9e87:6322:4058', + header: 'Martina.Cronin', + headerMedia: '11/27/2019', + content: 'Dolorem eveniet dolores repellat officia temporibus sequi.', + contentMedia: 'Odio commodi laborum nobis quia.', + }, + { + key: 'item-6', + media: '5fcd:fa53:c6f1:3bc0:9add:2fd4:d01d:4bf7', + header: 'Trinity16', + headerMedia: '1/16/2020', + content: 'Debitis culpa sit consequatur corrupti.', + contentMedia: 'Vero placeat id.', + }, + { + key: 'item-7', + media: 'f5fe:4259:50e5:72cd:67b3:650b:27c8:5ebc', + header: 'Emmet14', + headerMedia: '5/10/2019', + content: 'Ratione omnis nesciunt repellendus voluptatem aut sint amet voluptatem.', + contentMedia: 'Enim aliquam error quaerat.', + }, + { + key: 'item-8', + media: 'fd29:194a:d44c:dfc0:fce1:bc6c:a75c:c873', + header: 'Chet87', + headerMedia: '7/30/2019', + content: 'Dolor dolor inventore illum eum nulla.', + contentMedia: 'Ut debitis aut.', + }, + { + key: 'item-9', + media: '3f6c:62fe:deea:e526:e242:4e79:0883:8eb3', + header: 'Horacio39', + headerMedia: '5/6/2019', + content: 'Laborum fugit ut debitis voluptatum enim ea voluptas quas fugiat.', + contentMedia: 'Sint minus ut.', + }, + { + key: 'item-10', + media: '5b44:9c9d:bb62:50a9:77b0:3a61:a133:818c', + header: 'Aletha.Heaney28', + headerMedia: '7/12/2019', + content: 'Magnam velit et quo.', + contentMedia: 'Distinctio delectus modi et.', + }, + { + key: 'item-11', + media: '922b:68b1:432b:1c11:6734:e7de:baf6:d202', + header: 'Lempi85', + headerMedia: '6/3/2019', + content: 'Consequatur pariatur vel.', + contentMedia: 'Quis quibusdam at repellendus vero fuga accusamus atque provident aperiam.', + }, + { + key: 'item-12', + media: '9c88:6bcf:1534:d98b:9dfa:170e:0fe4:6f99', + header: 'Dayton19', + headerMedia: '9/9/2019', + content: 'Tempora sint corrupti.', + contentMedia: 'Ea est id aut facilis ex possimus.', + }, + { + key: 'item-13', + media: '931c:709c:7c0c:4142:8cb5:1e7d:a205:5ce5', + header: 'Jerel_Dooley95', + headerMedia: '4/7/2019', + content: 'Ullam nostrum est et necessitatibus possimus distinctio dolorum impedit dolor.', + contentMedia: 'Vitae tempore minima dolores tempora ea.', + }, + { + key: 'item-14', + media: '6309:59b2:6fed:d765:077b:94fa:e22d:2a6a', + header: 'Tate.Kuhic', + headerMedia: '8/8/2019', + content: 'In rerum molestiae.', + contentMedia: 'Omnis voluptate nesciunt voluptatem.', + }, + { + key: 'item-15', + media: 'a1b3:9bf4:3b64:4e8f:6a21:16c3:d208:551d', + header: 'Rosalee56', + headerMedia: '11/19/2019', + content: 'Mollitia quia saepe ut nihil et iure expedita repellendus.', + contentMedia: 'Consequuntur ea quia recusandae ratione.', + }, + { + key: 'item-16', + media: '50d1:2a0c:5997:506a:e748:02f4:c9d2:0e45', + header: 'Dorothy_Osinski96', + headerMedia: '6/28/2019', + content: 'Eum temporibus dicta impedit quas.', + contentMedia: 'Consequatur autem minus ut eveniet quo suscipit illo.', + }, + { + key: 'item-17', + media: '97e3:a2a2:8287:63a5:d1f6:5672:d711:7e8e', + header: 'Connie65', + headerMedia: '4/1/2019', + content: 'In ad beatae debitis.', + contentMedia: 'Quia quaerat ut aut in aspernatur et.', + }, + { + key: 'item-18', + media: '05ce:4ecf:1041:8415:b358:be0e:a65c:c275', + header: 'Raphael44', + headerMedia: '3/6/2019', + content: 'Perferendis soluta hic sint eum.', + contentMedia: 'Ut voluptas et consectetur est.', + }, + { + key: 'item-19', + media: 'cc8d:f10c:cf4f:82fa:64a2:1a36:2461:3434', + header: 'Roberta36', + headerMedia: '5/5/2019', + content: 'Molestiae nihil laborum hic pariatur deserunt ullam similique quibusdam quia.', + contentMedia: 'Qui qui id aut deserunt quidem temporibus voluptas.', + }, + { + key: 'item-20', + media: '63ac:0869:ef30:fd2e:e7ce:71d0:2519:20c1', + header: 'Amara45', + headerMedia: '5/13/2019', + content: 'Placeat a sed quam vel ipsa et quisquam aspernatur.', + contentMedia: 'Aut vero accusamus veritatis atque laboriosam harum ab fugit aut.', + }, + { + key: 'item-21', + media: '7f85:444a:43b9:c878:c5f8:02ac:796d:1006', + header: 'Cassandra86', + headerMedia: '1/21/2020', + content: 'Ut eaque id deserunt consequatur cupiditate.', + contentMedia: 'Ducimus aliquam voluptatem sed dolores reiciendis.', + }, + { + key: 'item-22', + media: '1c44:7233:decf:3ba9:fc2f:6c14:3358:10eb', + header: 'Kasandra.Jacobson', + headerMedia: '6/4/2019', + content: 'Ut culpa nobis enim.', + contentMedia: 'Repellat ut nesciunt quis consequuntur ea.', + }, + { + key: 'item-23', + media: '45da:675d:839f:4aad:c839:f771:16a3:433c', + header: 'Orrin65', + headerMedia: '1/9/2020', + content: 'Sit facilis nihil enim sint.', + contentMedia: 'Atque quia dolorem porro delectus libero optio ea ut.', + }, + { + key: 'item-24', + media: 'f5bd:fd04:d136:e7cb:dfb6:3f0f:418b:fc51', + header: 'Imelda79', + headerMedia: '1/28/2019', + content: 'Aliquam odio repudiandae quia qui tenetur accusantium officia.', + contentMedia: 'Ullam accusamus facere sed iure omnis fuga.', + }, + { + key: 'item-25', + media: '0ed1:d677:780f:1af8:689b:a134:f0b3:a715', + header: 'Gerson81', + headerMedia: '4/29/2019', + content: 'Quo quam dolores consequuntur autem nisi unde placeat eum.', + contentMedia: 'Harum eos repellat nihil.', + }, + { + key: 'item-26', + media: 'd473:7c5c:e688:d3e9:d168:34e3:68ab:cb34', + header: 'Ladarius7', + headerMedia: '12/9/2019', + content: 'Doloremque corporis vero explicabo est similique in.', + contentMedia: 'Voluptas consequatur voluptates.', + }, + { + key: 'item-27', + media: '4a2d:dc17:5ef1:d813:f1d7:73a9:d3ae:3b4f', + header: 'Dedric68', + headerMedia: '2/4/2019', + content: 'Pariatur ab aspernatur minus.', + contentMedia: 'Asperiores vel aliquam doloribus quam maxime cumque nisi distinctio autem.', + }, + { + key: 'item-28', + media: '094c:8254:1146:55d1:6d90:b41d:270a:c993', + header: 'Noemie_Nicolas50', + headerMedia: '12/24/2019', + content: 'Ipsam inventore est omnis eveniet.', + contentMedia: 'Animi tempore consequatur voluptatem eaque rerum sit ut et.', + }, + { + key: 'item-29', + media: 'a903:400f:c7ab:8547:4cf4:0cea:ad85:2f84', + header: 'Natalie_Kessler12', + headerMedia: '9/13/2019', + content: 'Officiis aperiam sed et beatae animi.', + contentMedia: 'Et impedit numquam.', + }, + { + key: 'item-30', + media: '14a0:7da0:6561:bf6e:7b72:6afe:175d:6725', + header: 'Liza.Tromp92', + headerMedia: '2/25/2019', + content: 'Occaecati a exercitationem dolor.', + contentMedia: 'Tempora eaque quo consectetur.', + }, + { + key: 'item-31', + media: '3d66:0500:1c4a:b289:eab9:a3d8:fc1d:8036', + header: 'Corrine.Ondricka', + headerMedia: '9/17/2019', + content: 'Quia sint omnis saepe exercitationem et necessitatibus minima.', + contentMedia: 'Necessitatibus eum neque totam accusamus iste.', + }, + { + key: 'item-32', + media: '3ef6:2e67:4d8a:e0fb:c614:0b87:b046:ee02', + header: 'Xzavier.Torphy', + headerMedia: '8/15/2019', + content: 'Sed saepe et.', + contentMedia: 'Nihil similique sunt ducimus.', + }, + { + key: 'item-33', + media: '8d1b:6a57:b845:deeb:41bd:1f88:6f7b:804f', + header: 'Odell89', + headerMedia: '2/10/2019', + content: 'Ut in voluptatem laudantium adipisci deleniti fugiat nam natus.', + contentMedia: 'Molestiae id dolorem sit.', + }, + { + key: 'item-34', + media: 'f48c:d246:d89a:213b:a252:03a3:7b23:c5e1', + header: 'Shakira.Wuckert51', + headerMedia: '1/19/2020', + content: 'Recusandae maiores laborum voluptas excepturi numquam.', + contentMedia: 'Alias et earum exercitationem rerum sed.', + }, + { + key: 'item-35', + media: 'fd13:2bf9:b739:5216:5a5a:1697:ec14:f388', + header: 'Stephan30', + headerMedia: '8/8/2019', + content: 'Laborum numquam dolores deserunt incidunt accusamus ipsam.', + contentMedia: 'Iusto voluptas nobis.', + }, + { + key: 'item-36', + media: '4df1:1c3e:3519:e661:570a:95e4:7d42:2a9f', + header: 'Kenton_Vandervort68', + headerMedia: '11/19/2019', + content: 'Blanditiis nemo possimus autem et sunt exercitationem inventore dolorum.', + contentMedia: 'Odio aspernatur at sapiente voluptatem qui reiciendis suscipit non aut.', + }, + { + key: 'item-37', + media: 'b0fd:820f:8349:bbe6:709d:bb31:dbcd:04e8', + header: 'Milton39', + headerMedia: '4/16/2019', + content: 'Ratione nihil omnis enim aut rerum molestias nulla sit rem.', + contentMedia: 'Rem dolorem ab cupiditate consequatur cum incidunt et vero eveniet.', + }, + { + key: 'item-38', + media: '2e80:c46e:0b5e:e390:0f07:75a7:ef51:f03a', + header: 'Domenic.Bernier63', + headerMedia: '7/20/2019', + content: 'Amet et soluta rerum vero totam non consequatur sit dolorum.', + contentMedia: 'Explicabo in voluptas esse et sint.', + }, + { + key: 'item-39', + media: '7bed:44f3:d339:6da5:17bb:ac34:9d31:3ef5', + header: 'Deshawn68', + headerMedia: '9/20/2019', + content: 'Mollitia ut sapiente ex eaque ducimus sit culpa et corrupti.', + contentMedia: 'Amet voluptatem vero et est dicta est.', + }, + { + key: 'item-40', + media: '5c39:27f2:3d18:a5ed:805d:98c0:eca1:441d', + header: 'Eda_Willms', + headerMedia: '6/19/2019', + content: 'Nihil quasi et voluptatem dolore et.', + contentMedia: 'Rerum dolores reiciendis asperiores fuga nostrum vitae et.', + }, + { + key: 'item-41', + media: 'ae15:839e:803b:0e90:0382:1502:254c:ff49', + header: 'Eliza44', + headerMedia: '10/18/2019', + content: 'Veniam ab ducimus ut repellat.', + contentMedia: 'Et voluptatibus error nihil numquam similique.', + }, + { + key: 'item-42', + media: 'cd53:e6ce:1aff:8b32:3786:fefa:c00e:a801', + header: 'Garry.Connelly', + headerMedia: '9/25/2019', + content: 'Accusamus esse impedit ratione id sit vero veniam odit nemo.', + contentMedia: 'Eveniet vel dolor dolores quam animi rerum temporibus.', + }, + { + key: 'item-43', + media: 'bb43:d3e5:2f02:e726:0edb:5029:9c59:40b0', + header: 'Sigrid4', + headerMedia: '10/4/2019', + content: 'Quis ut est sunt sit sed facilis rerum debitis iusto.', + contentMedia: 'Quos assumenda quam ab sed ea assumenda explicabo suscipit vero.', + }, + { + key: 'item-44', + media: '63c0:e35e:8a1a:a3b2:90ae:bda1:4466:aac7', + header: 'Armando.Durgan', + headerMedia: '5/12/2019', + content: 'Et numquam non hic et occaecati suscipit.', + contentMedia: 'Fugiat voluptatum quia amet aut ut ea nam.', + }, + { + key: 'item-45', + media: '6d4a:3868:1ac0:7edc:3461:fe0e:df54:0d0d', + header: 'Theresia.Thiel', + headerMedia: '12/30/2019', + content: 'Enim animi sit beatae nisi rerum vitae velit maiores quia.', + contentMedia: 'Ut quisquam est doloremque voluptatem iure similique ab sit.', + }, + { + key: 'item-46', + media: 'e494:2079:8bac:cafb:b075:4718:3791:065a', + header: 'Yazmin_Kertzmann', + headerMedia: '10/27/2019', + content: 'Deleniti iste ab est ut nihil soluta aut rem ex.', + contentMedia: 'Distinctio fugit fugiat commodi consequatur laudantium aut quod.', + }, + { + key: 'item-47', + media: 'e8e8:d347:be4c:90ac:aabf:8fc4:fac2:0c75', + header: 'Terry.Schuppe42', + headerMedia: '8/1/2019', + content: 'Repellendus necessitatibus minima fugit autem odio vel accusantium accusamus quas.', + contentMedia: 'Nihil aliquam tenetur veritatis atque.', + }, + { + key: 'item-48', + media: '9b6f:cc70:91dd:2fae:cab3:892f:b12e:84b4', + header: 'Gilda_Kuhlman41', + headerMedia: '5/22/2019', + content: 'Eum aut assumenda quia aperiam culpa aliquid pariatur qui sit.', + contentMedia: 'Voluptatem delectus odit.', + }, + { + key: 'item-49', + media: 'a479:0ac9:94bd:eda7:2105:9b91:003e:ff0f', + header: 'Mathew_Bashirian', + headerMedia: '11/27/2019', + content: 'Iusto rerum voluptas.', + contentMedia: 'Ea molestiae quos cupiditate laborum.', + }, + { + key: 'item-50', + media: '86e6:9dab:6835:7cb3:39aa:009b:e1ee:3d3f', + header: 'Roma84', + headerMedia: '7/6/2019', + content: 'Deleniti sunt doloremque perferendis quod assumenda ipsa.', + contentMedia: 'Est commodi illo incidunt provident alias ab aut ut placeat.', + }, + { + key: 'item-51', + media: 'c9c0:e84c:b2e7:9687:9089:f2d3:3c24:ee17', + header: 'Hans_Hayes', + headerMedia: '4/29/2019', + content: 'Dolores quibusdam nihil et modi earum officia earum incidunt et.', + contentMedia: 'Et officiis ea velit tempore enim at commodi dolores.', + }, + { + key: 'item-52', + media: '8738:61a1:bc19:1958:c7cb:f878:1ddc:a847', + header: 'Sam80', + headerMedia: '11/14/2019', + content: 'Eaque quam distinctio.', + contentMedia: 'Vel numquam asperiores ut assumenda veniam est dolorum officiis.', + }, + { + key: 'item-53', + media: '5295:1bbf:5778:011e:207a:d67b:517a:a02e', + header: 'Madaline53', + headerMedia: '5/6/2019', + content: 'Et error rerum odio ex molestiae.', + contentMedia: 'In quidem numquam omnis voluptas nam et ratione.', + }, + { + key: 'item-54', + media: 'dc6b:7b5a:739b:9ce7:3bf0:0801:f19f:e5ca', + header: 'Ardith46', + headerMedia: '7/18/2019', + content: 'Qui possimus quae hic ea ex voluptatum culpa.', + contentMedia: 'Cupiditate sequi vel.', + }, + { + key: 'item-55', + media: 'da52:440c:e8ce:65aa:2492:6bb7:f34c:fe8d', + header: 'Linnea22', + headerMedia: '2/22/2019', + content: 'Rerum quis in ad ad maiores possimus error optio.', + contentMedia: 'Eaque quo explicabo sunt et rerum.', + }, + { + key: 'item-56', + media: '0a4f:bfa5:8c03:e5f8:75b3:0751:6763:912b', + header: 'Mervin.Hickle85', + headerMedia: '10/6/2019', + content: 'Quo nesciunt culpa aliquam consequuntur.', + contentMedia: 'Iure eos perferendis.', + }, + { + key: 'item-57', + media: 'ae6a:016b:11ca:4c9e:203e:045d:b0e4:12cf', + header: 'Abel78', + headerMedia: '8/22/2019', + content: 'Voluptatem saepe est sit illo nihil enim iure ut quia.', + contentMedia: 'Rerum officia ut eveniet aut consequatur.', + }, + { + key: 'item-58', + media: 'ebcd:0202:ef1b:94f7:7003:dbae:53cb:e035', + header: 'Lexi.Pacocha18', + headerMedia: '7/11/2019', + content: 'Autem qui voluptatem.', + contentMedia: 'Quis nostrum repellat maxime.', + }, + { + key: 'item-59', + media: '0ea2:847a:c88b:a67b:a525:b0d1:5801:3383', + header: 'Braeden44', + headerMedia: '9/29/2019', + content: 'Quae consequatur pariatur et ea.', + contentMedia: 'Quisquam odit ipsa quo.', + }, + { + key: 'item-60', + media: '39d9:38bc:a073:8df5:e24c:8061:a0b2:5797', + header: 'Dena44', + headerMedia: '11/4/2019', + content: 'Odio enim corrupti doloribus neque velit eum quaerat modi.', + contentMedia: 'Quo totam dolorum officia nihil corporis earum doloremque voluptas quod.', + }, + { + key: 'item-61', + media: 'd2d8:af56:3f45:fd15:befb:e30b:73df:c011', + header: 'Donato12', + headerMedia: '9/7/2019', + content: 'Et expedita vero recusandae soluta autem assumenda.', + contentMedia: 'Ullam fuga minima laboriosam facilis velit.', + }, + { + key: 'item-62', + media: 'f6c3:9c9f:9c45:7439:c286:e8ac:91b2:f3b0', + header: 'Walker.Heidenreich5', + headerMedia: '11/30/2019', + content: 'Beatae dolorem eveniet.', + contentMedia: 'Et voluptas ullam beatae corporis quaerat et sint quasi.', + }, + { + key: 'item-63', + media: '3df8:81ab:9f29:d0a1:4360:0b1c:c4ac:9ef6', + header: 'Rosalinda_Kuphal75', + headerMedia: '5/27/2019', + content: 'Atque animi consectetur laborum.', + contentMedia: 'Aut quisquam rem.', + }, + { + key: 'item-64', + media: 'c249:12c6:642c:49be:4a08:d66a:8d0d:1a98', + header: 'Bella_Becker46', + headerMedia: '12/6/2019', + content: 'Similique placeat et possimus voluptates quia non non.', + contentMedia: 'Sit et reiciendis ad unde est porro quibusdam.', + }, + { + key: 'item-65', + media: '6b01:d676:8c9a:7559:5639:493e:d3f8:e529', + header: 'Zechariah.Zulauf75', + headerMedia: '12/9/2019', + content: 'Optio dolorem aut.', + contentMedia: 'Blanditiis voluptas quia corrupti laborum.', + }, + { + key: 'item-66', + media: '61a1:e08f:3b2c:6775:a768:79ef:fbc5:1d2b', + header: 'Amina_Reilly', + headerMedia: '4/2/2019', + content: 'Sed earum fuga pariatur ipsam officia amet.', + contentMedia: 'Minus facilis quam.', + }, + { + key: 'item-67', + media: '32d8:895f:7f5f:f3f8:3f71:ddee:0e80:6da8', + header: 'Jackson.Bauch17', + headerMedia: '12/31/2019', + content: 'Non corrupti quisquam et.', + contentMedia: 'Nulla soluta explicabo esse ea in nemo id reiciendis.', + }, + { + key: 'item-68', + media: 'e51d:a9e9:c018:ab6b:78aa:ce10:d01e:ba61', + header: 'Marcelo99', + headerMedia: '9/19/2019', + content: 'Odit nobis dicta vero impedit expedita voluptates aliquid.', + contentMedia: 'Ipsum consequatur accusantium.', + }, + { + key: 'item-69', + media: 'bca7:7384:2454:e457:f029:5f2b:23b6:e8e9', + header: 'Leonora_Wolf35', + headerMedia: '10/17/2019', + content: 'Beatae ut cum et occaecati excepturi molestiae sequi aut esse.', + contentMedia: 'Ipsa laboriosam rerum.', + }, + { + key: 'item-70', + media: '01a2:9bbd:3a4f:1c02:c4f8:f232:dfa7:452d', + header: 'Haleigh.Williamson', + headerMedia: '9/23/2019', + content: 'Animi fuga in omnis.', + contentMedia: 'Eos reiciendis quidem nam corrupti vero atque dolores ut.', + }, + { + key: 'item-71', + media: '1642:ec3a:415b:a5a6:4873:853f:6052:f601', + header: 'Elian_Lang', + headerMedia: '11/12/2019', + content: 'Autem nesciunt quia magnam qui.', + contentMedia: 'Repudiandae atque ipsum perspiciatis possimus tempora deleniti a.', + }, + { + key: 'item-72', + media: 'c135:5daa:737b:aba0:2241:c19f:42ce:8a91', + header: 'Filiberto_Runolfsdottir', + headerMedia: '6/16/2019', + content: 'Itaque nesciunt sit ducimus error adipisci et est vitae.', + contentMedia: 'Doloribus praesentium ut ut qui sint mollitia quasi rem non.', + }, + { + key: 'item-73', + media: '4e2d:cda6:9c1e:f561:a416:ce2d:4ed4:1807', + header: 'Darwin_Price', + headerMedia: '1/18/2020', + content: 'Sit delectus corrupti omnis et alias reprehenderit praesentium nihil.', + contentMedia: 'Aut minus sunt ut.', + }, + { + key: 'item-74', + media: 'a8db:749a:e801:5d86:929a:f04f:0fe0:7f3c', + header: 'River.Shields', + headerMedia: '4/12/2019', + content: 'Nobis voluptatem ipsum.', + contentMedia: 'Deserunt non ex eos deserunt.', + }, + { + key: 'item-75', + media: '65ca:2e7d:5a73:ab88:3d47:6e95:8ce0:c0be', + header: 'Marcelina97', + headerMedia: '11/12/2019', + content: 'Dolores ex aut.', + contentMedia: 'Ad dolorem ullam libero ipsam vel eum id molestiae qui.', + }, + { + key: 'item-76', + media: '28b5:6f91:ba1a:a099:bd84:1805:3c4e:6d3a', + header: 'Hector_Jacobs', + headerMedia: '6/19/2019', + content: 'Assumenda nostrum aut eum.', + contentMedia: 'Accusamus quae rerum molestias reprehenderit ducimus in.', + }, + { + key: 'item-77', + media: '5a9b:2b1c:0c64:978e:d0d8:a205:f1ba:d022', + header: 'Hank.Gutkowski', + headerMedia: '11/28/2019', + content: 'Nam itaque et.', + contentMedia: 'Esse aut ducimus illo sint error doloremque consequatur sunt assumenda.', + }, + { + key: 'item-78', + media: '22e4:aa44:2ca1:3505:06a8:6eb8:fbfd:738c', + header: 'Bianka_Bode', + headerMedia: '10/14/2019', + content: 'Hic incidunt officia.', + contentMedia: 'Vitae sunt nostrum nobis occaecati sed molestiae est impedit.', + }, + { + key: 'item-79', + media: 'fbee:c95c:d813:44ac:3925:7642:8e1c:7e16', + header: 'Neil81', + headerMedia: '2/23/2019', + content: 'Excepturi et corporis sed autem unde qui doloribus.', + contentMedia: 'Distinctio consequatur dignissimos enim.', + }, + { + key: 'item-80', + media: 'a3f7:3619:438a:b723:1666:403f:864d:e8f1', + header: 'Finn.Abshire29', + headerMedia: '2/20/2019', + content: 'Explicabo magni omnis eum consequatur numquam tenetur.', + contentMedia: 'Quas exercitationem delectus quasi sequi ut repellat maiores recusandae aut.', + }, + { + key: 'item-81', + media: '3a7a:9528:8159:40aa:4e91:1a05:886d:59e6', + header: 'Bartholome_Bartoletti44', + headerMedia: '8/11/2019', + content: 'Adipisci et ducimus et pariatur consequatur quibusdam.', + contentMedia: 'Omnis architecto odit illo enim.', + }, + { + key: 'item-82', + media: 'e5fe:d840:e9ee:467f:a631:a5ee:26ea:f5e4', + header: 'Amanda_Eichmann', + headerMedia: '10/6/2019', + content: 'Libero vitae laborum omnis consectetur beatae aut quas architecto quis.', + contentMedia: 'Est iusto tempore quaerat.', + }, + { + key: 'item-83', + media: '4398:93a6:1de1:94f3:903e:954c:77cd:d013', + header: 'Izaiah30', + headerMedia: '5/5/2019', + content: 'Voluptate provident omnis sequi enim dolorem quod.', + contentMedia: 'Assumenda aut officia deleniti velit corporis.', + }, + { + key: 'item-84', + media: 'defd:93e0:086c:c54c:91f8:3cd3:f906:2399', + header: 'Emmanuelle_Hodkiewicz', + headerMedia: '10/20/2019', + content: 'Quis quasi reiciendis cupiditate necessitatibus deleniti.', + contentMedia: 'Nam debitis voluptate labore ea non vel eveniet sint consequuntur.', + }, + { + key: 'item-85', + media: '5500:c4a6:1062:fb2b:1ec0:7a2f:2ff6:00a6', + header: 'Bailee_Metz4', + headerMedia: '9/11/2019', + content: 'Cumque ut et ut voluptates aliquam dolore occaecati quisquam rem.', + contentMedia: 'Consequatur deleniti sed illum cupiditate aliquam.', + }, + { + key: 'item-86', + media: '28ff:bcb7:ba28:6f30:9193:8d67:fd3a:a95f', + header: 'Roel14', + headerMedia: '1/9/2020', + content: 'Facere omnis itaque facere eligendi iusto corporis.', + contentMedia: 'Et autem pariatur iste magnam esse ab.', + }, + { + key: 'item-87', + media: '793d:32e6:3b90:1e5c:2974:4a05:9d15:a687', + header: 'Liliane.Welch', + headerMedia: '4/7/2019', + content: 'Aut unde nostrum quis corrupti placeat quibusdam.', + contentMedia: 'Saepe sit quis ullam.', + }, + { + key: 'item-88', + media: 'ab9f:016b:aabe:45cd:6457:a5be:1449:9ac3', + header: 'Emmet_Fritsch58', + headerMedia: '11/5/2019', + content: 'Sint culpa et corporis consequatur doloremque placeat fugiat nemo rerum.', + contentMedia: 'Deserunt deleniti et consequuntur consequuntur nemo molestias excepturi rerum.', + }, + { + key: 'item-89', + media: '5426:e139:7c43:360c:e540:305d:0dd3:c3d4', + header: 'Alba9', + headerMedia: '10/17/2019', + content: 'Expedita commodi qui.', + contentMedia: 'Quasi non sunt suscipit nesciunt enim ipsam quaerat.', + }, + { + key: 'item-90', + media: '64fc:571b:ad34:65cb:deab:4ce8:220b:8e67', + header: 'Colton_Stroman9', + headerMedia: '11/6/2019', + content: 'Impedit dolorum ut facilis neque qui.', + contentMedia: 'At amet hic.', + }, + { + key: 'item-91', + media: '7788:9eec:848d:468b:0264:2c4b:dd36:cec7', + header: 'Baylee.Leffler9', + headerMedia: '7/14/2019', + content: 'Aut dolores aperiam.', + contentMedia: 'Voluptatem aut aut molestias labore optio deserunt harum quos reprehenderit.', + }, + { + key: 'item-92', + media: 'b4f5:008d:9fe0:cef5:e1a0:f291:8a2e:50c8', + header: 'Emmanuel.Crona', + headerMedia: '2/6/2019', + content: 'Facere repudiandae rem voluptatem saepe rerum est velit porro.', + contentMedia: 'Molestiae aut eum reiciendis et voluptatibus quasi ut quasi consectetur.', + }, + { + key: 'item-93', + media: '77e2:f427:7f58:5e7e:7e70:c3b0:80cd:4495', + header: 'Aurelia.Olson4', + headerMedia: '10/17/2019', + content: 'Tempore aut facilis laboriosam minus.', + contentMedia: 'Id omnis est voluptatem sit non quo quaerat.', + }, + { + key: 'item-94', + media: '0079:dea3:8261:4801:d2d5:7dea:0dc2:4c2c', + header: 'Morris_Morar', + headerMedia: '10/27/2019', + content: 'Qui explicabo non.', + contentMedia: 'Dolore consequatur totam modi et.', + }, + { + key: 'item-95', + media: '6f85:fda7:38b4:e019:606d:6ed6:56c4:a4bf', + header: 'Ray.Berge', + headerMedia: '8/8/2019', + content: 'Et amet ut rerum debitis consequatur.', + contentMedia: 'Omnis voluptatum ut quisquam ut quidem.', + }, + { + key: 'item-96', + media: 'f498:acb8:7241:3d26:3fa3:a63f:3fd9:da18', + header: 'Freida.Keeling99', + headerMedia: '7/7/2019', + content: 'Et eveniet quae sapiente.', + contentMedia: 'Ea optio voluptatem maxime sunt aliquam nesciunt quia dicta sint.', + }, + { + key: 'item-97', + media: 'ca0a:c574:e92f:fc64:679b:27f6:930e:9379', + header: 'Frederic_Schuppe', + headerMedia: '2/10/2019', + content: 'Aliquam incidunt est aut sit esse.', + contentMedia: 'Non voluptatem quae adipisci et voluptatem voluptas velit ut.', + }, + { + key: 'item-98', + media: 'd909:4f8a:12bc:921d:b8f2:b9d5:b036:e5f9', + header: 'Selena.Schultz16', + headerMedia: '5/21/2019', + content: 'Quam dolorem rerum quo nulla.', + contentMedia: 'Laudantium ut quia iusto.', + }, + { + key: 'item-99', + media: 'd049:44b4:1d31:c7d1:62fd:db1c:2d79:b4c9', + header: 'Chloe_Shields0', + headerMedia: '2/10/2019', + content: 'Optio quas culpa eum unde debitis laudantium et excepturi.', + contentMedia: 'Molestias officia est delectus harum inventore dolor.', + }, +] + +const ListNestedPerf = () => ( + , + }, + { + key: 'list-2', + content: , + }, + { + key: 'list-3', + content: , + }, + ]} + /> + ), + }, + { + key: 'list-2', + content: ( + , + }, + { + key: 'list-2', + content: , + }, + { + key: 'list-3', + content: , + }, + ]} + /> + ), + }, + ]} + /> +) + +ListNestedPerf.iterations = 100 +ListNestedPerf.filename = 'ListNested.perf.tsx' + +export default ListNestedPerf diff --git a/docs/src/examples/components/List/Performance/index.tsx b/docs/src/examples/components/List/Performance/index.tsx index 0f98895912..a51ed1fbea 100644 --- a/docs/src/examples/components/List/Performance/index.tsx +++ b/docs/src/examples/components/List/Performance/index.tsx @@ -10,6 +10,11 @@ const Performance = () => ( description="A typical list with common slots filled." examplePath="components/List/Performance/ListCommon.perf" /> + ) diff --git a/docs/src/examples/components/List/Types/ListExample.tsx b/docs/src/examples/components/List/Types/ListExample.tsx index 72685b974d..52e49d1a97 100644 --- a/docs/src/examples/components/List/Types/ListExample.tsx +++ b/docs/src/examples/components/List/Types/ListExample.tsx @@ -12,18 +12,21 @@ const ListExampleSelectable = () => { header="Irving Kuhic" headerMedia="7:26:56 AM" content="Program the sensor to the SAS alarm through the haptic SQL card!" + index={0} /> } header="Skyler Parks" headerMedia="11:30:17 PM" content="Use the online FTP application to input the multi-byte application!" + index={1} /> } header="Dante Schneider" headerMedia="5:22:40 PM" content="The GB pixel is down, navigate the virtual interface!" + index={2} /> ) diff --git a/docs/src/examples/components/List/Types/ListExampleNavigable.tsx b/docs/src/examples/components/List/Types/ListExampleNavigable.tsx index 1ff3071564..8ff504ff42 100644 --- a/docs/src/examples/components/List/Types/ListExampleNavigable.tsx +++ b/docs/src/examples/components/List/Types/ListExampleNavigable.tsx @@ -9,6 +9,7 @@ const ListExampleNavigable = () => ( headerMedia="7:26:56 AM" content="Program the sensor to the SAS alarm through the haptic SQL card!" navigable + index={0} /> } @@ -16,6 +17,7 @@ const ListExampleNavigable = () => ( headerMedia="11:30:17 PM" content="Use the online FTP application to input the multi-byte application!" navigable + index={1} /> } @@ -23,6 +25,7 @@ const ListExampleNavigable = () => ( headerMedia="5:22:40 PM" content="The GB pixel is down, navigate the virtual interface!" navigable + index={2} /> ) diff --git a/docs/src/examples/components/List/Types/ListExampleSelectable.tsx b/docs/src/examples/components/List/Types/ListExampleSelectable.tsx index f8d97a0202..28f6db6108 100644 --- a/docs/src/examples/components/List/Types/ListExampleSelectable.tsx +++ b/docs/src/examples/components/List/Types/ListExampleSelectable.tsx @@ -7,22 +7,22 @@ const ListExampleSelectable = () => ( media={} header="Irving Kuhic" headerMedia="7:26:56 AM" + index={0} content="Program the sensor to the SAS alarm through the haptic SQL card!" - selectable /> } header="Skyler Parks" headerMedia="11:30:17 PM" + index={1} content="Use the online FTP application to input the multi-byte application!" - selectable /> } header="Dante Schneider" headerMedia="5:22:40 PM" + index={2} content="The GB pixel is down, navigate the virtual interface!" - selectable /> ) diff --git a/docs/src/examples/components/List/Usage/ListExampleMemo.tsx b/docs/src/examples/components/List/Usage/ListExampleMemo.tsx new file mode 100644 index 0000000000..6cbbd12c63 --- /dev/null +++ b/docs/src/examples/components/List/Usage/ListExampleMemo.tsx @@ -0,0 +1,59 @@ +import { useLogKnob } from '@fluentui/docs-components' +import { List, ListItem } from '@fluentui/react' +import * as React from 'react' + +type MemoItemProps = { + children?: string + index: number + onRender: (index: number) => void +} + +const RenderLogger: React.FC = props => { + const { 'data-id': id, onRender, ...rest } = props + onRender(id) + + return
    +} + +const MemoItem = React.memo(props => { + const { children, index, onRender } = props + + return ( + + ) +}) + +const ListExampleSelectable = () => { + // (!) `handleRender` and `RenderLogger` are used only for logging purposes and are not required + // for an actual implementation + const handleRender = useLogKnob( + 'MemoItem:render', + undefined, + (id: string, index: number) => `${new Date().toLocaleTimeString()}: ${id}({ id: ${index} })`, + ) + + return ( + + + This is an item 0 + + + This is an item 1 + + + This is an item 2 + + + This is an item 3 + + + ) +} + +export default ListExampleSelectable diff --git a/docs/src/examples/components/List/Usage/index.tsx b/docs/src/examples/components/List/Usage/index.tsx new file mode 100644 index 0000000000..cd7447d0fe --- /dev/null +++ b/docs/src/examples/components/List/Usage/index.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' +import ComponentExample from '../../../../components/ComponentDoc/ComponentExample' +import ExampleSection from '../../../../components/ComponentDoc/ExampleSection' + +const Usage = () => ( + + + List with React.memo() + + } + description="React.memo() can be used to avoid rerenders." + examplePath="components/List/Usage/ListExampleMemo" + /> + +) + +export default Usage diff --git a/docs/src/examples/components/List/Variations/ListExampleTruncate.tsx b/docs/src/examples/components/List/Variations/ListExampleTruncate.tsx index 2fd2387f22..5046acfb47 100644 --- a/docs/src/examples/components/List/Variations/ListExampleTruncate.tsx +++ b/docs/src/examples/components/List/Variations/ListExampleTruncate.tsx @@ -17,6 +17,7 @@ const ListExample = () => { headerMedia="7:26:56 AM" content="Program the sensor to the SAS alarm through the haptic SQL card!" contentMedia="!!" + index={0} /> } @@ -24,6 +25,7 @@ const ListExample = () => { headerMedia="11:30:17 PM" content="Use the online FTP application to input the multi-byte application!" contentMedia="!!" + index={1} /> } @@ -31,6 +33,7 @@ const ListExample = () => { headerMedia="5:22:40 PM" content="The GB pixel is down, navigate the virtual interface!" contentMedia="!!" + index={2} /> diff --git a/docs/src/examples/components/List/index.tsx b/docs/src/examples/components/List/index.tsx index 47ad2ce4a6..fae919b8e2 100644 --- a/docs/src/examples/components/List/index.tsx +++ b/docs/src/examples/components/List/index.tsx @@ -5,6 +5,7 @@ import Types from './Types' import Content from './Content' import Variations from './Variations' import Performance from './Performance' +import Usage from './Usage' const ListExamples = () => ( <> @@ -13,6 +14,7 @@ const ListExamples = () => ( + ) diff --git a/packages/docs-components/src/knobs/KnobContexts.ts b/packages/docs-components/src/knobs/KnobContexts.ts index 16385cc688..0982c11e95 100644 --- a/packages/docs-components/src/knobs/KnobContexts.ts +++ b/packages/docs-components/src/knobs/KnobContexts.ts @@ -10,11 +10,11 @@ export type KnobContextValue = { unregisterKnob: (knobName: KnobName) => void } -export type LogContextValue = { +export type LogContextFunctionsValue = { appendLog: (value: string) => void clearLog: () => void - items: string[] } +export type LogContextItemsValue = string[] const noop = () => null @@ -27,8 +27,8 @@ export const KnobContext = React.createContext({ unregisterKnob: noop, }) -export const LogContext = React.createContext({ +export const LogContextFunctions = React.createContext({ appendLog: noop, clearLog: noop, - items: [], }) +export const LogContextItems = React.createContext([]) diff --git a/packages/docs-components/src/knobs/KnobProvider.tsx b/packages/docs-components/src/knobs/KnobProvider.tsx index e99d82793d..dfb22a1d44 100644 --- a/packages/docs-components/src/knobs/KnobProvider.tsx +++ b/packages/docs-components/src/knobs/KnobProvider.tsx @@ -1,7 +1,13 @@ import * as React from 'react' import defaultComponents from './defaultComponents' -import { KnobContext, KnobContextValue, LogContext, LogContextValue } from './KnobContexts' +import { + KnobContext, + KnobContextValue, + LogContextFunctions, + LogContextFunctionsValue, + LogContextItems, +} from './KnobContexts' import { KnobComponents, KnobDefinition, KnobName, KnobSet } from './types' type KnobProviderProps = { @@ -55,11 +61,16 @@ const KnobProvider: React.FunctionComponent = props => { }), [knobs, components], ) - const logValue: LogContextValue = React.useMemo(() => ({ appendLog, clearLog, items }), [items]) + const logValue: LogContextFunctionsValue = React.useMemo(() => ({ appendLog, clearLog }), [ + appendLog, + clearLog, + ]) return ( - {children} + + {children} + ) } diff --git a/packages/docs-components/src/knobs/LogInspector.tsx b/packages/docs-components/src/knobs/LogInspector.tsx index 21e2da3146..6c4fbe964b 100644 --- a/packages/docs-components/src/knobs/LogInspector.tsx +++ b/packages/docs-components/src/knobs/LogInspector.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { KnobContext, LogContext } from './KnobContexts' +import { KnobContext, LogContextFunctions, LogContextItems } from './KnobContexts' type LogInspectorProps = { /** Will be hidden if is empty. */ @@ -8,7 +8,9 @@ type LogInspectorProps = { const LogInspector: React.FunctionComponent = props => { const { components } = React.useContext(KnobContext) - const { clearLog, items } = React.useContext(LogContext) + + const { clearLog } = React.useContext(LogContextFunctions) + const items = React.useContext(LogContextItems) const visible = props.silent ? items.length > 0 : true diff --git a/packages/docs-components/src/knobs/types.ts b/packages/docs-components/src/knobs/types.ts index 31778d5a39..57b5a8608e 100644 --- a/packages/docs-components/src/knobs/types.ts +++ b/packages/docs-components/src/knobs/types.ts @@ -1,5 +1,5 @@ import * as React from 'react' -import { LogContextValue } from './KnobContexts' +import { LogContextFunctionsValue, LogContextItemsValue } from './KnobContexts' export type KnobDefinition = { content?: React.ReactNode @@ -40,7 +40,9 @@ export type KnobRangeKnobComponentProps = KnobComponentProps & { unit: string } -export type LogInspectorProps = Pick +export type LogInspectorProps = Pick & { + items: LogContextItemsValue +} export type LogFormatter = (name: string, ...args: T) => string diff --git a/packages/docs-components/src/knobs/useLogKnob.ts b/packages/docs-components/src/knobs/useLogKnob.ts index b351a01006..cfa491f84e 100644 --- a/packages/docs-components/src/knobs/useLogKnob.ts +++ b/packages/docs-components/src/knobs/useLogKnob.ts @@ -1,7 +1,7 @@ import * as React from 'react' -import { LogContext } from './KnobContexts' -import { LogFormatter } from '@fluentui/docs-components' +import { LogContextFunctions } from './KnobContexts' +import { LogFormatter } from './types' const defaultFormatter: LogFormatter = (name: string) => `${new Date().toLocaleTimeString()}: ${name}` @@ -11,17 +11,16 @@ const useLogKnob = any>( callback?: T, formatter: LogFormatter = defaultFormatter, ): T => { - const { appendLog } = React.useContext(LogContext) + const { appendLog } = React.useContext(LogContextFunctions) const proxy = React.useCallback( (...a) => { appendLog(formatter(name, ...a)) + if (typeof callback === 'function') { return (callback as any)(...a) } - if (process.env.NODE_ENV !== 'production') { - console.log(`Please provide a function to "useLogKnob(${name}, callback)"`) - } + return null }, [appendLog, callback, name, formatter], diff --git a/packages/react-bindings/src/hooks/useAccessibility.ts b/packages/react-bindings/src/hooks/useAccessibility.ts index 524b104238..dba5a1e024 100644 --- a/packages/react-bindings/src/hooks/useAccessibility.ts +++ b/packages/react-bindings/src/hooks/useAccessibility.ts @@ -31,6 +31,7 @@ const mergeProps = >( ): MergedProps => { const finalProps: MergedProps = { ...definition.attributes[slotName], + ...(slotName === 'root' && definition.focusZone && definition.focusZone.props), ...slotProps, } const slotHandlers = definition.keyHandlers[slotName] diff --git a/packages/react-bindings/src/hooks/useStateManager.ts b/packages/react-bindings/src/hooks/useStateManager.ts index 9ab42d4ea4..0b390453fd 100644 --- a/packages/react-bindings/src/hooks/useStateManager.ts +++ b/packages/react-bindings/src/hooks/useStateManager.ts @@ -36,6 +36,7 @@ const useStateManager = < mapPropsToState = () => ({} as Partial), sideEffects = [], } = options + const latestActions = React.useMemo(() => ({} as Actions), [managerFactory]) const latestManager = React.useRef | null>(null) // Heads up! forceUpdate() is used only for triggering rerenders, stateManager is SSOT @@ -58,19 +59,29 @@ const useStateManager = < ], }) + // We need to keep the same reference to an object with actions to allow usage them as + // a dependency in useCallback() hook + Object.assign(latestActions, latestManager.current.actions) + + if (process.env.NODE_ENV !== 'production') { + if (Object.isExtensible(latestActions)) { + Object.preventExtensions(latestActions) + } + } + // We need to pass exactly `manager.state` to provide the same state object during the same render // frame. // It keeps behavior consistency between React state tools and our managers // https://github.com/facebook/react/issues/11527#issuecomment-360199710 if (process.env.NODE_ENV === 'production') { - return { state: latestManager.current.state, actions: latestManager.current.actions } + return { state: latestManager.current.state, actions: latestActions } } // Object.freeze() is used only in dev-mode to avoid usage mistakes return { state: Object.freeze(latestManager.current.state), - actions: Object.freeze(latestManager.current.actions), + actions: latestActions, } } diff --git a/packages/react-bindings/test/hooks/useStateManager-test.tsx b/packages/react-bindings/test/hooks/useStateManager-test.tsx index f39e597616..bbbd57046e 100644 --- a/packages/react-bindings/test/hooks/useStateManager-test.tsx +++ b/packages/react-bindings/test/hooks/useStateManager-test.tsx @@ -1,6 +1,6 @@ import { useStateManager } from '@fluentui/react-bindings' import { createManager, ManagerFactory } from '@fluentui/state' -import { shallow } from 'enzyme' +import { mount, shallow } from 'enzyme' import * as React from 'react' import * as ReactTestUtils from 'react-dom/test-utils' @@ -63,6 +63,22 @@ const TestComponent: React.FunctionComponent = props => { ) } +type ActionsComponentProps = { + onRender: () => void + onUpdate: () => void +} + +const ActionsComponent: React.FunctionComponent = props => { + const { actions } = useStateManager(createTestManager) + + props.onRender() + React.useEffect(() => { + props.onUpdate() + }, [actions]) + + return
    actions.toggle()} /> +} + describe('useStateManager', () => { it('uses default values from state manager', () => { const wrapper = shallow() @@ -148,4 +164,17 @@ describe('useStateManager', () => { expect(onChange).toHaveBeenNthCalledWith(1, 'foo') expect(onChange).toHaveBeenNthCalledWith(2, 'foo') }) + + it('actions are referentially equal between renders', () => { + const onRender = jest.fn() + const onUpdate = jest.fn() + const wrapper = mount() + + ReactTestUtils.act(() => { + wrapper.find('div').simulate('click') + }) + + expect(onRender).toHaveBeenCalledTimes(2) + expect(onUpdate).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/react-context-selector/.gulp.js b/packages/react-context-selector/.gulp.js new file mode 100644 index 0000000000..87c6422c6b --- /dev/null +++ b/packages/react-context-selector/.gulp.js @@ -0,0 +1 @@ +module.exports = require('../../.gulp') diff --git a/packages/react-context-selector/README.md b/packages/react-context-selector/README.md new file mode 100644 index 0000000000..5f7fb98601 --- /dev/null +++ b/packages/react-context-selector/README.md @@ -0,0 +1,45 @@ +# `@fluentui/react-context-selector` + +React `useContextSelector()` and `useContextSelectors()` hooks in userland. + +## Introduction + +[React Context](https://reactjs.org/docs/context.html) and [`useContext()`](https://reactjs.org/docs/hooks-reference.html#usecontext) is often used to avoid prop drilling, +however it's known that there's a performance issue. When a context value is changed, all components that are subscribed with `useContext()` will re-render. + +[useContextSelector](https://github.com/reactjs/rfcs/pull/119) is recently proposed. While waiting for the process, this library provides the API in userland. + +# Installation + +**NPM** + +```bash +npm install --save @fluentui/react-context-selector +``` + +**Yarn** + +```bash +yarn add @fluentui/react-context-selector +``` + +## Usage + +TODO + +## Technical memo + +React context by nature triggers propagation of component re-rendering if a value is changed. To avoid this, this package uses shallow equal `value` for `Context`. It then uses a subscription model to force update when a component needs to re-render. + +## Limitations + +- In order to stop propagation, `children` of a context provider has to be either created outside of the provider or memoized with `React.memo`. +- `` components are not supported. +- The [stale props](https://react-redux.js.org/api/hooks#stale-props-and-zombie-children) issue can't be solved in userland. (workaround with try-catch) + +## Related projects + +The implementation is heavily inspired by: + +- [use-context-selector](https://github.com/dai-shi/use-context-selector) +- [react-tracked](https://github.com/dai-shi/react-tracked) diff --git a/packages/react-context-selector/babel.config.js b/packages/react-context-selector/babel.config.js new file mode 100644 index 0000000000..a1c212ad45 --- /dev/null +++ b/packages/react-context-selector/babel.config.js @@ -0,0 +1 @@ +module.exports = api => require('@fluentui/internal-tooling/babel')(api) diff --git a/packages/react-context-selector/gulpfile.ts b/packages/react-context-selector/gulpfile.ts new file mode 100644 index 0000000000..de10829664 --- /dev/null +++ b/packages/react-context-selector/gulpfile.ts @@ -0,0 +1 @@ +import '../../gulpfile' diff --git a/packages/react-context-selector/package.json b/packages/react-context-selector/package.json new file mode 100644 index 0000000000..85b1d086ec --- /dev/null +++ b/packages/react-context-selector/package.json @@ -0,0 +1,32 @@ +{ + "name": "@fluentui/react-context-selector", + "description": "TODO", + "version": "0.43.0", + "author": "Oleksandr Fediashov ", + "bugs": "https://github.com/microsoft/fluent-ui-react/issues", + "dependencies": { + "@babel/runtime": "^7.1.2" + }, + "devDependencies": { + "@fluentui/internal-tooling": "^0.43.0", + "lerna-alias": "^3.0.3-0" + }, + "files": [ + "dist" + ], + "homepage": "https://github.com/microsoft/fluent-ui-react/tree/master/packages/react-context-selector", + "jsnext:main": "dist/es/index.js", + "license": "MIT", + "main": "dist/commonjs/index.js", + "module": "dist/es/index.js", + "publishConfig": { + "access": "public" + }, + "repository": "microsoft/fluent-ui-react.git", + "scripts": { + "build": "gulp bundle:package:no-umd", + "clean": "gulp bundle:package:clean" + }, + "sideEffects": false, + "types": "dist/es/index.d.ts" +} diff --git a/packages/react-context-selector/src/createContext.ts b/packages/react-context-selector/src/createContext.ts new file mode 100644 index 0000000000..17548a40fd --- /dev/null +++ b/packages/react-context-selector/src/createContext.ts @@ -0,0 +1,57 @@ +import * as React from 'react' + +import { Context, ContextListener, ContextValue } from './types' +import { CONTEXT_SUBSCRIBE_PROPERTY, CONTEXT_VALUE_PROPERTY } from './utils' + +const createProvider = (Original: React.Provider>) => { + const Provider: React.FC> = props => { + const listeners = React.useRef[]>([]) + const value = React.useMemo>(() => ({} as any), []) + + // We call listeners in render intentionally. Listeners are not technically pure, but + // otherwise we can't get benefits from concurrent mode. + // + // We make sure to work with double or more invocation of listeners. + listeners.current.forEach(listener => listener(props.value)) + + // Disables updates propogation for React Context as `value` is always shallow equal + value[CONTEXT_SUBSCRIBE_PROPERTY] = React.useCallback((listener: ContextListener) => { + listeners.current.push(listener) + + const unsubscribe = () => { + const index = listeners.current.indexOf(listener) + listeners.current.splice(index, 1) + } + + return unsubscribe + }, []) + value[CONTEXT_VALUE_PROPERTY] = props.value + + return React.createElement(Original, { value }, props.children) + } + + if (process.env.NODE_ENV !== 'production') { + Provider.displayName = 'ContextSelector.Provider' + } + + return Provider +} + +export const createContext = (defaultValue: Value): Context => { + const context = React.createContext>({ + get [CONTEXT_SUBSCRIBE_PROPERTY](): any { + throw new Error( + process.env.NODE_ENV === 'production' + ? '' + : `Please use component from "@fluentui/react-context-selector"`, + ) + }, + [CONTEXT_VALUE_PROPERTY]: defaultValue, + }) + context.Provider = createProvider(context.Provider) as any + + // We don't support Consumer API + delete context.Consumer + + return context as any +} diff --git a/packages/react-context-selector/src/index.ts b/packages/react-context-selector/src/index.ts new file mode 100644 index 0000000000..85d7a77ebf --- /dev/null +++ b/packages/react-context-selector/src/index.ts @@ -0,0 +1,4 @@ +export { createContext } from './createContext' +export { useContextSelector } from './useContextSelector' +export { useContextSelectors } from './useContextSelectors' +export * from './types' diff --git a/packages/react-context-selector/src/types.ts b/packages/react-context-selector/src/types.ts new file mode 100644 index 0000000000..fe8c6d0eb3 --- /dev/null +++ b/packages/react-context-selector/src/types.ts @@ -0,0 +1,15 @@ +import * as React from 'react' + +export type Context = React.Context & { + Provider: React.FC> + Consumer: never +} + +export type ContextListener = (value: Value) => void + +export type ContextSelector = (value: Value) => SelectedValue + +export type ContextValue = { + s: (listener: ContextListener) => any + v: Value +} diff --git a/packages/react-context-selector/src/useContextSelector.ts b/packages/react-context-selector/src/useContextSelector.ts new file mode 100644 index 0000000000..b2a7f23118 --- /dev/null +++ b/packages/react-context-selector/src/useContextSelector.ts @@ -0,0 +1,69 @@ +import * as React from 'react' + +import { Context, ContextSelector, ContextValue } from './types' +import { + CONTEXT_SUBSCRIBE_PROPERTY, + CONTEXT_VALUE_PROPERTY, + HOOK_SELECTED_PROPERTY, + HOOK_SELECTOR_PROPERTY, + HOOK_VALUE_PROPERTY, + useIsomorphicLayoutEffect, +} from './utils' + +type UseSelectorRef = { + [HOOK_SELECTOR_PROPERTY]: ContextSelector + [HOOK_VALUE_PROPERTY]: Value + [HOOK_SELECTED_PROPERTY]: SelectedValue +} + +/** + * This hook returns context selected value by selector. + * It will only accept context created by `createContext`. + * It will trigger re-render if only the selected value is referencially changed. + */ +export const useContextSelector = ( + context: Context, + selector: ContextSelector, +): SelectedValue => { + const { + [CONTEXT_SUBSCRIBE_PROPERTY]: subscribe, + [CONTEXT_VALUE_PROPERTY]: value, + } = React.useContext((context as unknown) as Context>) + const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void] + + const ref = React.useRef>() + const selected = selector(value) + + useIsomorphicLayoutEffect(() => { + ref.current = { + [HOOK_SELECTOR_PROPERTY]: selector, + [HOOK_VALUE_PROPERTY]: value, + [HOOK_SELECTED_PROPERTY]: selected, + } + }) + useIsomorphicLayoutEffect(() => { + const callback = (nextState: Value) => { + try { + const reference: UseSelectorRef = ref.current as NonNullable< + UseSelectorRef + > + + if ( + reference[HOOK_VALUE_PROPERTY] === nextState || + Object.is(reference[HOOK_SELECTED_PROPERTY], reference[HOOK_SELECTOR_PROPERTY](nextState)) + ) { + // not changed + return + } + } catch (e) { + // ignored (stale props or some other reason) + } + + forceUpdate() + } + + return subscribe(callback) + }, [subscribe]) + + return selected +} diff --git a/packages/react-context-selector/src/useContextSelectors.ts b/packages/react-context-selector/src/useContextSelectors.ts new file mode 100644 index 0000000000..21e21010b9 --- /dev/null +++ b/packages/react-context-selector/src/useContextSelectors.ts @@ -0,0 +1,96 @@ +import * as React from 'react' + +import { Context, ContextSelector, ContextValue } from './types' +import { + HOOK_SELECTED_PROPERTY, + HOOK_SELECTOR_PROPERTY, + useIsomorphicLayoutEffect, + HOOK_VALUE_PROPERTY, + CONTEXT_SUBSCRIBE_PROPERTY, + CONTEXT_VALUE_PROPERTY, +} from './utils' + +type ContextSelectors = Record> +type ContextSelected< + Value, + SelectedValue, + Properties extends keyof ContextSelectors +> = Record[Properties]>> + +type UseSelectorRef< + Value, + SelectedValue, + Properties extends keyof ContextSelectors = any +> = { + [HOOK_SELECTOR_PROPERTY]: ContextSelectors + [HOOK_VALUE_PROPERTY]: Value + [HOOK_SELECTED_PROPERTY]: ContextSelected +} + +/** + * This hook returns context selected value by selectors. + * It will only accept context created by `createContext`. + * It will trigger re-render if only the selected value is referencially changed. + */ +export const useContextSelectors = < + Value, + SelectedValue extends Record, + Properties extends keyof ContextSelectors = any +>( + context: Context, + selectors: ContextSelectors, +): ContextSelected => { + const { + [CONTEXT_SUBSCRIBE_PROPERTY]: subscribe, + [CONTEXT_VALUE_PROPERTY]: value, + } = React.useContext((context as unknown) as Context>) + const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void] + + const ref = React.useRef>() + const selected = {} as ContextSelected + + Object.keys(selectors).forEach(key => { + selected[key] = selectors[key](value) + }) + + useIsomorphicLayoutEffect(() => { + ref.current = { + [HOOK_SELECTOR_PROPERTY]: selectors, + [HOOK_VALUE_PROPERTY]: value, + [HOOK_SELECTED_PROPERTY]: selected, + } + }) + useIsomorphicLayoutEffect(() => { + const callback = (nextState: Value) => { + try { + const reference: UseSelectorRef = ref.current as NonNullable< + UseSelectorRef + > + + if (reference[HOOK_VALUE_PROPERTY] === nextState) { + return + } + + if ( + Object.keys(reference[HOOK_SELECTED_PROPERTY]).every(key => + Object.is( + reference[HOOK_SELECTED_PROPERTY][key], + reference[HOOK_SELECTOR_PROPERTY][key](nextState), + ), + ) + ) { + // not changed + return + } + } catch (e) { + // ignored (stale props or some other reason) + } + + forceUpdate() + } + + return subscribe(callback) + }, [subscribe]) + + return selected +} diff --git a/packages/react-context-selector/src/utils.ts b/packages/react-context-selector/src/utils.ts new file mode 100644 index 0000000000..425238fe4d --- /dev/null +++ b/packages/react-context-selector/src/utils.ts @@ -0,0 +1,13 @@ +import * as React from 'react' + +export const CONTEXT_SUBSCRIBE_PROPERTY = 's' +export const CONTEXT_VALUE_PROPERTY = 'v' + +export const HOOK_SELECTOR_PROPERTY = 'r' +export const HOOK_SELECTED_PROPERTY = 'l' +export const HOOK_VALUE_PROPERTY = 's' + +// useLayoutEffect that does not show warning when server-side rendering, see Alex Reardon's article for more info +// @see https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect diff --git a/packages/react-context-selector/tsconfig.json b/packages/react-context-selector/tsconfig.json new file mode 100644 index 0000000000..6908043d43 --- /dev/null +++ b/packages/react-context-selector/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../build/tsconfig.common", + "compilerOptions": { + "composite": true, + "outDir": "dist/dts", + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "noUnusedParameters": true, + "strictNullChecks": true + }, + "include": ["src", "test"], + "references": [] +} diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index 10df6c5e41..fa5552776c 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -33,6 +33,7 @@ import { createShorthandFactory, } from '../../utils' import ListItem, { ListItemProps } from './ListItem' +import { Provider, ListContextValue } from './context' export interface ListProps extends UIComponentProps, ChildrenComponentProps { /** Accessibility behavior if overridden by the user. */ @@ -76,16 +77,6 @@ export interface ListProps extends UIComponentProps, ChildrenComponentProps { wrap?: (children: ReactChildren) => React.ReactNode } -// List props that are passed to each individual Item props -const itemProps = [ - 'debug', - 'selectable', - 'navigable', - 'truncateContent', - 'truncateHeader', - 'variables', -] - const List: React.FC> & FluentComponentStaticProps & { Item: typeof ListItem @@ -103,11 +94,13 @@ const List: React.FC> & defaultSelectedIndex, design, horizontal, - navigable, items, + navigable, selectable, selectedIndex, styles, + truncateContent, + truncateHeader, variables, wrap, } = props @@ -136,38 +129,28 @@ const List: React.FC> & const unhandledProps = getUnhandledProps(List.handledProps, props) const hasContent = childrenExist(children) || (items && items.length > 0) - - const handleItemOverrides = (predefinedProps: ListItemProps) => ({ - onClick: (e: React.SyntheticEvent, itemProps: ListItemProps) => { - _.invoke(predefinedProps, 'onClick', e, itemProps) - + const onItemClick = React.useCallback( + (e, itemIndex) => { if (selectable) { - actions.select(itemProps.index) - _.invoke(props, 'onSelectedIndexChange', e, { - ...props, - ...{ selectedIndex: itemProps.index }, - }) + actions.select(itemIndex) + _.invoke(props, 'onSelectedIndexChange', e, { ...props, selectedIndex: itemIndex }) } }, - }) + [actions], + ) + const childProps: ListContextValue = { + debug, + navigable, + onItemClick, + selectable, + selectedIndex: state.selectedIndex, + truncateContent, + truncateHeader, + variables, + } const renderItems = () => - _.map(items, (item, index) => { - const maybeSelectableItemProps = {} as any - - if (selectable) { - maybeSelectableItemProps.selected = index === state.selectedIndex - } - - return ListItem.create(item, { - defaultProps: () => ({ - ..._.pick(props, itemProps), - ...maybeSelectableItemProps, - index, - }), - overrideProps: handleItemOverrides, - }) - }) + _.map(items, (item, index) => ListItem.create(item, { defaultProps: () => ({ index }) })) const element = getA11Props.unstable_wrapWithFocusZone( > & ...unhandledProps, })} > - {hasContent && wrap(childrenExist(children) ? children : renderItems())} + + {hasContent && wrap(childrenExist(children) ? children : renderItems())} + , ) setEnd() diff --git a/packages/react/src/components/List/ListItem.tsx b/packages/react/src/components/List/ListItem.tsx index 6af5e1aed2..3d733b651d 100644 --- a/packages/react/src/components/List/ListItem.tsx +++ b/packages/react/src/components/List/ListItem.tsx @@ -6,6 +6,7 @@ import { useStyles, useTelemetry, } from '@fluentui/react-bindings' +import { useContextSelectors } from '@fluentui/react-context-selector' import cx from 'classnames' import * as _ from 'lodash' import * as PropTypes from 'prop-types' @@ -28,6 +29,7 @@ import { commonPropTypes, ContentComponentProps, } from '../../utils' +import { ListContext } from './context' export interface ListItemSlotClassNames { header: string @@ -98,13 +100,26 @@ const ListItem: React.FC & { index: number }> & headerMedia, media, styles, - debug, - navigable, - selectable, - selected, - truncateContent, - truncateHeader, - variables, + } = props + + const parentProps = useContextSelectors(ListContext, { + debug: v => v.debug, + navigable: v => v.navigable, + selectable: v => v.selectable, + truncateContent: v => v.truncateContent, + truncateHeader: v => v.truncateHeader, + variables: v => v.variables, + onItemClick: v => v.onItemClick, + selected: v => v.selectedIndex === props.index, + }) + const { + debug = parentProps.debug, + navigable = parentProps.navigable, + selectable = parentProps.selectable, + selected = parentProps.selected, + truncateContent = parentProps.truncateContent, + truncateHeader = parentProps.truncateHeader, + variables = parentProps.variables, } = props const getA11Props = useAccessibility(accessibility, { @@ -147,6 +162,7 @@ const ListItem: React.FC & { index: number }> & const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => { _.invoke(props, 'onClick', e, props) + parentProps.onItemClick(e, props.index) } const contentElement = Box.create(content, { diff --git a/packages/react/src/components/List/context.ts b/packages/react/src/components/List/context.ts new file mode 100644 index 0000000000..d69283764c --- /dev/null +++ b/packages/react/src/components/List/context.ts @@ -0,0 +1,18 @@ +import { createContext } from '@fluentui/react-context-selector' +import { ComponentVariablesInput } from '@fluentui/styles' +import * as React from 'react' + +export type ListContextValue = { + debug: boolean + selectable: boolean + navigable: boolean + truncateContent: boolean + truncateHeader: boolean + variables: ComponentVariablesInput + + onItemClick: (e: React.KeyboardEvent | React.MouseEvent, itemIndex: number) => void + selectedIndex: number +} + +export const ListContext = createContext(null) +export const Provider = ListContext.Provider diff --git a/packages/react/src/components/Menu/MenuDivider.tsx b/packages/react/src/components/Menu/MenuDivider.tsx index a296a7bb17..4e6b431fc3 100644 --- a/packages/react/src/components/Menu/MenuDivider.tsx +++ b/packages/react/src/components/Menu/MenuDivider.tsx @@ -1,10 +1,18 @@ import { Accessibility, menuDividerBehavior } from '@fluentui/accessibility' -import * as React from 'react' +import { + getElementType, + getUnhandledProps, + useAccessibility, + useStyles, +} from '@fluentui/react-bindings' import * as PropTypes from 'prop-types' +import * as React from 'react' +// @ts-ignore +import { ThemeContext } from 'react-fela' +import { ProviderContextPrepared, WithAsProp, withSafeTypeForAs } from '../../types' import { createShorthandFactory, - UIComponent, UIComponentProps, commonPropTypes, childrenExist, @@ -13,7 +21,6 @@ import { rtlTextContainer, ShorthandFactory, } from '../../utils' -import { WithAsProp, withSafeTypeForAs } from '../../types' export interface MenuDividerProps extends UIComponentProps, @@ -28,41 +35,69 @@ export interface MenuDividerProps inSubmenu?: boolean } -class MenuDivider extends UIComponent> { - static displayName = 'MenuDivider' +const MenuDivider: React.FC> & { + className: string + create: ShorthandFactory + handledProps: string[] +} = props => { + const { + accessibility, + children, + className, + content, + design, + inSubmenu, + primary, + secondary, + styles, + variables, + vertical, + } = props - static create: ShorthandFactory + const context: ProviderContextPrepared = React.useContext(ThemeContext) - static className = 'ui-menu__divider' + const getA11Props = useAccessibility(accessibility, { + debugName: MenuDivider.displayName, + rtl: context.rtl, + }) + const { classes } = useStyles(MenuDivider.displayName, { + className: MenuDivider.className, + mapPropsToStyles: () => ({ inSubmenu, primary, secondary, vertical }), + mapPropsToInlineStyles: () => ({ className, design, styles, variables }), + rtl: context.rtl, + }) - static defaultProps = { - as: 'li', - accessibility: menuDividerBehavior as Accessibility, - } + const ElementType = getElementType(props) + const unhandledProps = getUnhandledProps(MenuDivider.handledProps as any, props) - static propTypes = { - ...commonPropTypes.createCommon(), - primary: PropTypes.bool, - secondary: PropTypes.bool, - vertical: PropTypes.bool, - inSubmenu: PropTypes.bool, - } + return ( + + {childrenExist(children) ? children : content} + + ) +} - renderComponent({ ElementType, classes, unhandledProps, accessibility }) { - const { children, content } = this.props +MenuDivider.className = 'ui-menu__divider' +MenuDivider.displayName = 'MenuDivider' - return ( - - {childrenExist(children) ? children : content} - - ) - } +MenuDivider.defaultProps = { + as: 'li', + accessibility: menuDividerBehavior as Accessibility, +} +MenuDivider.propTypes = { + ...commonPropTypes.createCommon(), + primary: PropTypes.bool, + secondary: PropTypes.bool, + vertical: PropTypes.bool, + inSubmenu: PropTypes.bool, } +MenuDivider.handledProps = Object.keys(MenuDivider.propTypes) MenuDivider.create = createShorthandFactory({ Component: MenuDivider, mappedProp: 'content' }) diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 61bd5687e9..ce400f030e 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../react-component-event-listener" }, { "path": "../react-component-nesting-registry" }, { "path": "../react-component-ref" }, + { "path": "../react-context-selector" }, { "path": "../react-proptypes" } ] } diff --git a/packages/state/src/managers/menuManager.ts b/packages/state/src/managers/menuManager.ts new file mode 100644 index 0000000000..e938e5ac4a --- /dev/null +++ b/packages/state/src/managers/menuManager.ts @@ -0,0 +1,26 @@ +import createManager from '../createManager' +import { Manager, ManagerConfig } from '../types' + +export type MenuState = { + activeIndex?: number +} + +export type MenuActions = { + select: (index: number) => void +} + +export type MenuManager = Manager + +export const createMenuManager = ( + config: Partial> = {}, +): MenuManager => + createManager({ + ...config, + actions: { + select: index => () => ({ activeIndex: index }), + }, + state: { + activeIndex: -1, + ...config.state, + }, + }) From 9724fe270535842b26081843aa8eb85d242f2860 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 28 Jan 2020 18:55:12 +0100 Subject: [PATCH 02/15] revert changes --- .../react/src/components/Menu/MenuDivider.tsx | 97 ++++++------------- packages/state/src/managers/menuManager.ts | 26 ----- 2 files changed, 31 insertions(+), 92 deletions(-) delete mode 100644 packages/state/src/managers/menuManager.ts diff --git a/packages/react/src/components/Menu/MenuDivider.tsx b/packages/react/src/components/Menu/MenuDivider.tsx index 4e6b431fc3..a296a7bb17 100644 --- a/packages/react/src/components/Menu/MenuDivider.tsx +++ b/packages/react/src/components/Menu/MenuDivider.tsx @@ -1,18 +1,10 @@ import { Accessibility, menuDividerBehavior } from '@fluentui/accessibility' -import { - getElementType, - getUnhandledProps, - useAccessibility, - useStyles, -} from '@fluentui/react-bindings' -import * as PropTypes from 'prop-types' import * as React from 'react' -// @ts-ignore -import { ThemeContext } from 'react-fela' +import * as PropTypes from 'prop-types' -import { ProviderContextPrepared, WithAsProp, withSafeTypeForAs } from '../../types' import { createShorthandFactory, + UIComponent, UIComponentProps, commonPropTypes, childrenExist, @@ -21,6 +13,7 @@ import { rtlTextContainer, ShorthandFactory, } from '../../utils' +import { WithAsProp, withSafeTypeForAs } from '../../types' export interface MenuDividerProps extends UIComponentProps, @@ -35,69 +28,41 @@ export interface MenuDividerProps inSubmenu?: boolean } -const MenuDivider: React.FC> & { - className: string - create: ShorthandFactory - handledProps: string[] -} = props => { - const { - accessibility, - children, - className, - content, - design, - inSubmenu, - primary, - secondary, - styles, - variables, - vertical, - } = props +class MenuDivider extends UIComponent> { + static displayName = 'MenuDivider' - const context: ProviderContextPrepared = React.useContext(ThemeContext) + static create: ShorthandFactory - const getA11Props = useAccessibility(accessibility, { - debugName: MenuDivider.displayName, - rtl: context.rtl, - }) - const { classes } = useStyles(MenuDivider.displayName, { - className: MenuDivider.className, - mapPropsToStyles: () => ({ inSubmenu, primary, secondary, vertical }), - mapPropsToInlineStyles: () => ({ className, design, styles, variables }), - rtl: context.rtl, - }) + static className = 'ui-menu__divider' - const ElementType = getElementType(props) - const unhandledProps = getUnhandledProps(MenuDivider.handledProps as any, props) + static defaultProps = { + as: 'li', + accessibility: menuDividerBehavior as Accessibility, + } - return ( - - {childrenExist(children) ? children : content} - - ) -} + static propTypes = { + ...commonPropTypes.createCommon(), + primary: PropTypes.bool, + secondary: PropTypes.bool, + vertical: PropTypes.bool, + inSubmenu: PropTypes.bool, + } -MenuDivider.className = 'ui-menu__divider' -MenuDivider.displayName = 'MenuDivider' + renderComponent({ ElementType, classes, unhandledProps, accessibility }) { + const { children, content } = this.props -MenuDivider.defaultProps = { - as: 'li', - accessibility: menuDividerBehavior as Accessibility, -} -MenuDivider.propTypes = { - ...commonPropTypes.createCommon(), - primary: PropTypes.bool, - secondary: PropTypes.bool, - vertical: PropTypes.bool, - inSubmenu: PropTypes.bool, + return ( + + {childrenExist(children) ? children : content} + + ) + } } -MenuDivider.handledProps = Object.keys(MenuDivider.propTypes) MenuDivider.create = createShorthandFactory({ Component: MenuDivider, mappedProp: 'content' }) diff --git a/packages/state/src/managers/menuManager.ts b/packages/state/src/managers/menuManager.ts deleted file mode 100644 index e938e5ac4a..0000000000 --- a/packages/state/src/managers/menuManager.ts +++ /dev/null @@ -1,26 +0,0 @@ -import createManager from '../createManager' -import { Manager, ManagerConfig } from '../types' - -export type MenuState = { - activeIndex?: number -} - -export type MenuActions = { - select: (index: number) => void -} - -export type MenuManager = Manager - -export const createMenuManager = ( - config: Partial> = {}, -): MenuManager => - createManager({ - ...config, - actions: { - select: index => () => ({ activeIndex: index }), - }, - state: { - activeIndex: -1, - ...config.state, - }, - }) From ef90282484b3ea9b06cd054a2c7954062f17f2d8 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Jan 2020 12:19:56 +0100 Subject: [PATCH 03/15] add UTs --- .../react-context-selector/jest.config.js | 5 + packages/react-context-selector/package.json | 12 +- .../src/createContext.ts | 14 +-- packages/react-context-selector/src/types.ts | 4 +- .../src/useContextSelector.ts | 32 ++---- .../src/useContextSelectors.ts | 37 +++--- packages/react-context-selector/src/utils.ts | 7 -- .../test/createContext-test.tsx | 11 ++ .../test/useContextSelector-test.tsx | 105 ++++++++++++++++++ 9 files changed, 164 insertions(+), 63 deletions(-) create mode 100644 packages/react-context-selector/jest.config.js create mode 100644 packages/react-context-selector/test/createContext-test.tsx create mode 100644 packages/react-context-selector/test/useContextSelector-test.tsx diff --git a/packages/react-context-selector/jest.config.js b/packages/react-context-selector/jest.config.js new file mode 100644 index 0000000000..8c78ce7fdf --- /dev/null +++ b/packages/react-context-selector/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + ...require('@fluentui/internal-tooling/jest'), + name: 'react-context-selector', + moduleNameMapper: require('lerna-alias').jest(), +} diff --git a/packages/react-context-selector/package.json b/packages/react-context-selector/package.json index 85b1d086ec..514c66a46c 100644 --- a/packages/react-context-selector/package.json +++ b/packages/react-context-selector/package.json @@ -9,7 +9,13 @@ }, "devDependencies": { "@fluentui/internal-tooling": "^0.43.0", - "lerna-alias": "^3.0.3-0" + "@types/react-is": "^16.7.1", + "lerna-alias": "^3.0.3-0", + "react": "^16.8.0", + "react-is": "^16.6.3" + }, + "peerDependencies": { + "react": "^16.8.0" }, "files": [ "dist" @@ -25,7 +31,9 @@ "repository": "microsoft/fluent-ui-react.git", "scripts": { "build": "gulp bundle:package:no-umd", - "clean": "gulp bundle:package:clean" + "clean": "gulp bundle:package:clean", + "test": "gulp test", + "test:watch": "gulp test:watch" }, "sideEffects": false, "types": "dist/es/index.d.ts" diff --git a/packages/react-context-selector/src/createContext.ts b/packages/react-context-selector/src/createContext.ts index 17548a40fd..e945e3e521 100644 --- a/packages/react-context-selector/src/createContext.ts +++ b/packages/react-context-selector/src/createContext.ts @@ -1,12 +1,10 @@ import * as React from 'react' - import { Context, ContextListener, ContextValue } from './types' -import { CONTEXT_SUBSCRIBE_PROPERTY, CONTEXT_VALUE_PROPERTY } from './utils' const createProvider = (Original: React.Provider>) => { const Provider: React.FC> = props => { const listeners = React.useRef[]>([]) - const value = React.useMemo>(() => ({} as any), []) + const contextValue = React.useMemo>(() => ({} as any), []) // We call listeners in render intentionally. Listeners are not technically pure, but // otherwise we can't get benefits from concurrent mode. @@ -15,7 +13,7 @@ const createProvider = (Original: React.Provider>) => listeners.current.forEach(listener => listener(props.value)) // Disables updates propogation for React Context as `value` is always shallow equal - value[CONTEXT_SUBSCRIBE_PROPERTY] = React.useCallback((listener: ContextListener) => { + contextValue.subscribe = React.useCallback((listener: ContextListener) => { listeners.current.push(listener) const unsubscribe = () => { @@ -25,9 +23,9 @@ const createProvider = (Original: React.Provider>) => return unsubscribe }, []) - value[CONTEXT_VALUE_PROPERTY] = props.value + contextValue.value = props.value - return React.createElement(Original, { value }, props.children) + return React.createElement(Original, { value: contextValue }, props.children) } if (process.env.NODE_ENV !== 'production') { @@ -39,14 +37,14 @@ const createProvider = (Original: React.Provider>) => export const createContext = (defaultValue: Value): Context => { const context = React.createContext>({ - get [CONTEXT_SUBSCRIBE_PROPERTY](): any { + get subscribe(): any { throw new Error( process.env.NODE_ENV === 'production' ? '' : `Please use component from "@fluentui/react-context-selector"`, ) }, - [CONTEXT_VALUE_PROPERTY]: defaultValue, + value: defaultValue, }) context.Provider = createProvider(context.Provider) as any diff --git a/packages/react-context-selector/src/types.ts b/packages/react-context-selector/src/types.ts index fe8c6d0eb3..e8906ce8f8 100644 --- a/packages/react-context-selector/src/types.ts +++ b/packages/react-context-selector/src/types.ts @@ -10,6 +10,6 @@ export type ContextListener = (value: Value) => void export type ContextSelector = (value: Value) => SelectedValue export type ContextValue = { - s: (listener: ContextListener) => any - v: Value + subscribe: (listener: ContextListener) => any + value: Value } diff --git a/packages/react-context-selector/src/useContextSelector.ts b/packages/react-context-selector/src/useContextSelector.ts index b2a7f23118..d90dd3cbed 100644 --- a/packages/react-context-selector/src/useContextSelector.ts +++ b/packages/react-context-selector/src/useContextSelector.ts @@ -1,19 +1,12 @@ import * as React from 'react' import { Context, ContextSelector, ContextValue } from './types' -import { - CONTEXT_SUBSCRIBE_PROPERTY, - CONTEXT_VALUE_PROPERTY, - HOOK_SELECTED_PROPERTY, - HOOK_SELECTOR_PROPERTY, - HOOK_VALUE_PROPERTY, - useIsomorphicLayoutEffect, -} from './utils' +import { useIsomorphicLayoutEffect } from './utils' type UseSelectorRef = { - [HOOK_SELECTOR_PROPERTY]: ContextSelector - [HOOK_VALUE_PROPERTY]: Value - [HOOK_SELECTED_PROPERTY]: SelectedValue + selector: ContextSelector + selected: SelectedValue + value: Value } /** @@ -25,10 +18,9 @@ export const useContextSelector = ( context: Context, selector: ContextSelector, ): SelectedValue => { - const { - [CONTEXT_SUBSCRIBE_PROPERTY]: subscribe, - [CONTEXT_VALUE_PROPERTY]: value, - } = React.useContext((context as unknown) as Context>) + const { subscribe, value } = React.useContext( + (context as unknown) as Context>, + ) const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void] const ref = React.useRef>() @@ -36,9 +28,9 @@ export const useContextSelector = ( useIsomorphicLayoutEffect(() => { ref.current = { - [HOOK_SELECTOR_PROPERTY]: selector, - [HOOK_VALUE_PROPERTY]: value, - [HOOK_SELECTED_PROPERTY]: selected, + selector, + value, + selected, } }) useIsomorphicLayoutEffect(() => { @@ -49,8 +41,8 @@ export const useContextSelector = ( > if ( - reference[HOOK_VALUE_PROPERTY] === nextState || - Object.is(reference[HOOK_SELECTED_PROPERTY], reference[HOOK_SELECTOR_PROPERTY](nextState)) + reference.value === nextState || + Object.is(reference.selected, reference.selector(nextState)) ) { // not changed return diff --git a/packages/react-context-selector/src/useContextSelectors.ts b/packages/react-context-selector/src/useContextSelectors.ts index 21e21010b9..5e5afac48c 100644 --- a/packages/react-context-selector/src/useContextSelectors.ts +++ b/packages/react-context-selector/src/useContextSelectors.ts @@ -1,14 +1,7 @@ import * as React from 'react' import { Context, ContextSelector, ContextValue } from './types' -import { - HOOK_SELECTED_PROPERTY, - HOOK_SELECTOR_PROPERTY, - useIsomorphicLayoutEffect, - HOOK_VALUE_PROPERTY, - CONTEXT_SUBSCRIBE_PROPERTY, - CONTEXT_VALUE_PROPERTY, -} from './utils' +import { useIsomorphicLayoutEffect } from './utils' type ContextSelectors = Record> type ContextSelected< @@ -22,9 +15,9 @@ type UseSelectorRef< SelectedValue, Properties extends keyof ContextSelectors = any > = { - [HOOK_SELECTOR_PROPERTY]: ContextSelectors - [HOOK_VALUE_PROPERTY]: Value - [HOOK_SELECTED_PROPERTY]: ContextSelected + selectors: ContextSelectors + value: Value + selected: ContextSelected } /** @@ -40,10 +33,9 @@ export const useContextSelectors = < context: Context, selectors: ContextSelectors, ): ContextSelected => { - const { - [CONTEXT_SUBSCRIBE_PROPERTY]: subscribe, - [CONTEXT_VALUE_PROPERTY]: value, - } = React.useContext((context as unknown) as Context>) + const { subscribe, value } = React.useContext( + (context as unknown) as Context>, + ) const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void] const ref = React.useRef>() @@ -55,9 +47,9 @@ export const useContextSelectors = < useIsomorphicLayoutEffect(() => { ref.current = { - [HOOK_SELECTOR_PROPERTY]: selectors, - [HOOK_VALUE_PROPERTY]: value, - [HOOK_SELECTED_PROPERTY]: selected, + selectors, + value, + selected, } }) useIsomorphicLayoutEffect(() => { @@ -67,16 +59,13 @@ export const useContextSelectors = < UseSelectorRef > - if (reference[HOOK_VALUE_PROPERTY] === nextState) { + if (reference.value === nextState) { return } if ( - Object.keys(reference[HOOK_SELECTED_PROPERTY]).every(key => - Object.is( - reference[HOOK_SELECTED_PROPERTY][key], - reference[HOOK_SELECTOR_PROPERTY][key](nextState), - ), + Object.keys(reference.selected).every(key => + Object.is(reference.selected[key], reference.selectors[key](nextState)), ) ) { // not changed diff --git a/packages/react-context-selector/src/utils.ts b/packages/react-context-selector/src/utils.ts index 425238fe4d..74bbadc85e 100644 --- a/packages/react-context-selector/src/utils.ts +++ b/packages/react-context-selector/src/utils.ts @@ -1,12 +1,5 @@ import * as React from 'react' -export const CONTEXT_SUBSCRIBE_PROPERTY = 's' -export const CONTEXT_VALUE_PROPERTY = 'v' - -export const HOOK_SELECTOR_PROPERTY = 'r' -export const HOOK_SELECTED_PROPERTY = 'l' -export const HOOK_VALUE_PROPERTY = 's' - // useLayoutEffect that does not show warning when server-side rendering, see Alex Reardon's article for more info // @see https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a export const useIsomorphicLayoutEffect = diff --git a/packages/react-context-selector/test/createContext-test.tsx b/packages/react-context-selector/test/createContext-test.tsx new file mode 100644 index 0000000000..510172f64d --- /dev/null +++ b/packages/react-context-selector/test/createContext-test.tsx @@ -0,0 +1,11 @@ +import { createContext } from '@fluentui/react-context-selector' +import * as ReactIs from 'react-is' + +describe('createContext', () => { + describe('Provider', () => { + it('creates a Provider component', () => { + const Context = createContext(null) + expect(ReactIs.isValidElementType(Context.Provider)).toBeTruthy() + }) + }) +}) diff --git a/packages/react-context-selector/test/useContextSelector-test.tsx b/packages/react-context-selector/test/useContextSelector-test.tsx new file mode 100644 index 0000000000..82a9817874 --- /dev/null +++ b/packages/react-context-selector/test/useContextSelector-test.tsx @@ -0,0 +1,105 @@ +import { createContext, useContextSelector } from '@fluentui/react-context-selector' +import { mount } from 'enzyme' +import * as React from 'react' + +class TestBoundary extends React.Component<{ onError: (e: Error) => void }, { hasError: boolean }> { + state = { hasError: false } + + componentDidCatch(error: Error) { + this.props.onError(error) + this.setState({ hasError: true }) + } + + render() { + if (this.state.hasError) { + return null + } + + return this.props.children + } +} + +const TestContext = createContext<{ index: number }>({ index: -1 }) + +const TestComponent: React.FC<{ index: number; onUpdate?: () => void }> = props => { + const active = useContextSelector(TestContext, v => v.index === props.index) + + React.useEffect(() => { + props.onUpdate && props.onUpdate() + }) + + return
    +} + +describe('useContextSelector', () => { + it('propogates values via Context', () => { + const wrapper = mount( + + + , + ) + + expect(wrapper.find('div').prop('data-active')).toBe(true) + }) + + it('updates only on selector match', () => { + const onUpdate = jest.fn() + const wrapper = mount( + + + , + ) + + expect(wrapper.find('div').prop('data-active')).toBe(false) + expect(onUpdate).toBeCalledTimes(1) + + // No match, (v.index: 2, p.index: 1) + wrapper.setProps({ value: { index: 2 } }) + expect(wrapper.find('div').prop('data-active')).toBe(false) + expect(onUpdate).toBeCalledTimes(1) + + // Match => update, (v.index: 1, p.index: 1) + wrapper.setProps({ value: { index: 1 } }) + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(onUpdate).toBeCalledTimes(2) + + // Match previous => no update, (v.index: 1, p.index: 1) + wrapper.setProps({ value: { index: 1 } }) + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(onUpdate).toBeCalledTimes(2) + }) + + it('updates only with in React.memo()', () => { + // Will never pass updates + // https://reactjs.org/docs/react-api.html#reactmemo + const MemoComponent = React.memo(TestComponent, () => false) + + const onUpdate = jest.fn() + const wrapper = mount( + + + , + ) + + wrapper.setProps({ value: { index: 1 } }) + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(onUpdate).toBeCalledTimes(2) + }) + + it('throws on usage outside Provider', () => { + jest.spyOn(global.console, 'error').mockImplementation(() => {}) + + const onError = jest.fn() + mount( + + + , + ) + + expect(onError).toBeCalledWith( + expect.objectContaining({ + message: 'Please use component from "@fluentui/react-context-selector"', + }), + ) + }) +}) From 5289db770249e842076274a79494e02fc3f0934b Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 29 Jan 2020 14:49:39 +0100 Subject: [PATCH 04/15] fix UTs, create default --- .../src/createContext.ts | 27 ++++++++--- packages/react/src/components/List/context.ts | 15 +++++- .../test/specs/components/List/List-test.tsx | 48 +++++++++++++------ 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/packages/react-context-selector/src/createContext.ts b/packages/react-context-selector/src/createContext.ts index e945e3e521..54c190abfc 100644 --- a/packages/react-context-selector/src/createContext.ts +++ b/packages/react-context-selector/src/createContext.ts @@ -35,14 +35,27 @@ const createProvider = (Original: React.Provider>) => return Provider } -export const createContext = (defaultValue: Value): Context => { +type CreateContextOptions = { + strict?: boolean +} + +export const createContext = ( + defaultValue: Value, + options: CreateContextOptions = {}, +): Context => { + const { strict = true } = options + const context = React.createContext>({ - get subscribe(): any { - throw new Error( - process.env.NODE_ENV === 'production' - ? '' - : `Please use component from "@fluentui/react-context-selector"`, - ) + get subscribe() { + if (strict) { + throw new Error( + process.env.NODE_ENV === 'production' + ? '' + : `Please use component from "@fluentui/react-context-selector"`, + ) + } + + return () => () => {} }, value: defaultValue, }) diff --git a/packages/react/src/components/List/context.ts b/packages/react/src/components/List/context.ts index d69283764c..eda63ab9d6 100644 --- a/packages/react/src/components/List/context.ts +++ b/packages/react/src/components/List/context.ts @@ -14,5 +14,18 @@ export type ListContextValue = { selectedIndex: number } -export const ListContext = createContext(null) +export const ListContext = createContext( + { + debug: false, + selectable: false, + navigable: false, + truncateContent: false, + truncateHeader: false, + variables: {}, + + onItemClick: () => {}, + selectedIndex: -1, + }, + { strict: false }, +) export const Provider = ListContext.Provider diff --git a/packages/react/test/specs/components/List/List-test.tsx b/packages/react/test/specs/components/List/List-test.tsx index 98f00b23cd..f00dbe370b 100644 --- a/packages/react/test/specs/components/List/List-test.tsx +++ b/packages/react/test/specs/components/List/List-test.tsx @@ -45,34 +45,54 @@ describe('List', () => { describe('selectedIndex', () => { it('should not be set by default', () => { - const listItems = mountWithProvider().find('ListItem') - expect(listItems.everyWhere(item => !item.props().selected)).toBe(true) + const wrapper = mountWithProvider() + + expect( + wrapper.find('li').filterWhere(item => Boolean(item.prop('aria-selected'))), + ).toHaveLength(0) }) it('can be set a default value', () => { - const listItems = mountWithProvider( + const wrapper = mountWithProvider( , - ).find('ListItem') - expect(listItems.first().props().selected).toBe(true) + ) + expect( + wrapper + .find('li') + .at(0) + .prop('aria-selected'), + ).toBe(true) }) it('should be set when item is clicked', () => { const wrapper = mountWithProvider( , ) - const listItems = wrapper.find('ListItem') - expect(listItems.at(0).props().selected).toBe(true) - listItems - .at(1) + expect( + wrapper + .find('li') + .at(0) + .prop('aria-selected'), + ).toBe(true) + + wrapper .find('li') - .first() + .at(1) .simulate('click') - const updatedItems = wrapper.find('ListItem') - - expect(updatedItems.at(0).props().selected).toBe(false) - expect(updatedItems.at(1).props().selected).toBe(true) + expect( + wrapper + .find('li') + .at(0) + .prop('aria-selected'), + ).toBe(false) + expect( + wrapper + .find('li') + .at(1) + .prop('aria-selected'), + ).toBe(true) }) it('calls onClick handler for item if `selectable`', () => { From 64395fc732d459b62a68d4f6d46fbad90d0c740a Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Thu, 30 Jan 2020 12:10:17 +0100 Subject: [PATCH 05/15] improve UTs, types --- .../src/createContext.ts | 3 + .../src/useContextSelectors.ts | 47 +++++----- packages/react-context-selector/src/utils.ts | 2 +- .../test/createContext-test.tsx | 69 +++++++++++++-- .../test/useContextSelector-test.tsx | 47 ++++------ .../test/useContextSelectors-test.tsx | 87 +++++++++++++++++++ 6 files changed, 191 insertions(+), 64 deletions(-) create mode 100644 packages/react-context-selector/test/useContextSelectors-test.tsx diff --git a/packages/react-context-selector/src/createContext.ts b/packages/react-context-selector/src/createContext.ts index 54c190abfc..60d2dba47a 100644 --- a/packages/react-context-selector/src/createContext.ts +++ b/packages/react-context-selector/src/createContext.ts @@ -28,6 +28,7 @@ const createProvider = (Original: React.Provider>) => return React.createElement(Original, { value: contextValue }, props.children) } + /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { Provider.displayName = 'ContextSelector.Provider' } @@ -48,6 +49,7 @@ export const createContext = ( const context = React.createContext>({ get subscribe() { if (strict) { + /* istanbul ignore next */ throw new Error( process.env.NODE_ENV === 'production' ? '' @@ -55,6 +57,7 @@ export const createContext = ( ) } + /* istanbul ignore next */ return () => () => {} }, value: defaultValue, diff --git a/packages/react-context-selector/src/useContextSelectors.ts b/packages/react-context-selector/src/useContextSelectors.ts index 5e5afac48c..bc9afafb00 100644 --- a/packages/react-context-selector/src/useContextSelectors.ts +++ b/packages/react-context-selector/src/useContextSelectors.ts @@ -3,21 +3,15 @@ import * as React from 'react' import { Context, ContextSelector, ContextValue } from './types' import { useIsomorphicLayoutEffect } from './utils' -type ContextSelectors = Record> -type ContextSelected< - Value, - SelectedValue, - Properties extends keyof ContextSelectors -> = Record[Properties]>> - type UseSelectorRef< Value, - SelectedValue, - Properties extends keyof ContextSelectors = any + Properties extends string, + Selectors extends Record>, + SelectedValue extends any > = { - selectors: ContextSelectors + selectors: Selectors value: Value - selected: ContextSelected + selected: Record } /** @@ -27,21 +21,22 @@ type UseSelectorRef< */ export const useContextSelectors = < Value, - SelectedValue extends Record, - Properties extends keyof ContextSelectors = any + Properties extends string, + Selectors extends Record>, + SelectedValue extends any >( context: Context, - selectors: ContextSelectors, -): ContextSelected => { + selectors: Selectors, +): Record => { const { subscribe, value } = React.useContext( (context as unknown) as Context>, ) const [, forceUpdate] = React.useReducer((c: number) => c + 1, 0) as [never, () => void] - const ref = React.useRef>() - const selected = {} as ContextSelected + const ref = React.useRef>() + const selected = {} as Record - Object.keys(selectors).forEach(key => { + Object.keys(selectors).forEach((key: Properties) => { selected[key] = selectors[key](value) }) @@ -55,16 +50,16 @@ export const useContextSelectors = < useIsomorphicLayoutEffect(() => { const callback = (nextState: Value) => { try { - const reference: UseSelectorRef = ref.current as NonNullable< - UseSelectorRef - > - - if (reference.value === nextState) { - return - } + const reference: UseSelectorRef< + Value, + Properties, + Selectors, + SelectedValue + > = ref.current as NonNullable> if ( - Object.keys(reference.selected).every(key => + reference.value === nextState || + Object.keys(reference.selected).every((key: Properties) => Object.is(reference.selected[key], reference.selectors[key](nextState)), ) ) { diff --git a/packages/react-context-selector/src/utils.ts b/packages/react-context-selector/src/utils.ts index 74bbadc85e..7afd4eabda 100644 --- a/packages/react-context-selector/src/utils.ts +++ b/packages/react-context-selector/src/utils.ts @@ -3,4 +3,4 @@ import * as React from 'react' // useLayoutEffect that does not show warning when server-side rendering, see Alex Reardon's article for more info // @see https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a export const useIsomorphicLayoutEffect = - typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect + typeof window !== 'undefined' ? React.useLayoutEffect : /* istanbul ignore next */ React.useEffect diff --git a/packages/react-context-selector/test/createContext-test.tsx b/packages/react-context-selector/test/createContext-test.tsx index 510172f64d..be3a22ef94 100644 --- a/packages/react-context-selector/test/createContext-test.tsx +++ b/packages/react-context-selector/test/createContext-test.tsx @@ -1,11 +1,70 @@ -import { createContext } from '@fluentui/react-context-selector' +import { createContext, useContextSelector } from '@fluentui/react-context-selector' +import { mount } from 'enzyme' +import * as React from 'react' import * as ReactIs from 'react-is' +class TestBoundary extends React.Component<{ onError: (e: Error) => void }, { hasError: boolean }> { + state = { hasError: false } + + componentDidCatch(error: Error) { + this.props.onError(error) + this.setState({ hasError: true }) + } + + render() { + if (this.state.hasError) { + return null + } + + return this.props.children + } +} + describe('createContext', () => { - describe('Provider', () => { - it('creates a Provider component', () => { - const Context = createContext(null) - expect(ReactIs.isValidElementType(Context.Provider)).toBeTruthy() + it('creates a Provider component', () => { + const Context = createContext(null) + expect(ReactIs.isValidElementType(Context.Provider)).toBeTruthy() + }) + + describe('options', () => { + it('throws on usage outside Provider by default', () => { + jest.spyOn(global.console, 'error').mockImplementation(() => {}) + + const TestContext = createContext('') + const TestComponent: React.FC = () => { + const value = useContextSelector(TestContext, v => v) + return
    + } + + const onError = jest.fn() + mount( + + + , + ) + + expect(onError).toBeCalledWith( + expect.objectContaining({ + message: 'Please use component from "@fluentui/react-context-selector"', + }), + ) + }) + + it('do not throw usage outside Provider when `strict` is `false`', () => { + const TestContext = createContext('', { strict: false }) + const TestComponent: React.FC = () => { + const value = useContextSelector(TestContext, v => v) + return
    + } + + const onError = jest.fn() + mount( + + + , + ) + + expect(onError).not.toBeCalled() }) }) }) diff --git a/packages/react-context-selector/test/useContextSelector-test.tsx b/packages/react-context-selector/test/useContextSelector-test.tsx index 82a9817874..4f303fc9a4 100644 --- a/packages/react-context-selector/test/useContextSelector-test.tsx +++ b/packages/react-context-selector/test/useContextSelector-test.tsx @@ -2,23 +2,6 @@ import { createContext, useContextSelector } from '@fluentui/react-context-selec import { mount } from 'enzyme' import * as React from 'react' -class TestBoundary extends React.Component<{ onError: (e: Error) => void }, { hasError: boolean }> { - state = { hasError: false } - - componentDidCatch(error: Error) { - this.props.onError(error) - this.setState({ hasError: true }) - } - - render() { - if (this.state.hasError) { - return null - } - - return this.props.children - } -} - const TestContext = createContext<{ index: number }>({ index: -1 }) const TestComponent: React.FC<{ index: number; onUpdate?: () => void }> = props => { @@ -69,10 +52,10 @@ describe('useContextSelector', () => { expect(onUpdate).toBeCalledTimes(2) }) - it('updates only with in React.memo()', () => { - // Will never pass updates + it('updates are propogated inside React.memo()', () => { // https://reactjs.org/docs/react-api.html#reactmemo - const MemoComponent = React.memo(TestComponent, () => false) + // Will never pass updates + const MemoComponent = React.memo(TestComponent, () => true) const onUpdate = jest.fn() const wrapper = mount( @@ -86,20 +69,20 @@ describe('useContextSelector', () => { expect(onUpdate).toBeCalledTimes(2) }) - it('throws on usage outside Provider', () => { - jest.spyOn(global.console, 'error').mockImplementation(() => {}) + it('handles unsubscribe', () => { + const MemoComponent = React.memo(TestComponent) + const onUpdate = jest.fn() - const onError = jest.fn() - mount( - - - , + const wrapper = mount( + + + + , ) - expect(onError).toBeCalledWith( - expect.objectContaining({ - message: 'Please use component from "@fluentui/react-context-selector"', - }), - ) + wrapper.setProps({ + children: [null, ], + }) + expect(onUpdate).toBeCalledTimes(1) }) }) diff --git a/packages/react-context-selector/test/useContextSelectors-test.tsx b/packages/react-context-selector/test/useContextSelectors-test.tsx new file mode 100644 index 0000000000..0cfd991d59 --- /dev/null +++ b/packages/react-context-selector/test/useContextSelectors-test.tsx @@ -0,0 +1,87 @@ +import { createContext, useContextSelectors } from '@fluentui/react-context-selector' +import { mount } from 'enzyme' +import * as React from 'react' + +const TestContext = createContext<{ index: number; value: string }>({ + index: -1, + value: '', +}) + +const TestComponent: React.FC<{ index: number; onUpdate?: () => void }> = props => { + const context = useContextSelectors(TestContext, { + active: v => v.index === props.index, + value: v => v.value, + }) + + React.useEffect(() => { + props.onUpdate && props.onUpdate() + }) + + return
    +} + +describe('useContextSelectors', () => { + it('propogates values via Context', () => { + const wrapper = mount( + + + , + ) + + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(wrapper.find('div').prop('data-value')).toBe('foo') + }) + + it('updates only on selector match', () => { + const onUpdate = jest.fn() + const wrapper = mount( + + + , + ) + + expect(wrapper.find('div').prop('data-active')).toBe(false) + expect(wrapper.find('div').prop('data-value')).toBe('foo') + expect(onUpdate).toBeCalledTimes(1) + + // No match, (v.index: 2, p.index: 1) + wrapper.setProps({ value: { index: 2, value: 'foo' } }) + expect(wrapper.find('div').prop('data-active')).toBe(false) + expect(wrapper.find('div').prop('data-value')).toBe('foo') + expect(onUpdate).toBeCalledTimes(1) + + // Match => update, (v.index: 1, p.index: 1) + wrapper.setProps({ value: { index: 1, value: 'foo' } }) + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(wrapper.find('div').prop('data-value')).toBe('foo') + expect(onUpdate).toBeCalledTimes(2) + + // Match previous => no update, (v.index: 1, p.index: 1) + wrapper.setProps({ value: { index: 1, value: 'foo' } }) + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(wrapper.find('div').prop('data-value')).toBe('foo') + expect(onUpdate).toBeCalledTimes(2) + + // Match => update, (v.value: 'bar') + wrapper.setProps({ value: { index: 1, value: 'bar' } }) + expect(wrapper.find('div').prop('data-value')).toBe('bar') + expect(onUpdate).toBeCalledTimes(3) + }) + + it('updates are propogated inside React.memo()', () => { + // https://reactjs.org/docs/react-api.html#reactmemo + // Will never pass updates + const MemoComponent = React.memo(TestComponent, () => true) + + const onUpdate = jest.fn() + const wrapper = mount( + + + , + ) + + wrapper.setProps({ value: { index: 1, value: 'foo' } }) + expect(wrapper.find('div').prop('data-active')).toBe(true) + expect(onUpdate).toBeCalledTimes(2) + }) +}) From 326b63df0c66f6eec073eda28d9500a4f3cf1e11 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 12 Feb 2020 14:30:53 +0100 Subject: [PATCH 06/15] apply review comments --- packages/react/src/components/List/List.tsx | 6 +++--- packages/react/src/components/List/ListItem.tsx | 4 ++-- .../components/List/{context.ts => listContext.ts} | 14 +++++++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) rename packages/react/src/components/List/{context.ts => listContext.ts} (72%) diff --git a/packages/react/src/components/List/List.tsx b/packages/react/src/components/List/List.tsx index fa5552776c..fa86d68fda 100644 --- a/packages/react/src/components/List/List.tsx +++ b/packages/react/src/components/List/List.tsx @@ -32,8 +32,8 @@ import { rtlTextContainer, createShorthandFactory, } from '../../utils' +import { ListContextProvider, ListContextValue } from './listContext' import ListItem, { ListItemProps } from './ListItem' -import { Provider, ListContextValue } from './context' export interface ListProps extends UIComponentProps, ChildrenComponentProps { /** Accessibility behavior if overridden by the user. */ @@ -160,9 +160,9 @@ const List: React.FC> & ...unhandledProps, })} > - + {hasContent && wrap(childrenExist(children) ? children : renderItems())} - + , ) setEnd() diff --git a/packages/react/src/components/List/ListItem.tsx b/packages/react/src/components/List/ListItem.tsx index 3d733b651d..496103f29b 100644 --- a/packages/react/src/components/List/ListItem.tsx +++ b/packages/react/src/components/List/ListItem.tsx @@ -29,7 +29,7 @@ import { commonPropTypes, ContentComponentProps, } from '../../utils' -import { ListContext } from './context' +import { ListContext, ListContextSubscribedValue } from './listContext' export interface ListItemSlotClassNames { header: string @@ -102,7 +102,7 @@ const ListItem: React.FC & { index: number }> & styles, } = props - const parentProps = useContextSelectors(ListContext, { + const parentProps: ListContextSubscribedValue = useContextSelectors(ListContext, { debug: v => v.debug, navigable: v => v.navigable, selectable: v => v.selectable, diff --git a/packages/react/src/components/List/context.ts b/packages/react/src/components/List/listContext.ts similarity index 72% rename from packages/react/src/components/List/context.ts rename to packages/react/src/components/List/listContext.ts index eda63ab9d6..f1e3ead194 100644 --- a/packages/react/src/components/List/context.ts +++ b/packages/react/src/components/List/listContext.ts @@ -14,6 +14,17 @@ export type ListContextValue = { selectedIndex: number } +export type ListContextSubscribedValue = Pick< + ListContextValue, + | 'debug' + | 'selectable' + | 'navigable' + | 'truncateContent' + | 'truncateHeader' + | 'variables' + | 'onItemClick' +> & { selected: boolean } + export const ListContext = createContext( { debug: false, @@ -28,4 +39,5 @@ export const ListContext = createContext( }, { strict: false }, ) -export const Provider = ListContext.Provider + +export const ListContextProvider = ListContext.Provider From c171cfa34b86ce53de7212fe90cc2dbe3e3439d4 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 12 Feb 2020 14:49:25 +0100 Subject: [PATCH 07/15] prettify fixes --- packages/react-bindings/src/hooks/useAccessibility.ts | 1 - packages/react-bindings/src/hooks/useStateManager.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-bindings/src/hooks/useAccessibility.ts b/packages/react-bindings/src/hooks/useAccessibility.ts index dba5a1e024..524b104238 100644 --- a/packages/react-bindings/src/hooks/useAccessibility.ts +++ b/packages/react-bindings/src/hooks/useAccessibility.ts @@ -31,7 +31,6 @@ const mergeProps = >( ): MergedProps => { const finalProps: MergedProps = { ...definition.attributes[slotName], - ...(slotName === 'root' && definition.focusZone && definition.focusZone.props), ...slotProps, } const slotHandlers = definition.keyHandlers[slotName] diff --git a/packages/react-bindings/src/hooks/useStateManager.ts b/packages/react-bindings/src/hooks/useStateManager.ts index 0b390453fd..02e1e1e11f 100644 --- a/packages/react-bindings/src/hooks/useStateManager.ts +++ b/packages/react-bindings/src/hooks/useStateManager.ts @@ -63,10 +63,10 @@ const useStateManager = < // a dependency in useCallback() hook Object.assign(latestActions, latestManager.current.actions) + // For development environments we disallow ability to extend object with other properties to + // avoid misusage if (process.env.NODE_ENV !== 'production') { - if (Object.isExtensible(latestActions)) { - Object.preventExtensions(latestActions) - } + if (Object.isExtensible(latestActions)) Object.preventExtensions(latestActions) } // We need to pass exactly `manager.state` to provide the same state object during the same render From 45bf1ec1606b2c24e4ad3d2433815dca25e4f30b Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Wed, 12 Feb 2020 15:30:07 +0100 Subject: [PATCH 08/15] feat(hooks): add useAutoControlled() hook --- .../src/hooks/useAutoControlled.ts | 27 ++++++ packages/react-bindings/src/index.ts | 1 + .../test/hooks/useAutoControlled-test.tsx | 93 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 packages/react-bindings/src/hooks/useAutoControlled.ts create mode 100644 packages/react-bindings/test/hooks/useAutoControlled-test.tsx diff --git a/packages/react-bindings/src/hooks/useAutoControlled.ts b/packages/react-bindings/src/hooks/useAutoControlled.ts new file mode 100644 index 0000000000..eb59d5207a --- /dev/null +++ b/packages/react-bindings/src/hooks/useAutoControlled.ts @@ -0,0 +1,27 @@ +import * as React from 'react' + +type UseAutoControlledOptions = { + defaultValue: Value + value: Value + + initialValue?: Value +} + +const isUndefined = (value: any) => typeof value === 'undefined' + +/** + * Returns a stateful value, and a function to update it. Mimics the `useState()` React Hook + * signature. + */ +const useAutoControlled = ( + options: UseAutoControlledOptions, +): [Value, React.Dispatch>] => { + const { defaultValue, initialValue = undefined, value } = options + const [state, setState] = React.useState( + isUndefined(defaultValue) ? (initialValue as Value) : defaultValue, + ) + + return [isUndefined(value) ? state : value, setState] +} + +export default useAutoControlled diff --git a/packages/react-bindings/src/index.ts b/packages/react-bindings/src/index.ts index 338af3dc11..502312b074 100644 --- a/packages/react-bindings/src/index.ts +++ b/packages/react-bindings/src/index.ts @@ -10,6 +10,7 @@ export * from './FocusZone/FocusZone.types' export * from './FocusZone/focusUtilities' export { default as useAccessibility } from './hooks/useAccessibility' +export { default as useAutoControlled } from './hooks/useAutoControlled' export { default as useStyles } from './hooks/useStyles' export { default as unstable_useDispatchEffect } from './hooks/useDispatchEffect' export { default as useIsomorphicLayoutEffect } from './hooks/useIsomorphicLayoutEffect' diff --git a/packages/react-bindings/test/hooks/useAutoControlled-test.tsx b/packages/react-bindings/test/hooks/useAutoControlled-test.tsx new file mode 100644 index 0000000000..b046317cb3 --- /dev/null +++ b/packages/react-bindings/test/hooks/useAutoControlled-test.tsx @@ -0,0 +1,93 @@ +import { useAutoControlled } from '@fluentui/react-bindings' +import { shallow } from 'enzyme' +import * as React from 'react' +import * as ReactTestUtils from 'react-dom/test-utils' + +type TestComponentProps = { + defaultValue?: string + value?: string + + initialValue?: string + onChange?: (value: string) => void +} + +const TestComponent: React.FunctionComponent = props => { + const [value, setValue] = useAutoControlled({ + defaultValue: props.defaultValue, + value: props.value, + + initialValue: props.initialValue, + }) + + return ( + <> + { + setValue(e.target.value) + if (props.onChange) props.onChange(e.target.value) + }} + value={value || ''} + /> +