From d6aa359c89f5b36e6055682f3b594a1573bc730b Mon Sep 17 00:00:00 2001 From: Lyse Date: Tue, 17 Jun 2025 15:05:45 +0400 Subject: [PATCH 1/2] [#3504] Add version labels for default and beta versions - Marked 1.11.7 as '(Default)' and 2.0.3 as '(Beta)' - Updated version matching logic to handle object format - Maintained backward compatibility with string versions --- .../modules/IDE/components/VersionPicker.jsx | 35 +++++++++++++++---- client/modules/IDE/hooks/useP5Version.jsx | 9 +++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/client/modules/IDE/components/VersionPicker.jsx b/client/modules/IDE/components/VersionPicker.jsx index a42743d353..f048769dcf 100644 --- a/client/modules/IDE/components/VersionPicker.jsx +++ b/client/modules/IDE/components/VersionPicker.jsx @@ -75,9 +75,23 @@ const VersionPicker = React.forwardRef(({ onChangeVersion }, ref) => { return ( - {versionInfo.version} + + {versionInfo + ? (() => { + const current = p5Versions.find((v) => + typeof v === 'string' + ? v === versionInfo.version + : v.version === versionInfo.version + ); + if (!current) return versionInfo.version; + if (typeof current === 'string') return current; + return `${current.version} ${current.label}`; + })() + : t('Toolbar.CustomLibraryVersion')} + @@ -86,11 +100,20 @@ const VersionPicker = React.forwardRef(({ onChangeVersion }, ref) => { align="left" maxHeight="50vh" > - {p5Versions.map((version) => ( - dispatchReplaceVersion(version)}> - {version} - - ))} + {p5Versions.map((item) => { + const version = typeof item === 'string' ? item : item.version; + const label = + typeof item === 'string' ? item : `${item.version} ${item.label}`; + + return ( + dispatchReplaceVersion(version)} + > + {label} + + ); + })} ); }); diff --git a/client/modules/IDE/hooks/useP5Version.jsx b/client/modules/IDE/hooks/useP5Version.jsx index 03ba3d0da4..05dc96fe4b 100644 --- a/client/modules/IDE/hooks/useP5Version.jsx +++ b/client/modules/IDE/hooks/useP5Version.jsx @@ -8,12 +8,12 @@ import PropTypes from 'prop-types'; // JSON.stringify([...document.querySelectorAll('._132722c7')].map(n => n.innerText), null, 2) // TODO: use their API for this to grab these at build time? export const p5Versions = [ - '2.0.3', + { version: '2.0.3', label: '(Beta)' }, '2.0.2', '2.0.1', '2.0.0', '1.11.8', - '1.11.7', + { version: '1.11.7', label: '(Default)' }, '1.11.6', '1.11.5', '1.11.4', @@ -197,7 +197,10 @@ export function P5VersionProvider(props) { if (!match) return null; // See if this is a version we recognize - if (p5Versions.includes(match[1])) { + const versionExists = p5Versions.some((v) => + typeof v === 'string' ? v === match[1] : v.version === match[1] + ); + if (versionExists) { return { version: match[1], minified: !!match[2], scriptNode }; } return null; From 2b2044920658d36ef65fe59979cb043fe643a742 Mon Sep 17 00:00:00 2001 From: raclim <43053081+raclim@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:32:15 -0500 Subject: [PATCH 2/2] merge 'develop' branch --- .babelrc | 3 +- .eslintrc | 61 +- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/test.yml | 19 +- .gitignore | 4 +- .nvmrc | 2 +- .prettierrc | 1 - .storybook/main.js | 16 +- .storybook/preview.js | 6 +- .travis.yml | 2 +- Dockerfile | 5 +- README.md | 26 +- client/common/Button.stories.jsx | 10 +- client/common/Button.test.tsx | 91 + client/common/{Button.jsx => Button.tsx} | 211 +- ...nOrLink.test.jsx => ButtonOrLink.test.tsx} | 2 +- .../{ButtonOrLink.jsx => ButtonOrLink.tsx} | 77 +- client/common/IconButton.jsx | 38 - client/common/IconButton.test.tsx | 27 + client/common/IconButton.tsx | 30 + client/common/RouterTab.test.tsx | 33 + .../common/{RouterTab.jsx => RouterTab.tsx} | 17 +- client/common/{icons.jsx => icons.tsx} | 49 +- client/common/useKeyDownHandlers.test.tsx | 82 + ...yDownHandlers.js => useKeyDownHandlers.ts} | 33 +- client/common/useModalClose.js | 45 - client/common/useModalClose.test.tsx | 65 + client/common/useModalClose.ts | 65 + client/common/usePrevious.js | 11 - client/common/usePrevious.test.tsx | 37 + client/common/usePrevious.ts | 16 + client/common/useSyncFormTranslations.js | 22 - .../common/useSyncFormTranslations.test.tsx | 46 + client/common/useSyncFormTranslations.ts | 31 + client/components/Dropdown.jsx | 110 - client/components/Dropdown/DropdownMenu.jsx | 108 - client/components/Dropdown/DropdownMenu.tsx | 168 + client/components/Dropdown/MenuItem.jsx | 35 - client/components/Dropdown/MenuItem.tsx | 24 + .../{TableDropdown.jsx => TableDropdown.tsx} | 14 +- .../{Menubar.test.jsx => Menubar.test.tsx} | 6 +- .../Menubar/{Menubar.jsx => Menubar.tsx} | 64 +- .../{MenubarItem.jsx => MenubarItem.tsx} | 50 +- ...{MenubarSubmenu.jsx => MenubarSubmenu.tsx} | 344 +- client/components/Menubar/contexts.jsx | 13 - client/components/Menubar/contexts.tsx | 63 + client/components/PreviewNav.test.tsx | 54 + .../{PreviewNav.jsx => PreviewNav.tsx} | 43 +- .../components/{RootPage.jsx => RootPage.tsx} | 15 +- client/components/SkipLink.test.tsx | 32 + .../components/{SkipLink.jsx => SkipLink.tsx} | 15 +- client/components/useAsModal.jsx | 29 - client/constants.js | 2 + client/custom.d.ts | 21 + client/i18n.js | 12 +- client/images/checkmark.svg | 13 + client/images/cross.svg | 2 +- client/images/earth.svg | 13 + client/images/lock.svg | 3 + client/index.integration.test.jsx | 4 +- client/index.jsx | 137 +- .../{About.styles.js => About.styles.ts} | 5 + .../About/pages/{About.jsx => About.tsx} | 68 +- .../statics/{aboutData.js => aboutData.ts} | 13 +- client/modules/App/components/Overlay.jsx | 2 +- client/modules/IDE/actions/assets.js | 2 +- client/modules/IDE/actions/collections.js | 2 +- client/modules/IDE/actions/files.js | 2 +- .../{preferences.js => preferences.ts} | 93 +- .../modules/IDE/actions/preferences.types.ts | 116 + client/modules/IDE/actions/project.js | 70 +- client/modules/IDE/actions/projects.js | 2 +- client/modules/IDE/actions/uploader.js | 20 +- client/modules/IDE/components/AssetList.jsx | 2 +- .../modules/IDE/components/AssetListRow.jsx | 4 +- client/modules/IDE/components/AssetSize.jsx | 13 +- client/modules/IDE/components/Banner.jsx | 49 + .../CollectionList/CollectionList.jsx | 2 +- .../CollectionList/CollectionListRow.jsx | 14 +- client/modules/IDE/components/Console.jsx | 4 +- .../modules/IDE/components/ConsoleInput.jsx | 2 +- .../modules/IDE/components/Editor/index.jsx | 124 +- .../IDE/components/Header/MobileNav.jsx | 36 +- client/modules/IDE/components/Header/Nav.jsx | 47 +- .../IDE/components/Header/Nav.unit.test.jsx | 59 +- .../modules/IDE/components/Header/Toolbar.jsx | 55 +- .../__snapshots__/Nav.unit.test.jsx.snap | 49 +- .../modules/IDE/components/IDEKeyHandlers.jsx | 2 +- .../IDE/components/KeyboardShortcutModal.jsx | 5 + client/modules/IDE/components/Modal.jsx | 2 +- client/modules/IDE/components/NewFileForm.jsx | 4 +- .../modules/IDE/components/NewFolderForm.jsx | 4 +- .../Preferences/Preferences.unit.test.jsx | 41 +- .../IDE/components/Preferences/index.jsx | 49 +- .../modules/IDE/components/PreviewFrame.jsx | 2 +- client/modules/IDE/components/ShareModal.jsx | 2 - client/modules/IDE/components/SketchList.jsx | 10 +- .../IDE/components/SketchList.unit.test.jsx | 19 +- .../IDE/components/SketchListRowBase.jsx | 39 +- client/modules/IDE/components/Timer.jsx | 8 +- .../IDE/components/UploadFileModal.jsx | 5 +- .../IDE/components/VersionIndicator.jsx | 12 +- .../modules/IDE/components/VersionPicker.jsx | 10 +- .../SketchList.unit.test.jsx.snap | 166 - client/modules/IDE/components/show-hint.js | 169 +- client/modules/IDE/hooks/useP5Version.jsx | 214 +- client/modules/IDE/pages/FullView.jsx | 4 +- client/modules/IDE/pages/IDEView.jsx | 38 +- client/modules/IDE/reducers/files.js | 4 +- .../{preferences.js => preferences.ts} | 21 +- client/modules/IDE/reducers/project.js | 15 +- client/modules/IDE/reducers/projects.js | 10 +- client/modules/IDE/selectors/files.js | 2 +- client/modules/IDE/selectors/users.js | 5 +- client/modules/IDE/utils/consoleStyles.js | 16 +- ...olicyContainer.jsx => PolicyContainer.tsx} | 12 +- .../{CodeOfConduct.jsx => CodeOfConduct.tsx} | 6 +- .../Legal/pages/{Legal.jsx => Legal.tsx} | 35 +- .../{PrivacyPolicy.jsx => PrivacyPolicy.tsx} | 6 +- .../pages/{TermsOfUse.jsx => TermsOfUse.tsx} | 6 +- client/modules/Preview/EmbedFrame.jsx | 8 +- client/modules/Preview/previewIndex.jsx | 2 +- client/modules/User/actions.js | 2 +- client/modules/User/components/APIKeyForm.jsx | 4 +- client/modules/User/components/APIKeyList.jsx | 9 +- .../modules/User/components/AccountForm.jsx | 6 +- .../User/components/CollectionCreate.jsx | 4 +- .../User/components/CollectionItemRow.jsx | 18 +- .../User/components/CollectionMetadata.jsx | 2 +- .../User/components/CollectionShareButton.jsx | 4 +- .../modules/User/components/CookieConsent.jsx | 17 +- .../User/components/DashboardTabSwitcher.jsx | 4 +- client/modules/User/components/LoginForm.jsx | 6 +- .../User/components/LoginForm.unit.test.jsx | 4 +- .../User/components/NewPasswordForm.jsx | 7 +- .../User/components/ResetPasswordForm.jsx | 4 +- client/modules/User/components/SignupForm.jsx | 11 +- .../User/components/SocialAuthButton.jsx | 2 +- .../User/components/VisibilityDropdown.jsx | 122 + client/modules/User/pages/CollectionView.jsx | 2 +- client/modules/User/pages/DashboardView.jsx | 4 +- .../User/pages/EmailVerificationView.jsx | 2 +- client/modules/User/pages/LoginView.jsx | 2 +- client/modules/User/pages/NewPasswordView.jsx | 2 +- .../modules/User/pages/ResetPasswordView.jsx | 2 +- client/modules/User/pages/SignupView.jsx | 2 +- client/{persistState.js => persistState.ts} | 7 +- client/protected-route.jsx | 32 + client/{reducers.js => reducers.ts} | 6 +- client/routes.jsx | 23 +- client/{store.js => store.ts} | 5 +- client/styles/base/_base.scss | 2 +- client/styles/components/_banner.scss | 32 + client/styles/components/_console.scss | 14 + client/styles/components/_hints.scss | 77 +- client/styles/components/_preferences.scss | 12 +- client/styles/components/_preview-frame.scss | 3 +- client/styles/components/_sketch-list.scss | 86 +- client/styles/components/_toggle.scss | 63 + client/styles/components/_toolbar.scss | 75 +- .../styles/components/_version-indicator.scss | 0 .../components/_visibility-dropdown.scss | 172 + client/styles/main.scss | 15 +- client/test-utils.js | 9 +- .../{testReduxStore.js => testReduxStore.ts} | 9 +- client/theme.js | 9 + client/tsconfig.json | 11 + client/utils/ScreenReaderHelper.js | 25 + ...ateRandomName.js => generateRandomName.ts} | 0 client/utils/apiClient.js | 17 - client/utils/apiClient.ts | 17 + client/utils/checkTestEnv.ts | 3 + client/utils/consoleUtils.test.ts | 85 + .../{consoleUtils.js => consoleUtils.ts} | 11 +- client/utils/contextAwareHinter.js | 189 + client/utils/device.js | 1 - client/utils/device.test.ts | 45 + client/utils/device.ts | 8 + client/utils/dispatcher.js | 69 - client/utils/dispatcher.test.ts | 92 + client/utils/dispatcher.ts | 91 + client/utils/evaluateExpression.js | 29 - client/utils/evaluateExpression.test.ts | 36 + client/utils/evaluateExpression.ts | 44 + client/utils/formatDate.js | 57 - client/utils/formatDate.test.ts | 79 + client/utils/formatDate.ts | 66 + ...ateRandomName.js => generateRandomName.ts} | 14 +- client/utils/getConfig.js | 25 - client/utils/getConfig.test.js | 28 - client/utils/getConfig.test.ts | 81 + client/utils/getConfig.ts | 63 + client/utils/getContext.js | 44 + client/utils/isSecurePage.js | 3 - client/utils/jump-to-def-helper.js | 69 + client/utils/jump-to-definition.js | 196 + client/utils/language-utils.js | 80 - client/utils/language-utils.test.ts | 75 + client/utils/language-utils.ts | 53 + client/utils/metaKey.js | 11 - client/utils/metaKey.ts | 17 + client/utils/p5-hinter.js | 1733 +- .../p5-instance-methods-and-creators.json | 661 + client/utils/p5-reference-functions.json | 379 + .../utils/p5-scope-function-access-map.json | 479 + client/utils/p5CodeAstAnalyzer.js | 291 + client/utils/parseStringToType.test.ts | 104 + client/utils/parseStringToType.ts | 57 + client/utils/parseURLParams.js | 87 + client/utils/parseURLParams.test.js | 51 + client/utils/previewEntry.js | 2 +- client/utils/reduxFormUtils.js | 118 - client/utils/reduxFormUtils.test.ts | 172 + client/utils/reduxFormUtils.ts | 152 + client/utils/rename-variable.js | 277 + client/utils/renameVariableHelper.js | 209 + client/utils/showRenameDialog.jsx | 179 + common/p5URLs.js | 12 + common/p5Versions.js | 146 + common/types/index.ts | 33 + .../GSOC_hinter_and_refactoring_changes.md | 83 + contributor_docs/README.md | 30 +- contributor_docs/accessibility.md | 44 +- contributor_docs/installation.md | 6 +- .../images/hoverJsDocs.gif | Bin 0 -> 3209990 bytes .../images/swagger-microsite.png | Bin 0 -> 168096 bytes .../pr05_2025_typescript_migration/index.md | 595 + contributor_docs/preparing_a_pull_request.md | 3 +- contributor_docs/s3_configuration.md | 2 +- contributor_docs/testing.md | 2 +- contributor_docs/typescript_migration.md | 13 + docker-compose-development.yml | 2 +- docker-compose.yml | 2 +- index.js | 7 +- nodemon.json | 2 +- package-lock.json | 54798 ++++++++-------- package.json | 96 +- server/config/passport.js | 35 +- server/controllers/aws.controller.js | 21 +- .../addProjectToCollection.js | 2 +- .../collectionForUserExists.js | 2 +- .../collection.controller/createCollection.js | 2 +- .../collection.controller/listCollections.js | 37 +- .../collection.controller/removeCollection.js | 4 +- .../removeProjectFromCollection.js | 4 +- .../collection.controller/updateCollection.js | 2 +- server/controllers/embed.controller.js | 31 +- server/controllers/file.controller.js | 178 +- server/controllers/project.controller.js | 52 +- .../__test__/deleteProject.test.js | 14 +- .../__test__/getProjectsForUser.test.js | 4 +- .../project.controller/deleteProject.js | 2 +- .../project.controller/getProjectsForUser.js | 19 +- server/controllers/user.controller.js | 389 - .../user.controller/__testUtils__.ts | 66 + .../user.controller/__tests__/apiKey.test.js | 158 - .../user.controller/__tests__/apiKey.test.ts | 170 + .../authManagement/3rdPartyManagement.test.ts | 123 + .../authManagement/passwordManagement.test.ts | 336 + .../authManagement/updateSettings.test.ts | 537 + .../user.controller/__tests__/helpers.test.ts | 104 + .../user.controller/__tests__/signup.test.ts | 352 + .../__tests__/userPreferences.test.ts | 178 + server/controllers/user.controller/apiKey.js | 110 - server/controllers/user.controller/apiKey.ts | 129 + .../user.controller/authManagement.ts | 260 + server/controllers/user.controller/helpers.ts | 66 + server/controllers/user.controller/index.ts | 5 + server/controllers/user.controller/signup.ts | 195 + .../user.controller/userPreferences.ts | 66 + server/domain-objects/createDefaultFiles.js | 42 +- .../__tests__/isAuthenticated.test.ts | 31 + server/middleware/isAuthenticated.ts | 18 + server/migrations/db_reformat.js | 55 +- server/migrations/emailConsolidation.js | 11 +- server/migrations/moveBucket.js | 100 +- server/migrations/populateTotalSize.js | 41 +- server/migrations/s3UnderUser.js | 2 +- server/models/__mocks__/{user.js => user.ts} | 4 +- server/models/__test__/apiKey.test.ts | 51 + server/models/__test__/user.test.ts | 125 + server/models/apiKey.ts | 38 + server/models/project.js | 23 +- server/models/{user.js => user.ts} | 133 +- server/previewServer.js | 17 +- .../routes/{api.routes.js => api.routes.ts} | 3 +- .../{asset.routes.js => asset.routes.ts} | 3 +- .../routes/{aws.routes.js => aws.routes.ts} | 5 +- ...lection.routes.js => collection.routes.ts} | 5 +- .../{embed.routes.js => embed.routes.ts} | 3 +- .../routes/{file.routes.js => file.routes.ts} | 5 +- server/routes/passport.routes.js | 35 - server/routes/passport.routes.ts | 45 + .../{project.routes.js => project.routes.ts} | 7 +- ...mbed.routes.js => redirectEmbed.routes.ts} | 3 +- server/routes/server.routes.js | 11 +- .../{session.routes.js => session.routes.ts} | 3 +- .../routes/{user.routes.js => user.routes.ts} | 70 +- server/scripts/examples-gg-latest.js | 132 +- server/scripts/examples.js | 2 +- server/scripts/fetch-examples.js | 5 +- server/server.js | 23 +- server/tsconfig.json | 13 + server/types/apiKey.ts | 51 + server/types/email.ts | 72 + server/types/express.ts | 22 + server/types/express/index.d.ts | 10 + server/types/index.ts | 6 + server/types/jest-express/index.d.ts | 9 + server/types/mongoose.ts | 8 + server/types/user.ts | 140 + server/types/userPreferences.ts | 44 + server/utils/__mocks__/mail.ts | 4 + ...eName.js => generateFileSystemSafeName.ts} | 13 +- server/utils/isAuthenticated.js | 10 - server/utils/{mail.js => mail.ts} | 27 +- server/utils/previewGeneration.js | 4 +- server/utils/renderMjml.js | 13 - server/utils/renderMjml.ts | 14 + server/views/404Page.js | 5 +- server/views/__mocks__/mail.ts | 12 + ...ilLayout.js => consolidationMailLayout.ts} | 7 +- server/views/{index.js => index.ts} | 105 +- server/views/{mail.js => mail.ts} | 33 +- server/views/{mailLayout.js => mailLayout.ts} | 7 +- .../{previewIndex.js => previewIndex.ts} | 4 +- .../locales/{be => bn}/translations.json | 186 +- translations/locales/de/translations.json | 4 +- translations/locales/en-US/translations.json | 62 +- translations/locales/es-419/translations.json | 3 + translations/locales/fr-CA/translations.json | 3 + translations/locales/hi/translations.json | 858 +- translations/locales/it/translations.json | 3 + translations/locales/ja/translations.json | 3 + translations/locales/ko/translations.json | 3 + translations/locales/pt-BR/translations.json | 488 +- translations/locales/sv/translations.json | 3 + translations/locales/tr/translations.json | 3 + translations/locales/uk-UA/translations.json | 196 +- translations/locales/ur/translations.json | 3 + translations/locales/zh-CN/translations.json | 3 + translations/locales/zh-TW/translations.json | 3 + tsconfig.base.json | 22 + tsconfig.json | 7 + webpack/config.dev.js | 14 +- webpack/config.examples.js | 157 +- webpack/config.prod.js | 224 +- webpack/config.server.js | 39 +- 348 files changed, 44011 insertions(+), 31453 deletions(-) create mode 100644 client/common/Button.test.tsx rename client/common/{Button.jsx => Button.tsx} (60%) rename client/common/{ButtonOrLink.test.jsx => ButtonOrLink.test.tsx} (96%) rename client/common/{ButtonOrLink.jsx => ButtonOrLink.tsx} (51%) delete mode 100644 client/common/IconButton.jsx create mode 100644 client/common/IconButton.test.tsx create mode 100644 client/common/IconButton.tsx create mode 100644 client/common/RouterTab.test.tsx rename client/common/{RouterTab.jsx => RouterTab.tsx} (64%) rename client/common/{icons.jsx => icons.tsx} (76%) create mode 100644 client/common/useKeyDownHandlers.test.tsx rename client/common/{useKeyDownHandlers.js => useKeyDownHandlers.ts} (61%) delete mode 100644 client/common/useModalClose.js create mode 100644 client/common/useModalClose.test.tsx create mode 100644 client/common/useModalClose.ts delete mode 100644 client/common/usePrevious.js create mode 100644 client/common/usePrevious.test.tsx create mode 100644 client/common/usePrevious.ts delete mode 100644 client/common/useSyncFormTranslations.js create mode 100644 client/common/useSyncFormTranslations.test.tsx create mode 100644 client/common/useSyncFormTranslations.ts delete mode 100644 client/components/Dropdown.jsx delete mode 100644 client/components/Dropdown/DropdownMenu.jsx create mode 100644 client/components/Dropdown/DropdownMenu.tsx delete mode 100644 client/components/Dropdown/MenuItem.jsx create mode 100644 client/components/Dropdown/MenuItem.tsx rename client/components/Dropdown/{TableDropdown.jsx => TableDropdown.tsx} (77%) rename client/components/Menubar/{Menubar.test.jsx => Menubar.test.tsx} (97%) rename client/components/Menubar/{Menubar.jsx => Menubar.tsx} (82%) rename client/components/Menubar/{MenubarItem.jsx => MenubarItem.tsx} (82%) rename client/components/Menubar/{MenubarSubmenu.jsx => MenubarSubmenu.tsx} (56%) delete mode 100644 client/components/Menubar/contexts.jsx create mode 100644 client/components/Menubar/contexts.tsx create mode 100644 client/components/PreviewNav.test.tsx rename client/components/{PreviewNav.jsx => PreviewNav.tsx} (55%) rename client/components/{RootPage.jsx => RootPage.tsx} (62%) create mode 100644 client/components/SkipLink.test.tsx rename client/components/{SkipLink.jsx => SkipLink.tsx} (73%) delete mode 100644 client/components/useAsModal.jsx create mode 100644 client/custom.d.ts create mode 100644 client/images/checkmark.svg create mode 100644 client/images/earth.svg create mode 100644 client/images/lock.svg rename client/modules/About/{About.styles.js => About.styles.ts} (95%) rename client/modules/About/pages/{About.jsx => About.tsx} (78%) rename client/modules/About/statics/{aboutData.js => aboutData.ts} (87%) rename client/modules/IDE/actions/{preferences.js => preferences.ts} (61%) create mode 100644 client/modules/IDE/actions/preferences.types.ts create mode 100644 client/modules/IDE/components/Banner.jsx delete mode 100644 client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap rename client/modules/IDE/reducers/{preferences.js => preferences.ts} (80%) rename client/modules/Legal/components/{PolicyContainer.jsx => PolicyContainer.tsx} (86%) rename client/modules/Legal/pages/{CodeOfConduct.jsx => CodeOfConduct.tsx} (70%) rename client/modules/Legal/pages/{Legal.jsx => Legal.tsx} (82%) rename client/modules/Legal/pages/{PrivacyPolicy.jsx => PrivacyPolicy.tsx} (70%) rename client/modules/Legal/pages/{TermsOfUse.jsx => TermsOfUse.tsx} (70%) create mode 100644 client/modules/User/components/VisibilityDropdown.jsx rename client/{persistState.js => persistState.ts} (68%) create mode 100644 client/protected-route.jsx rename client/{reducers.js => reducers.ts} (82%) rename client/{store.js => store.ts} (90%) create mode 100644 client/styles/components/_banner.scss create mode 100644 client/styles/components/_toggle.scss create mode 100644 client/styles/components/_version-indicator.scss create mode 100644 client/styles/components/_visibility-dropdown.scss rename client/testData/{testReduxStore.js => testReduxStore.ts} (92%) create mode 100644 client/tsconfig.json create mode 100644 client/utils/ScreenReaderHelper.js rename client/utils/__mocks__/{generateRandomName.js => generateRandomName.ts} (100%) delete mode 100644 client/utils/apiClient.js create mode 100644 client/utils/apiClient.ts create mode 100644 client/utils/checkTestEnv.ts create mode 100644 client/utils/consoleUtils.test.ts rename client/utils/{consoleUtils.js => consoleUtils.ts} (77%) create mode 100644 client/utils/contextAwareHinter.js delete mode 100644 client/utils/device.js create mode 100644 client/utils/device.test.ts create mode 100644 client/utils/device.ts delete mode 100644 client/utils/dispatcher.js create mode 100644 client/utils/dispatcher.test.ts create mode 100644 client/utils/dispatcher.ts delete mode 100644 client/utils/evaluateExpression.js create mode 100644 client/utils/evaluateExpression.test.ts create mode 100644 client/utils/evaluateExpression.ts delete mode 100644 client/utils/formatDate.js create mode 100644 client/utils/formatDate.test.ts create mode 100644 client/utils/formatDate.ts rename client/utils/{generateRandomName.js => generateRandomName.ts} (53%) delete mode 100644 client/utils/getConfig.js delete mode 100644 client/utils/getConfig.test.js create mode 100644 client/utils/getConfig.test.ts create mode 100644 client/utils/getConfig.ts create mode 100644 client/utils/getContext.js delete mode 100644 client/utils/isSecurePage.js create mode 100644 client/utils/jump-to-def-helper.js create mode 100644 client/utils/jump-to-definition.js delete mode 100644 client/utils/language-utils.js create mode 100644 client/utils/language-utils.test.ts create mode 100644 client/utils/language-utils.ts delete mode 100644 client/utils/metaKey.js create mode 100644 client/utils/metaKey.ts create mode 100644 client/utils/p5-instance-methods-and-creators.json create mode 100644 client/utils/p5-reference-functions.json create mode 100644 client/utils/p5-scope-function-access-map.json create mode 100644 client/utils/p5CodeAstAnalyzer.js create mode 100644 client/utils/parseStringToType.test.ts create mode 100644 client/utils/parseStringToType.ts create mode 100644 client/utils/parseURLParams.js create mode 100644 client/utils/parseURLParams.test.js delete mode 100644 client/utils/reduxFormUtils.js create mode 100644 client/utils/reduxFormUtils.test.ts create mode 100644 client/utils/reduxFormUtils.ts create mode 100644 client/utils/rename-variable.js create mode 100644 client/utils/renameVariableHelper.js create mode 100644 client/utils/showRenameDialog.jsx create mode 100644 common/p5URLs.js create mode 100644 common/p5Versions.js create mode 100644 common/types/index.ts create mode 100644 contributor_docs/GSOC_hinter_and_refactoring_changes.md create mode 100644 contributor_docs/pr05_2025_typescript_migration/images/hoverJsDocs.gif create mode 100644 contributor_docs/pr05_2025_typescript_migration/images/swagger-microsite.png create mode 100644 contributor_docs/pr05_2025_typescript_migration/index.md create mode 100644 contributor_docs/typescript_migration.md delete mode 100644 server/controllers/user.controller.js create mode 100644 server/controllers/user.controller/__testUtils__.ts delete mode 100644 server/controllers/user.controller/__tests__/apiKey.test.js create mode 100644 server/controllers/user.controller/__tests__/apiKey.test.ts create mode 100644 server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts create mode 100644 server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts create mode 100644 server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts create mode 100644 server/controllers/user.controller/__tests__/helpers.test.ts create mode 100644 server/controllers/user.controller/__tests__/signup.test.ts create mode 100644 server/controllers/user.controller/__tests__/userPreferences.test.ts delete mode 100644 server/controllers/user.controller/apiKey.js create mode 100644 server/controllers/user.controller/apiKey.ts create mode 100644 server/controllers/user.controller/authManagement.ts create mode 100644 server/controllers/user.controller/helpers.ts create mode 100644 server/controllers/user.controller/index.ts create mode 100644 server/controllers/user.controller/signup.ts create mode 100644 server/controllers/user.controller/userPreferences.ts create mode 100644 server/middleware/__tests__/isAuthenticated.test.ts create mode 100644 server/middleware/isAuthenticated.ts rename server/models/__mocks__/{user.js => user.ts} (90%) create mode 100644 server/models/__test__/apiKey.test.ts create mode 100644 server/models/__test__/user.test.ts create mode 100644 server/models/apiKey.ts rename server/models/{user.js => user.ts} (79%) rename server/routes/{api.routes.js => api.routes.ts} (89%) rename server/routes/{asset.routes.js => asset.routes.ts} (89%) rename server/routes/{aws.routes.js => aws.routes.ts} (76%) rename server/routes/{collection.routes.js => collection.routes.ts} (86%) rename server/routes/{embed.routes.js => embed.routes.ts} (92%) rename server/routes/{file.routes.js => file.routes.ts} (69%) delete mode 100644 server/routes/passport.routes.js create mode 100644 server/routes/passport.routes.ts rename server/routes/{project.routes.js => project.routes.ts} (75%) rename server/routes/{redirectEmbed.routes.js => redirectEmbed.routes.ts} (92%) rename server/routes/{session.routes.js => session.routes.ts} (79%) rename server/routes/{user.routes.js => user.routes.ts} (59%) create mode 100644 server/tsconfig.json create mode 100644 server/types/apiKey.ts create mode 100644 server/types/email.ts create mode 100644 server/types/express.ts create mode 100644 server/types/express/index.d.ts create mode 100644 server/types/index.ts create mode 100644 server/types/jest-express/index.d.ts create mode 100644 server/types/mongoose.ts create mode 100644 server/types/user.ts create mode 100644 server/types/userPreferences.ts create mode 100644 server/utils/__mocks__/mail.ts rename server/utils/{generateFileSystemSafeName.js => generateFileSystemSafeName.ts} (55%) delete mode 100644 server/utils/isAuthenticated.js rename server/utils/{mail.js => mail.ts} (67%) delete mode 100644 server/utils/renderMjml.js create mode 100644 server/utils/renderMjml.ts create mode 100644 server/views/__mocks__/mail.ts rename server/views/{consolidationMailLayout.js => consolidationMailLayout.ts} (90%) rename server/views/{index.js => index.ts} (53%) rename server/views/{mail.js => mail.ts} (80%) rename server/views/{mailLayout.js => mailLayout.ts} (87%) rename server/views/{previewIndex.js => previewIndex.ts} (94%) rename translations/locales/{be => bn}/translations.json (76%) create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json diff --git a/.babelrc b/.babelrc index a34308e711..3bb00f57cf 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,8 @@ { "presets": [ "@babel/preset-react", - "@babel/preset-env" + "@babel/preset-env", + "@babel/preset-typescript" ], "env": { "production": { diff --git a/.eslintrc b/.eslintrc index 6260aa925a..22dddfeffb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,13 +16,36 @@ "import/no-unresolved": 0, "import/no-named-as-default": 0, "import/no-named-as-default-member": 0, + "import/no-useless-path-segments": 1, + "import/no-cycle":0, //temporarily off + "import/no-import-module-exports": 0, //temporarily off + "import/extensions": [ // override airbnb setting to allow imports of js, jsx, ts, and tsx files to auto-resolve instead of error + "error", + "ignorePackages", + { + "js": "never", + "jsx": "never", + "ts": "never", + "tsx": "never" + } + ], + "import/prefer-default-export": "off", + "react/jsx-filename-extension": [1, { "extensions": [".jsx", ".tsx"] }], "comma-dangle": 0, // not sure why airbnb turned this on. gross! + "default-param-last": 0, + "no-else-return" :0, "indent": 0, "no-console": 0, "no-alert": 0, + "no-import-assign": 2, + "no-promise-executor-return": 0, //temporarily off + "no-restricted-exports": 1, "no-underscore-dangle": 0, "no-useless-catch": 2, + "no-plusplus": "off", + "prefer-object-spread": 0, "max-len": [1, 120, 2, {"ignoreComments": true, "ignoreTemplateLiterals": true}], + "max-classes-per-file": 0, "quote-props": [1, "as-needed"], "no-unused-vars": [1, {"vars": "local", "args": "none"}], "consistent-return": ["error", { "treatUndefinedAsUnspecified": true }], @@ -36,7 +59,19 @@ { "ignorePureComponents": true }], "class-methods-use-this": 0, - "react/jsx-no-bind": [2, {"allowBind": true, "allowArrowFunctions": true}], + "react/button-has-type": 0, + "react/destructuring-assignment":0, + "react/function-component-definition": 0, + "react/jsx-curly-newline":0, + "react/jsx-fragments":0, + "react/jsx-no-useless-fragment":0, // temporarily off + "react/jsx-one-expression-per-line": 0, + "react/jsx-props-no-spreading": 0, + "react/jsx-wrap-multilines": 0, + "react/jsx-no-bind": [2, {"allowBind": true, "allowArrowFunctions": true, "allowFunctions": true}], + "react/no-deprecated": 0, //temporarily off + "react/no-unused-class-component-methods": 1, + "react/sort-comp": 0, "no-return-assign": [2, "except-parens"], "jsx-a11y/anchor-is-valid": [ "error", @@ -49,6 +84,8 @@ ] } ], + "jsx-a11y/control-has-associated-label": 0, //temporarily off + "jsx-a11y/label-has-associated-control": 0, //temporarily off "jsx-a11y/label-has-for": [ 2, { @@ -72,6 +109,11 @@ ], "settings": { "import/parser": "@babel/eslint-parser", + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + }, "import/resolve": { "moduleDirectory": ["node_modules"] } @@ -85,7 +127,22 @@ }, "overrides": [ { - "files": ["*.stories.jsx"], + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "rules": { + "no-use-before-define": "off", + "import/no-extraneous-dependencies": "off", + "no-unused-vars": "off", + "import/no-default-export": "warn", + "no-underscore-dangle": "off", + "react/require-default-props": "off", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error" + } + }, + { + "files": ["*.stories.@(js|jsx|ts|tsx)"], "rules": { "import/no-extraneous-dependencies": "off" } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1f457785f4..df080857be 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,3 +8,4 @@ I have verified that this pull request: * [ ] has no test errors (`npm run test`) * [ ] is from a uniquely-named feature branch and is up to date with the `develop` branch. * [ ] is descriptively named and links to an issue number, i.e. `Fixes #123` +* [ ] meets the standards outlined in the [accessibility guidelines](https://github.com/processing/p5.js-web-editor/blob/develop/contributor_docs/accessibility.md) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c3360bb029..4a39783065 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,13 +7,12 @@ jobs: name: Test and lint code base runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Use Node.js - uses: actions/setup-node@v1 - with: - node-version: '16.14.x' - - run: npm install - - run: npm run test - - run: npm run lint - - + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: '18.20.x' + - run: npm install + - run: npm run test + - run: npm run typecheck + - run: npm run lint diff --git a/.gitignore b/.gitignore index 47ebf7ccbc..bfedbe9b96 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ terraform/.terraform/ storybook-static duplicates.json -coverage \ No newline at end of file +coverage + +*.tsbuildinfo \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index d9f880069d..08b7109d00 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.14.2 +18.20.8 diff --git a/.prettierrc b/.prettierrc index e2da8bb25a..df6b0841b0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,7 +5,6 @@ "insertPragma": false, "jsxBracketSameLine": false, "jsxSingleQuote": false, - "parser": "babel", "printWidth": 80, "proseWrap": "never", "requirePragma": false, diff --git a/.storybook/main.js b/.storybook/main.js index 3596c88b9e..82272d32af 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,6 +1,6 @@ /** @type { import('@storybook/react-webpack5').StorybookConfig } */ const config = { - stories: ['../client/**/*.stories.(jsx|mdx)'], + stories: ['../client/**/*.stories.(jsx|mdx|tsx)'], addons: [ '@storybook/addon-links', '@storybook/addon-essentials', @@ -18,19 +18,19 @@ const config = { // https://storybook.js.org/docs/react/builders/webpack // this modifies the existing image rule to exclude .svg files // since we want to handle those files with @svgr/webpack - const imageRule = config.module.rules.find(rule => rule.test.test('.svg')) - imageRule.exclude = /\.svg$/ + const imageRule = config.module.rules.find((rule) => + rule.test.test('.svg') + ); + imageRule.exclude = /\.svg$/; // configure .svg files to be loaded with @svgr/webpack config.module.rules.push({ test: /\.svg$/, use: ['@svgr/webpack'] - }) + }); - return config - }, + return config; + } }; export default config; - - diff --git a/.storybook/preview.js b/.storybook/preview.js index 9260b91c98..2f611743f2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,14 +2,14 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router'; -import configureStore from '../client/store'; +import { setupStore } from '../client/store'; import '../client/i18n-test'; -import '../client/styles/storybook.css' +import '../client/styles/storybook.css'; import { withThemeProvider, themeToolbarItem } from './decorator-theme'; const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); export const decorators = [ (Story) => ( diff --git a/.travis.yml b/.travis.yml index 6888df8a26..553859080c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ sudo: required language: node_js node_js: - - "16.14.2" + - "18.20.8" cache: directories: diff --git a/Dockerfile b/Dockerfile index ac21908220..27f5b5e3f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.14.2 AS base +FROM node:18.20.8 AS base ENV APP_HOME=/usr/src/app \ TERM=xterm RUN mkdir -p $APP_HOME @@ -14,6 +14,7 @@ COPY .babelrc index.js nodemon.json ./ COPY ./webpack ./webpack COPY client ./client COPY server ./server +COPY common ./common COPY translations/locales ./translations/locales COPY public ./public CMD ["npm", "start"] @@ -27,4 +28,4 @@ ENV NODE_ENV=production COPY package.json package-lock.json index.js ./ RUN npm install --production COPY --from=build $APP_HOME/dist ./dist -CMD ["npm", "run", "start:prod"] +CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/README.md b/README.md index 5ac8968c49..21f63ec68f 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,23 @@ We are a community of, and in solidarity with, people from every gender identity Learn more about [our community](https://p5js.org/community/) and read our [Community Statement and Code of Conduct](./.github/CODE_OF_CONDUCT.md). You can directly support our work with p5.js by [donating to the Processing Foundation](https://processingfoundation.org/support). +Stay in touch with Processing Foundation across other platforms: + +- [Instagram](https://www.instagram.com/p5xjs) +- [Youtube](https://www.youtube.com/@ProcessingFoundation) +- [X](https://x.com/p5xjs) +- [Discord](https://discord.com/invite/esmGA6H6wm) +- [Forum](https://discourse.processing.org) + ## Using the p5.js Editor 🤔 -Make your first sketch in the [p5.js Editor](https://editor.p5js.org/)! Learn more about sketching with p5.js on the [Get Started](https://p5js.org/tutorials/get-started/) and find everything you can do in the [Reference](https://p5js.org/reference/). You can also look at [examples](https://editor.p5js.org/p5/sketches) and remix them in the p5.js Editor. +Make your first sketch in the [p5.js Editor](https://editor.p5js.org/)! Learn more about sketching with p5.js on the [Get Started](https://p5js.org/tutorials/get-started/) and find everything you can do in the [Reference](https://p5js.org/reference/). You can also look at [examples](https://editor.p5js.org/p5/sketches) and remix them in the p5.js Editor. -For more information on usage guidelines for the p5.js Editor, check out the [p5.js Editor Terms of Use](https://editor.p5js.org/terms-of-use). To gain better insight into how we handle user data and data privacy, refer to the [p5.js Editor Privacy Policy](https://editor.p5js.org/privacy-policy). +For more information on usage guidelines for the p5.js Editor, check out the [p5.js Editor Terms of Use](https://editor.p5js.org/terms-of-use). To gain better insight into how we handle user data and data privacy, refer to the [p5.js Editor Privacy Policy](https://editor.p5js.org/privacy-policy). ## Contributing 📖 🐛 🎨 -The p5.js Editor is a collaborative project created by many individuals, mostly volunteers, and you are invited to help. All types of involvement are welcome. To get started with contributing to the p5.js Editor, we recommend exploring the following resources in order: +The p5.js Editor is a collaborative project created by many individuals, mostly volunteers, and you are invited to help. All types of involvement are welcome. To get started with contributing to the p5.js Editor, we recommend exploring the following resources in order: 1. [p5.js Community Statement and Code of Conduct](https://editor.p5js.org/code-of-conduct) - Read our Community Statement and Code of Conduct to understand the values that guide our community and how to participate respectfully and constructively. @@ -26,14 +34,12 @@ The p5.js Editor is a collaborative project created by many individuals, mostly 3. [All Contributors list on the p5.js repository](https://github.com/processing/p5.js?tab=readme-ov-file#contributors) - Explore the All Contributors list to see the wide range of contributions by our amazing community! +> **TypeScript Migration:** We have initiated migrating the repo to Typescript as part of the **[p5.js Web Editor pr05 Grant](https://github.com/processing/pr05-grant/wiki/2025-pr05-Program-Page)**, and the repo is now open to migration contributions. +> +> Please see [Typescript Migration](contributor_docs/typescript_migration.md) for migration guidelines. Please see [2025 pr05 Typescript Migration Project](contributor_docs/pr05_2025_typescript_migration/index.md) for details and technical decisions for the migration project. ## Acknowledgements 🙏 -Support for this project has come from [Processing Foundation](https://processingfoundation.org/), [NYU ITP](https://tisch.nyu.edu/itp), [CS4All, NYC DOE](http://cs4all.nyc/), [COSA at DU](https://liberalarts.du.edu/emergent-digital-practices/open-source-arts), [STUDIO for Creative Inquiry](https://studioforcreativeinquiry.org/), [Grant for the Web](https://www.grantfortheweb.org/), [New Media Rights](https://www.newmediarights.org/), and many others. +Support for this project has come from [Processing Foundation](https://processingfoundation.org/), [NYU ITP](https://tisch.nyu.edu/itp), [CS4All, NYC DOE](http://cs4all.nyc/), [COSA at DU](https://liberalarts.du.edu/emergent-digital-practices/open-source-arts), [STUDIO for Creative Inquiry](https://studioforcreativeinquiry.org/), [Grant for the Web](https://www.grantfortheweb.org/), [New Media Rights](https://www.newmediarights.org/), and many others. -Hosting and technical support has come from: -
-
- -
- +Hosting and technical support has come from:


diff --git a/client/common/Button.stories.jsx b/client/common/Button.stories.jsx index d11634ae28..0a0150a5b6 100644 --- a/client/common/Button.stories.jsx +++ b/client/common/Button.stories.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import Button from './Button'; +import { Button, ButtonDisplays, ButtonKinds, ButtonTypes } from './Button'; import { GithubIcon, DropdownArrowIcon, PlusIcon } from './icons'; export default { @@ -15,13 +15,13 @@ export default { }; export const AllFeatures = (args) => ( - ); export const SubmitButton = () => ( - ); @@ -59,7 +59,7 @@ export const ButtonWithIconAfter = () => ( ); export const InlineButtonWithIconAfter = () => ( - ); @@ -68,6 +68,6 @@ export const InlineIconOnlyButton = () => ( ); + const anchor = screen.getByRole('link'); + expect(anchor.tagName.toLowerCase()).toBe('a'); + expect(anchor).toHaveAttribute('href', 'https://example.com'); + }); + + it('renders as a React Router when `to` is provided', () => { + render(); + const link = screen.getByRole('link'); + expect(link.tagName.toLowerCase()).toBe('a'); // Link renders as + expect(link).toHaveAttribute('href', '/dashboard'); + }); + + it('renders as a ); + const el = screen.getByRole('button'); + expect(el.tagName.toLowerCase()).toBe('button'); + expect(el).toHaveAttribute('type', 'button'); + }); + + // Children & Icons + it('renders children', () => { + render(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('renders an iconBefore and button text', () => { + render( + + ); + expect(screen.getByLabelText('iconbefore')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent( + 'This has a before icon' + ); + }); + + it('renders with iconAfter', () => { + render( + + ); + expect(screen.getByLabelText('iconafter')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent( + 'This has an after icon' + ); + }); + + it('renders only the icon if iconOnly', () => { + render( + + ); + expect(screen.getByLabelText('iconafter')).toBeInTheDocument(); + expect(screen.getByRole('button')).not.toHaveTextContent( + 'This has an after icon' + ); + }); + + // HTML attributes + it('calls onClick handler when clicked', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByText('Click')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders disabled state', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('uses aria-label when provided', () => { + render( - {isOpen && ( - { - setTimeout(close, 0); - }} - onBlur={handleBlur} - onFocus={handleFocus} - style={maxHeight && { maxHeight, overflowY: 'auto' }} - > - {children} - - )} - - ); - } -); - -DropdownMenu.propTypes = { - /** - * Provide elements as children to control the contents of the menu. - */ - children: PropTypes.node.isRequired, - /** - * Can optionally override the contents of the button which opens the menu. - * Defaults to - */ - anchor: PropTypes.node, - 'aria-label': PropTypes.string.isRequired, - align: PropTypes.oneOf(['left', 'right']), - className: PropTypes.string, - classes: PropTypes.shape({ - button: PropTypes.string, - list: PropTypes.string - }), - maxHeight: PropTypes.string -}; - -DropdownMenu.defaultProps = { - anchor: null, - align: 'right', - className: '', - classes: {}, - maxHeight: undefined -}; - -export default DropdownMenu; diff --git a/client/components/Dropdown/DropdownMenu.tsx b/client/components/Dropdown/DropdownMenu.tsx new file mode 100644 index 0000000000..3d6e262e2a --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.tsx @@ -0,0 +1,168 @@ +import React, { forwardRef, useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { remSize, prop } from '../../theme'; +import { useModalClose } from '../../common/useModalClose'; +import DownArrowIcon from '../../images/down-filled-triangle.svg'; + +export enum DropdownMenuAlignment { + RIGHT = 'right', + LEFT = 'left' +} + +interface StyledDropdownMenuProps { + align: DropdownMenuAlignment; +} + +const DropdownWrapper = styled.ul` + background-color: ${prop('Modal.background')}; + border: 1px solid ${prop('Modal.border')}; + box-shadow: 0 0 18px 0 ${prop('shadowColor')}; + color: ${prop('primaryTextColor')}; + + position: absolute; + right: ${(props) => + props.align === DropdownMenuAlignment.RIGHT ? 0 : 'initial'}; + left: ${(props) => + props.align === DropdownMenuAlignment.LEFT ? 0 : 'initial'}; + + text-align: left; + width: ${remSize(180)}; + display: flex; + flex-direction: column; + height: auto; + z-index: 2; + border-radius: ${remSize(6)}; + + & li:first-child { + border-radius: ${remSize(5)} ${remSize(5)} 0 0; + } + & li:last-child { + border-radius: 0 0 ${remSize(5)} ${remSize(5)}; + } + + & li:hover { + background-color: ${prop('Button.primary.hover.background')}; + color: ${prop('Button.primary.hover.foreground')}; + + * { + color: ${prop('Button.primary.hover.foreground')}; + } + } + + li { + height: ${remSize(36)}; + cursor: pointer; + display: flex; + align-items: center; + + & button, + & button span, + & a { + padding: ${remSize(8)} ${remSize(16)}; + font-size: ${remSize(12)}; + } + + * { + text-align: left; + justify-content: left; + + color: ${prop('primaryTextColor')}; + width: 100%; + justify-content: flex-start; + } + + & button span { + padding: 0px; + } + } +`; + +export interface DropdownMenuProps extends StyledDropdownMenuProps { + /** + * Provide elements as children to control the contents of the menu. + */ + children: React.ReactNode; + /** + * Can optionally override the contents of the button which opens the menu. + * Defaults to + */ + anchor?: React.ReactNode; + 'aria-label': string; + className?: string; + classes?: { + button?: string; + list?: string; + }; + maxHeight?: string; +} + +export const DropdownMenu = forwardRef( + ( + { + children, + anchor, + 'aria-label': ariaLabel, + align = DropdownMenuAlignment.RIGHT, + className = '', + classes = {}, + maxHeight + }, + ref + ) => { + // Note: need to use a ref instead of a state to avoid stale closures. + const focusedRef = useRef(false); + + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => setIsOpen(false), [setIsOpen]); + + const anchorRef = useModalClose(close, ref); + + const toggle = useCallback(() => { + setIsOpen((prevState) => !prevState); + }, [setIsOpen]); + + const handleFocus = () => { + focusedRef.current = true; + }; + + const handleBlur = () => { + focusedRef.current = false; + setTimeout(() => { + if (!focusedRef.current) { + // close(); + } + }, 200); + }; + + return ( +
+ + {isOpen && ( + { + setTimeout(close, 0); + }} + onBlur={handleBlur} + onFocus={handleFocus} + style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined} + > + {children} + + )} +
+ ); + } +); diff --git a/client/components/Dropdown/MenuItem.jsx b/client/components/Dropdown/MenuItem.jsx deleted file mode 100644 index a7908e487d..0000000000 --- a/client/components/Dropdown/MenuItem.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ButtonOrLink from '../../common/ButtonOrLink'; - -// TODO: combine with NavMenuItem - -function MenuItem({ hideIf, ...rest }) { - if (hideIf) { - return null; - } - - return ( -
  • - -
  • - ); -} - -MenuItem.propTypes = { - ...ButtonOrLink.propTypes, - onClick: PropTypes.func, - value: PropTypes.string, - /** - * Provides a way to deal with optional items. - */ - hideIf: PropTypes.bool -}; - -MenuItem.defaultProps = { - onClick: null, - value: null, - hideIf: false -}; - -export default MenuItem; diff --git a/client/components/Dropdown/MenuItem.tsx b/client/components/Dropdown/MenuItem.tsx new file mode 100644 index 0000000000..401aae3361 --- /dev/null +++ b/client/components/Dropdown/MenuItem.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink'; + +// TODO: combine with NavMenuItem + +export interface MenuItemProps extends ButtonOrLinkProps { + /** + * Provides a way to deal with optional items. + */ + hideIf?: boolean; + value?: string; +} + +export function MenuItem({ hideIf = false, ...rest }: MenuItemProps) { + if (hideIf) { + return null; + } + + return ( +
  • + +
  • + ); +} diff --git a/client/components/Dropdown/TableDropdown.jsx b/client/components/Dropdown/TableDropdown.tsx similarity index 77% rename from client/components/Dropdown/TableDropdown.jsx rename to client/components/Dropdown/TableDropdown.tsx index 44f4f27fd6..e9408dd6b9 100644 --- a/client/components/Dropdown/TableDropdown.jsx +++ b/client/components/Dropdown/TableDropdown.tsx @@ -1,7 +1,11 @@ import React from 'react'; import styled from 'styled-components'; import { prop, remSize } from '../../theme'; -import DropdownMenu from './DropdownMenu'; +import { + DropdownMenu, + DropdownMenuProps, + DropdownMenuAlignment +} from './DropdownMenu'; import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg'; import MoreIconSvg from '../../images/more.svg'; @@ -20,8 +24,10 @@ const TableDropdownIcon = () => { ); }; -const TableDropdown = styled(DropdownMenu).attrs({ - align: 'right', +export interface TableDropdownProps extends DropdownMenuProps {} + +export const TableDropdown = styled(DropdownMenu).attrs({ + align: DropdownMenuAlignment.RIGHT, anchor: })` & > button { @@ -42,5 +48,3 @@ const TableDropdown = styled(DropdownMenu).attrs({ right: calc(100% - 26px); } `; - -export default TableDropdown; diff --git a/client/components/Menubar/Menubar.test.jsx b/client/components/Menubar/Menubar.test.tsx similarity index 97% rename from client/components/Menubar/Menubar.test.jsx rename to client/components/Menubar/Menubar.test.tsx index 0f78d2f547..ade21a4138 100644 --- a/client/components/Menubar/Menubar.test.jsx +++ b/client/components/Menubar/Menubar.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, screen, fireEvent } from '../../test-utils'; -import Menubar from './Menubar'; -import MenubarSubmenu from './MenubarSubmenu'; -import MenubarItem from './MenubarItem'; +import { Menubar } from './Menubar'; +import { MenubarSubmenu } from './MenubarSubmenu'; +import { MenubarItem } from './MenubarItem'; describe('Menubar', () => { const renderMenubar = () => { diff --git a/client/components/Menubar/Menubar.jsx b/client/components/Menubar/Menubar.tsx similarity index 82% rename from client/components/Menubar/Menubar.jsx rename to client/components/Menubar/Menubar.tsx index 8a358fceb6..f2244ad4f9 100644 --- a/client/components/Menubar/Menubar.jsx +++ b/client/components/Menubar/Menubar.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useMemo, @@ -6,18 +5,14 @@ import React, { useState, useEffect } from 'react'; -import useModalClose from '../../common/useModalClose'; +import { useModalClose } from '../../common/useModalClose'; import { MenuOpenContext, MenubarContext } from './contexts'; -import usePrevious from '../../common/usePrevious'; +import { usePrevious } from '../../common/usePrevious'; /** * Menubar manages a collection of menu items and their submenus. It provides keyboard navigation, * focus and state management, and other accessibility features for the menu items and submenus. * - * @param {React.ReactNode} props.children - Menu items that will be rendered in the menubar - * @param {string} [props.className='nav__menubar'] - CSS class name to apply to the menubar - * @returns {JSX.Element} - * * @example * * @@ -26,16 +21,26 @@ import usePrevious from '../../common/usePrevious'; * */ -function Menubar({ children, className }) { - const [menuOpen, setMenuOpen] = useState('none'); - const [activeIndex, setActiveIndex] = useState(0); - const prevIndex = usePrevious(activeIndex); - const [hasFocus, setHasFocus] = useState(false); +export interface MenubarProps { + /** Menu items that will be rendered in the menubar */ + children?: React.ReactNode; + /** CSS class name to apply to the menubar */ + className?: string; +} + +export function Menubar({ + children, + className = 'nav__menubar' +}: MenubarProps) { + const [menuOpen, setMenuOpen] = useState('none'); + const [activeIndex, setActiveIndex] = useState(0); + const prevIndex = usePrevious(activeIndex); + const [hasFocus, setHasFocus] = useState(false); - const menuItems = useRef(new Set()).current; + const menuItems = useRef>(new Set()).current; const menuItemToId = useRef(new Map()).current; - const timerRef = useRef(null); + const timerRef = useRef | null>(null); const getMenuId = useCallback( (index) => { @@ -85,7 +90,7 @@ function Menubar({ children, className }) { const toggleMenuOpen = useCallback((id) => { setMenuOpen((prevState) => (prevState === id ? 'none' : id)); - }); + }, []); const registerTopLevelItem = useCallback( (ref, submenuId) => { @@ -105,7 +110,7 @@ function Menubar({ children, className }) { ); const clearHideTimeout = useCallback(() => { - if (timerRef.current) { + if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } @@ -116,7 +121,7 @@ function Menubar({ children, className }) { setMenuOpen('none'); }, [setMenuOpen]); - const nodeRef = useModalClose(handleClose); + const nodeRef = useModalClose(handleClose); const handleFocus = useCallback(() => { setHasFocus(true); @@ -138,7 +143,7 @@ function Menubar({ children, className }) { [nodeRef] ); - const keyHandlers = { + const keyHandlers: Record void> = { ArrowLeft: (e) => { e.preventDefault(); e.stopPropagation(); @@ -173,8 +178,11 @@ function Menubar({ children, className }) { useEffect(() => { if (activeIndex !== prevIndex) { const items = Array.from(menuItems); + const prevNode = + prevIndex != null /** check against undefined or null */ + ? items[prevIndex] + : undefined; const activeNode = items[activeIndex]; - const prevNode = items[prevIndex]; prevNode?.setAttribute('tabindex', '-1'); activeNode?.setAttribute('tabindex', '0'); @@ -191,7 +199,7 @@ function Menubar({ children, className }) { const contextValue = useMemo( () => ({ - createMenuHandlers: (menu) => ({ + createMenuHandlers: (menu: string) => ({ onMouseOver: () => { setMenuOpen((prevState) => (prevState === 'none' ? 'none' : menu)); }, @@ -210,8 +218,8 @@ function Menubar({ children, className }) { onBlur: handleBlur, onFocus: clearHideTimeout }), - createMenuItemHandlers: (menu) => ({ - onMouseUp: (e) => { + createMenuItemHandlers: (menu: string) => ({ + onMouseUp: (e: React.MouseEvent) => { if (e.button === 2) { return; } @@ -278,15 +286,3 @@ function Menubar({ children, className }) { ); } - -Menubar.propTypes = { - children: PropTypes.node, - className: PropTypes.string -}; - -Menubar.defaultProps = { - children: null, - className: 'nav__menubar' -}; - -export default Menubar; diff --git a/client/components/Menubar/MenubarItem.jsx b/client/components/Menubar/MenubarItem.tsx similarity index 82% rename from client/components/Menubar/MenubarItem.jsx rename to client/components/Menubar/MenubarItem.tsx index ab3b741e34..d2b3a1b1a3 100644 --- a/client/components/Menubar/MenubarItem.jsx +++ b/client/components/Menubar/MenubarItem.tsx @@ -1,7 +1,19 @@ -import PropTypes from 'prop-types'; import React, { useEffect, useContext, useRef } from 'react'; import { MenubarContext, SubmenuContext, ParentMenuContext } from './contexts'; -import ButtonOrLink from '../../common/ButtonOrLink'; +import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink'; + +export enum MenubarItemRole { + MENU_ITEM = 'menuitem', + OPTION = 'option' +} + +export interface MenubarItemProps extends Omit { + /** + * Provides a way to deal with optional items. + */ + role?: MenubarItemRole; + selected?: boolean; +} /** * MenubarItem wraps a button or link in an accessible list item that @@ -36,14 +48,14 @@ import ButtonOrLink from '../../common/ButtonOrLink'; * */ -function MenubarItem({ - className, +export function MenubarItem({ + className = 'nav__dropdown-item', id, - role: customRole, - isDisabled, - selected, + role: customRole = MenubarItemRole.MENU_ITEM, + isDisabled = false, + selected = false, ...rest -}) { +}: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); const { setSubmenuActiveIndex, @@ -94,25 +106,3 @@ function MenubarItem({ ); } - -MenubarItem.propTypes = { - ...ButtonOrLink.propTypes, - className: PropTypes.string, - id: PropTypes.string, - /** - * Provides a way to deal with optional items. - */ - role: PropTypes.oneOf(['menuitem', 'option']), - isDisabled: PropTypes.bool, - selected: PropTypes.bool -}; - -MenubarItem.defaultProps = { - className: 'nav__dropdown-item', - id: undefined, - role: 'menuitem', - isDisabled: false, - selected: false -}; - -export default MenubarItem; diff --git a/client/components/Menubar/MenubarSubmenu.jsx b/client/components/Menubar/MenubarSubmenu.tsx similarity index 56% rename from client/components/Menubar/MenubarSubmenu.jsx rename to client/components/Menubar/MenubarSubmenu.tsx index 38683f2310..d9c3702a5f 100644 --- a/client/components/Menubar/MenubarSubmenu.jsx +++ b/client/components/Menubar/MenubarSubmenu.tsx @@ -1,7 +1,6 @@ // https://blog.logrocket.com/building-accessible-menubar-component-react import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React, { useState, useEffect, @@ -18,7 +17,23 @@ import { } from './contexts'; import TriangleIcon from '../../images/down-filled-triangle.svg'; -export function useMenuProps(id) { +/** Custom subset of valid roles for the Menubar container */ +export enum MenuContainerRole { + MENU = 'menu', + LISTBOX = 'listbox' +} + +/** Custom subset of valid roles for the Menubar items */ +export enum MenubarListItemRole { + MENUITEM = 'menuitem', + OPTION = 'option' +} + +/* ------------------------------------------------------------------------------------------------- + * useMenuProps hook + * -----------------------------------------------------------------------------------------------*/ + +export function useMenuProps(id: string) { const activeMenu = useContext(MenuOpenContext); const isOpen = id === activeMenu; @@ -37,15 +52,27 @@ export function useMenuProps(id) { * MenubarTrigger * -----------------------------------------------------------------------------------------------*/ +/** Custom subset of valid values for aria-hasPopup for the MenubarTrigger */ +enum MenubarTriggerAriaHasPopup { + MENU = MenuContainerRole.MENU, + LISTBOX = MenuContainerRole.LISTBOX +} + +interface MenubarTriggerProps + extends Omit< + React.ComponentProps<'button'>, + 'aria-haspopup' | 'aria-expanded' | 'onMouseEnter' | 'onKeyDown' | 'role' + > { + /** The ARIA role of the trigger button */ + role?: MenubarListItemRole; + /** The ARIA property that indicates the presence of a popup */ + hasPopup?: MenubarTriggerAriaHasPopup; +} + /** * MenubarTrigger renders a button that toggles a submenu. It handles keyboard navigation and supports * screen readers. It needs to be within a submenu context. * - * @param {Object} props - * @param {string} [props.role='menuitem'] - The ARIA role of the trigger button - * @param {string} [props.hasPopup='menu'] - The ARIA property that indicates the presence of a popup - * @returns {JSX.Element} - * * @example *
  • */ - -const MenubarTrigger = React.forwardRef(({ role, hasPopup, ...props }, ref) => { - const { - setActiveIndex, - menuItems, - registerTopLevelItem, - hasFocus - } = useContext(MenubarContext); - const { id, title, first, last } = useContext(SubmenuContext); - const { isOpen, handlers } = useMenuProps(id); - - const handleMouseEnter = () => { - if (hasFocus) { - const items = Array.from(menuItems); - const index = items.findIndex((item) => item === ref.current); - - if (index !== -1) { - setActiveIndex(index); - } - } - }; - - const handleKeyDown = (e) => { - switch (e.key) { - case 'ArrowDown': - if (!isOpen) { - e.preventDefault(); - e.stopPropagation(); - first(); - } - break; - case 'ArrowUp': - if (!isOpen) { - e.preventDefault(); - e.stopPropagation(); - last(); - } - break; - case 'Enter': - case ' ': - if (!isOpen) { - e.preventDefault(); - e.stopPropagation(); - first(); +const MenubarTrigger = React.forwardRef( + ( + { + role = MenubarListItemRole.MENUITEM, + hasPopup = MenubarTriggerAriaHasPopup.MENU, + ...props + }, + ref + ) => { + const { + setActiveIndex, + menuItems, + registerTopLevelItem, + hasFocus + } = useContext(MenubarContext); + const { id, title, first, last } = useContext(SubmenuContext); + const { isOpen, handlers } = useMenuProps(id); + + // `ref` is always a button from MenubarSubmenu, so safe to cast. + const buttonRef = ref as React.RefObject; + + const handleMouseEnter = (e: React.MouseEvent) => { + if (hasFocus) { + const items = Array.from(menuItems); + const index = items.findIndex((item) => item === buttonRef.current); + + if (index !== -1) { + setActiveIndex(index); } - break; - default: - break; - } - }; - - useEffect(() => { - const unregister = registerTopLevelItem(ref, id); - return unregister; - }, [menuItems, registerTopLevelItem]); - - return ( - - ); -}); + } + }; -MenubarTrigger.propTypes = { - role: PropTypes.string, - hasPopup: PropTypes.oneOf(['menu', 'listbox', 'true']) -}; + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + first(); + } + break; + case 'ArrowUp': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + last(); + } + break; + case 'Enter': + case ' ': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + first(); + } + break; + default: + break; + } + }; -MenubarTrigger.defaultProps = { - role: 'menuitem', - hasPopup: 'menu' -}; + useEffect(() => { + const unregister = registerTopLevelItem(ref, id); + return unregister; + }, [menuItems, registerTopLevelItem]); + + return ( + + ); + } +); /* ------------------------------------------------------------------------------------------------- * MenubarList * -----------------------------------------------------------------------------------------------*/ +interface MenubarListProps { + /** MenubarItems that should be rendered in the list */ + children?: React.ReactNode; + /** The ARIA role of the list element */ + role?: MenuContainerRole; +} + /** * MenubarList renders the container for menu items in a submenu. It provides context and handles ARIA roles. - * - * @param {Object} props - * @param {React.ReactNode} props.children - MenubarItems that should be rendered in the list - * @param {string} [props.role='menu'] - The ARIA role of the list element - * @returns {JSX.Element} - * * @example * * ... elements * */ - -function MenubarList({ children, role, ...props }) { +function MenubarList({ + children, + role = MenuContainerRole.MENU, + ...props +}: MenubarListProps) { const { id, title } = useContext(SubmenuContext); return ( @@ -184,33 +216,37 @@ function MenubarList({ children, role, ...props }) { ); } -MenubarList.propTypes = { - children: PropTypes.node, - role: PropTypes.oneOf(['menu', 'listbox']) -}; - -MenubarList.defaultProps = { - children: null, - role: 'menu' -}; - /* ------------------------------------------------------------------------------------------------- * MenubarSubmenu * -----------------------------------------------------------------------------------------------*/ +/** + * Safely casts a value to an HTMLElement. + * + * @param {unknown | null} node - The value to check. + * @returns {HTMLElement | null} The node if it is an HTMLElement, otherwise null. + */ +function getHTMLElement(node: unknown | null): HTMLElement | null { + return node instanceof HTMLElement ? node : null; +} + +export interface MenubarSubmenuProps { + /** The unique id of the submenu */ + id: string; + /** A list of menu items that will be rendered in the menubar */ + children?: React.ReactNode; + /** The title of the submenu */ + title: string; + /** The ARIA role of the trigger button */ + triggerRole?: MenubarListItemRole; + /** The ARIA role of the list element */ + listRole?: MenuContainerRole; +} /** * MenubarSubmenu manages a triggerable submenu within a menubar. It is a compound component * that manages the state of the submenu and its items. It also provides keyboard navigation * and screen reader support. Supports menu and listbox roles. Needs to be a direct child of Menubar. * - * @param {Object} props - * @param {React.ReactNode} props.children - A list of menu items that will be rendered in the menubar - * @param {string} props.id - The unique id of the submenu - * @param {string} props.title - The title of the submenu - * @param {string} [props.triggerRole='menuitem'] - The ARIA role of the trigger button - * @param {string} [props.listRole='menu'] - The ARIA role of the list element - * @returns {JSX.Element} - * * @example * * @@ -219,26 +255,26 @@ MenubarList.defaultProps = { * * */ - -function MenubarSubmenu({ +export function MenubarSubmenu({ children, id, title, - triggerRole: customTriggerRole, - listRole: customListRole, + triggerRole = MenubarListItemRole.MENUITEM, + listRole = MenuContainerRole.MENU, ...props -}) { +}: MenubarSubmenuProps) { const { isOpen, handlers } = useMenuProps(id); const [submenuActiveIndex, setSubmenuActiveIndex] = useState(0); const { setMenuOpen, toggleMenuOpen } = useContext(MenubarContext); - const submenuItems = useRef(new Set()).current; + const submenuItems = useRef>(new Set()).current; - const buttonRef = useRef(null); - const listItemRef = useRef(null); + const buttonRef = useRef(null); + const listItemRef = useRef(null); - const triggerRole = customTriggerRole || 'menuitem'; - const listRole = customListRole || 'menu'; - const hasPopup = listRole === 'listbox' ? 'listbox' : 'menu'; + const hasPopup = + listRole === MenuContainerRole.LISTBOX + ? MenubarTriggerAriaHasPopup.LISTBOX + : MenubarTriggerAriaHasPopup.MENU; const prev = useCallback(() => { const newIndex = @@ -272,23 +308,22 @@ function MenubarSubmenu({ const items = Array.from(submenuItems); const activeItem = items[submenuActiveIndex]; - if (activeItem) { - const activeItemNode = activeItem.firstChild; + if (!activeItem) return; - const isDisabled = - activeItemNode.getAttribute('aria-disabled') === 'true'; + const activeItemNode = getHTMLElement(activeItem.firstChild); - if (isDisabled) { - return; - } + if (!activeItemNode) return; - activeItemNode.click(); + const isDisabled = activeItemNode.getAttribute('aria-disabled') === 'true'; - toggleMenuOpen(id); + if (isDisabled) return; - if (buttonRef.current) { - buttonRef.current.focus(); - } + activeItemNode.click(); + + toggleMenuOpen(id); + + if (buttonRef.current) { + buttonRef.current.focus(); } }, [submenuActiveIndex, submenuItems, buttonRef]); @@ -315,7 +350,7 @@ function MenubarSubmenu({ [submenuItems] ); - const keyHandlers = { + const keyHandlers: Record void> = { ArrowUp: (e) => { if (!isOpen) return; e.preventDefault(); @@ -388,14 +423,13 @@ function MenubarSubmenu({ if (isOpen && submenuItems.size > 0) { const items = Array.from(submenuItems); const activeItem = items[submenuActiveIndex]; + if (!activeItem) return; - if (activeItem) { - const activeNode = activeItem.querySelector( - '[role="menuitem"], [role="option"]' - ); - if (activeNode) { - activeNode.focus(); - } + const activeNode = getHTMLElement( + activeItem.querySelector('[role="menuitem"], [role="option"]') + ); + if (activeNode) { + activeNode.focus(); } } }, [isOpen, submenuItems, submenuActiveIndex]); @@ -442,19 +476,3 @@ function MenubarSubmenu({ ); } - -MenubarSubmenu.propTypes = { - id: PropTypes.string.isRequired, - children: PropTypes.node, - title: PropTypes.node.isRequired, - triggerRole: PropTypes.string, - listRole: PropTypes.string -}; - -MenubarSubmenu.defaultProps = { - children: null, - triggerRole: 'menuitem', - listRole: 'menu' -}; - -export default MenubarSubmenu; diff --git a/client/components/Menubar/contexts.jsx b/client/components/Menubar/contexts.jsx deleted file mode 100644 index 18cad6c36b..0000000000 --- a/client/components/Menubar/contexts.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext } from 'react'; - -export const ParentMenuContext = createContext('none'); - -export const MenuOpenContext = createContext('none'); - -export const MenubarContext = createContext({ - createMenuHandlers: () => ({}), - createMenuItemHandlers: () => ({}), - toggleMenuOpen: () => {} -}); - -export const SubmenuContext = createContext({}); diff --git a/client/components/Menubar/contexts.tsx b/client/components/Menubar/contexts.tsx new file mode 100644 index 0000000000..7580b257a4 --- /dev/null +++ b/client/components/Menubar/contexts.tsx @@ -0,0 +1,63 @@ +import React, { createContext, RefObject } from 'react'; + +export const ParentMenuContext = createContext('none'); + +export const MenuOpenContext = createContext('none'); + +interface MenubarContextType { + createMenuHandlers: ( + id: string + ) => Partial<{ + onMouseOver: (e: React.MouseEvent) => void; + onClick: (e: React.MouseEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onFocus: (e: React.FocusEvent) => void; + }>; + createMenuItemHandlers: ( + id: string + ) => Partial<{ + onMouseUp: (e: React.MouseEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onFocus: (e: React.FocusEvent) => void; + }>; + toggleMenuOpen: (id: string) => void; + setActiveIndex: (idx: number) => void; + registerTopLevelItem: ( + ref: React.ForwardedRef, + id: string + ) => void; + setMenuOpen: (id: string) => void; + menuItems: Set; + hasFocus: boolean; +} + +export const MenubarContext = createContext({ + createMenuHandlers: () => ({}), + createMenuItemHandlers: () => ({}), + toggleMenuOpen: () => {}, + setActiveIndex: () => {}, + registerTopLevelItem: () => {}, + setMenuOpen: () => {}, + menuItems: new Set(), + hasFocus: false +}); + +export interface SubmenuContextType { + setSubmenuActiveIndex: (index: number) => void; + registerSubmenuItem: (ref: RefObject) => () => void; + first: () => void; + last: () => void; + submenuItems: Set; + id: string; + title: string; +} + +export const SubmenuContext = createContext({ + setSubmenuActiveIndex: () => {}, + registerSubmenuItem: () => () => {}, + first: () => {}, + last: () => {}, + submenuItems: new Set(), + id: '', + title: '' +}); diff --git a/client/components/PreviewNav.test.tsx b/client/components/PreviewNav.test.tsx new file mode 100644 index 0000000000..f5566cfa8c --- /dev/null +++ b/client/components/PreviewNav.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, screen } from '../test-utils'; +import { PreviewNav } from './PreviewNav'; + +describe('PreviewNav', () => { + const owner = { username: 'alice' }; + const project = { id: '123', name: 'My Project' }; + + test('renders with correct links and icons using provided data-testid attributes', () => { + render(); + + // Logo link to /alice/sketches + const iconLinkUserSketches = screen.getByTestId('icon-link_user-sketches'); + expect(iconLinkUserSketches).toHaveAttribute( + 'href', + `/${owner.username}/sketches` + ); + + // p5js logo icon presence + const iconP5Logo = screen.getByTestId('icon_p5-logo'); + expect(iconP5Logo).toBeInTheDocument(); + + // Current project link to /alice/sketches/123 + const linkCurrentProject = screen.getByTestId('link_current-project'); + expect(linkCurrentProject).toHaveAttribute( + 'href', + `/${owner.username}/sketches/${project.id}` + ); + expect(linkCurrentProject).toHaveTextContent(project.name); + + // Owner username link to /alice/sketches + const linkUserSketches = screen.getByTestId('link_user-sketches'); + expect(linkUserSketches).toHaveAttribute( + 'href', + `/${owner.username}/sketches` + ); + expect(linkUserSketches).toHaveTextContent(owner.username); + + // Edit project code link to /alice/sketches/123 + const linkProjectCode = screen.getByTestId('link_project-code'); + expect(linkProjectCode).toHaveAttribute( + 'href', + `/${owner.username}/sketches/${project.id}` + ); + + // Code icon presence + const iconCode = screen.getByTestId('icon_code'); + expect(iconCode).toBeInTheDocument(); + + // Check nav container presence + const nav = screen.getByTestId('preview-nav'); + expect(nav).toBeInTheDocument(); + }); +}); diff --git a/client/components/PreviewNav.jsx b/client/components/PreviewNav.tsx similarity index 55% rename from client/components/PreviewNav.jsx rename to client/components/PreviewNav.tsx index f66476d92b..65f429db49 100644 --- a/client/components/PreviewNav.jsx +++ b/client/components/PreviewNav.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -6,56 +5,60 @@ import { useTranslation } from 'react-i18next'; import LogoIcon from '../images/p5js-logo-small.svg'; import CodeIcon from '../images/code.svg'; -const PreviewNav = ({ owner, project }) => { +interface PreviewNavProps { + owner: { username: string }; + project: { name: string; id: string }; +} + +export const PreviewNav = ({ owner, project }: PreviewNavProps) => { const { t } = useTranslation(); return ( -
  • @@ -757,6 +782,22 @@ exports[`Nav renders editor version for mobile 1`] = ` margin: 0; } +.c2 > section { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + gap: 5px; +} + .c2 > h5 { font-size: 1.0833333333333333rem; font-weight: normal; @@ -930,7 +971,7 @@ exports[`Nav renders editor version for mobile 1`] = ` >