From 9cf4847a3446638d674d1ca6b4f87182067130f2 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 5 Apr 2024 17:21:02 +1000 Subject: [PATCH] Keyboard navigation between components (#9499) - Close https://github.com/enso-org/cloud-v2/issues/982 - Add keyboard navigation via arrows between different components - This is achieved by a `Navigator2D` class which keeps track of the closest adjacent elements. Other changes: - Switch much of the codebase to use `react-aria-components` - This *should* (but does not necessarily) give us improved accessibility for free. - Refactor various common styles into styled components - `FocusArea` to perform automatic registration with `Navigator2D` - `Button` and `UnstyledButton` to let buttons participate in keyboard navigation - `HorizontalMenuBar` - used for buttons below the titles in the Drive page, Keyboard Shortcuts settings page, and Members List settings page - `SettingsPage` in the settings pages - `SettingsSection` in the settings page to wrap around `FocusArea` and the heading for each section - Add debugging utilities - Add debugging when `body` has the `data-debug` attribute: `document.body.dataset.debug = ''` - This adds rings around elements (all with different colors): - That are `FocusArea`s. `FocusArea` is a wrapper component that makes an element participate in `Navigator2D`. - That are `:focus`ed, and that are `:focus-visible` - That are `.focus-child`. This is because keyboard navigation via arrows ***ignores*** all focusable elements that are not `.focus-child`. - Debug `Navigator2D` neighbors when `body` has the `debug-navigator2d` attribute: `document.body.dataset.debugNavigator2d = ''` - This highlights neighbors of the currently focused element. This is a separate debug option because computing neighbors is potentially quite expensive. # Important Notes - :warning: Modals and the authentication flow are not yet fully tested. - Up+Down to navigate through suggestions has been disabled to improve UX when accidentally navigating upwards to the assets search bar. - There are a number of *known* issues with keyboard navigation. For the most part it's because a proper solution will be quite difficult. - Focus is lost when a column (from the extra columns selector) is toggled - because the button stops existing - It's not possible to navigate to the icons on the assets table - so it's current not possible to *hide* columns via the keyboard - Neighbors of the extra columns selector are not ideal (both when it is being navigated from, and when it is being navigated to) - The suggestions in the `AssetSearchBar` aren't *quite* fully integrated with arrow keyboard navigation. - This is *semi*-intentional. I think it makes a lot more sense to integrate them in, *however* it stays like this for now largely because I think pressing `ArrowUp` then `ArrowDown` from the assets table should return to the assets table - Likewise for the assets table. The reason here, however, is because we want multi-select. While `react-aria-components` has lists which support multi-select, it doesn't allow programmatic focus control, making it not particularly ideal, as we want to focus the topmost element when navigating in from above. - Clicking on the "New Folder" icon (and the like) do not focus on the newly created child. This one should be pretty easy to do, but I'm not sure whether it's the right thing to do. --- app/ide-desktop/.vscode/settings.json | 8 +- app/ide-desktop/eslint.config.js | 25 +- app/ide-desktop/lib/dashboard/e2e/actions.ts | 34 +- .../lib/dashboard/e2e/assetSearchBar.spec.ts | 6 +- .../lib/dashboard/e2e/copy.spec.ts | 6 +- app/ide-desktop/lib/dashboard/favicon.ico | Bin 0 -> 16898 bytes app/ide-desktop/lib/dashboard/package.json | 19 +- app/ide-desktop/lib/dashboard/src/App.tsx | 10 + .../AriaComponents/Button/Button.tsx | 75 ++- .../AriaComponents/Dialog/Dialog.tsx | 18 +- .../AriaComponents/Dialog/DialogTrigger.tsx | 23 +- .../components/AriaComponents/Dialog/types.ts | 29 +- .../AriaComponents/Tooltip/Tooltip.tsx | 24 +- .../dashboard/src/components/Autocomplete.tsx | 87 +-- .../lib/dashboard/src/components/Button.tsx | 45 -- .../dashboard/src/components/ColorPicker.tsx | 93 ++- .../dashboard/src/components/ContextMenu.tsx | 35 +- .../src/components/ContextMenuSeparator.tsx | 21 - .../src/components/ControlledInput.tsx | 121 ++-- .../dashboard/src/components/DateInput.tsx | 173 +++--- .../lib/dashboard/src/components/Dropdown.tsx | 299 +++++----- .../dashboard/src/components/EditableSpan.tsx | 81 +-- .../src/components/JSONSchemaInput.tsx | 225 ++++---- .../lib/dashboard/src/components/Link.tsx | 25 +- .../dashboard/src/components/MenuEntry.tsx | 50 +- .../lib/dashboard/src/components/Modal.tsx | 53 +- .../lib/dashboard/src/components/Root.tsx | 24 +- .../src/components/SelectionBrush.tsx | 4 +- .../dashboard/src/components/SubmitButton.tsx | 18 +- .../lib/dashboard/src/components/SvgMask.tsx | 10 +- .../lib/dashboard/src/components/TextLink.tsx | 37 ++ .../src/components/UnstyledButton.tsx | 47 ++ .../lib/dashboard/src/components/aria.tsx | 19 + .../src/components/dashboard/AssetInfoBar.tsx | 50 +- .../src/components/dashboard/AssetRow.tsx | 343 +++++------ .../src/components/dashboard/AssetSummary.tsx | 11 +- .../dashboard/DirectoryNameColumn.tsx | 1 + .../components/dashboard/FileNameColumn.tsx | 1 + .../components/dashboard/KeyboardShortcut.tsx | 21 +- .../src/components/dashboard/Label.tsx | 85 +-- .../dashboard/PermissionDisplay.tsx | 27 +- .../dashboard/PermissionSelector.tsx | 201 +++---- .../dashboard/PermissionTypeSelector.tsx | 108 ++-- .../src/components/dashboard/ProjectIcon.tsx | 45 +- .../dashboard/ProjectNameColumn.tsx | 1 + .../components/dashboard/SecretNameColumn.tsx | 5 +- .../components/dashboard/UserPermission.tsx | 41 +- .../dashboard/column/LabelsColumn.tsx | 35 +- .../dashboard/column/SharedWithColumn.tsx | 20 +- .../AccessedByProjectsColumnHeading.tsx | 3 +- .../AccessedDataColumnHeading.tsx | 3 +- .../columnHeading/DocsColumnHeading.tsx | 3 +- .../columnHeading/LabelsColumnHeading.tsx | 3 +- .../columnHeading/ModifiedColumnHeading.tsx | 13 +- .../columnHeading/NameColumnHeading.tsx | 13 +- .../columnHeading/SharedWithColumnHeading.tsx | 3 +- .../src/components/styled/Button.tsx | 70 +++ .../src/components/styled/ButtonRow.tsx | 34 ++ .../src/components/styled/Checkbox.tsx | 33 ++ .../src/components/styled/FocusArea.tsx | 123 ++++ .../src/components/styled/FocusRing.tsx | 39 ++ .../src/components/styled/FocusRoot.tsx | 80 +++ .../components/styled/HorizontalMenuBar.tsx | 26 + .../dashboard/src/components/styled/Input.tsx | 33 ++ .../src/components/styled/RadioGroup.tsx | 173 ++++++ .../src/components/styled/Separator.tsx | 24 + .../components/styled/SidebarTabButton.tsx | 41 ++ .../styled/settings/SettingsInput.tsx | 103 ++++ .../styled/settings/SettingsPage.tsx | 16 + .../styled/settings/SettingsSection.tsx | 43 ++ .../src/components/styled/withFocusScope.tsx | 28 + .../lib/dashboard/src/hooks/focusHooks.ts | 93 +++ .../lib/dashboard/src/hooks/scrollHooks.ts | 43 ++ .../lib/dashboard/src/hooks/setAssetHooks.ts | 10 +- .../src/layouts/AssetContextMenu.tsx | 36 +- .../lib/dashboard/src/layouts/AssetPanel.tsx | 12 +- .../dashboard/src/layouts/AssetProperties.tsx | 70 ++- .../dashboard/src/layouts/AssetSearchBar.tsx | 429 ++++++++------ .../dashboard/src/layouts/AssetVersion.tsx | 2 +- .../layouts/AssetVersions/AssetVersions.tsx | 2 +- .../lib/dashboard/src/layouts/AssetsTable.tsx | 531 ++++++++++-------- .../src/layouts/AssetsTableContextMenu.tsx | 6 +- .../dashboard/src/layouts/BackendSwitcher.tsx | 57 +- .../src/layouts/CategorySwitcher.tsx | 231 +++++--- .../lib/dashboard/src/layouts/Chat.tsx | 77 ++- .../dashboard/src/layouts/ChatPlaceholder.tsx | 18 +- .../lib/dashboard/src/layouts/Drive.tsx | 23 +- .../lib/dashboard/src/layouts/DriveBar.tsx | 72 +-- .../src/layouts/GlobalContextMenu.tsx | 15 +- .../lib/dashboard/src/layouts/Home.tsx | 12 +- .../lib/dashboard/src/layouts/Labels.tsx | 206 ++++--- .../dashboard/src/layouts/PageSwitcher.tsx | 98 ++-- .../lib/dashboard/src/layouts/Samples.tsx | 118 ++-- .../lib/dashboard/src/layouts/Settings.tsx | 8 +- .../layouts/Settings/AccountSettingsTab.tsx | 297 +--------- .../Settings/ActivityLogSettingsTab.tsx | 148 ++--- .../ChangePasswordSettingsSection.tsx | 121 ++++ .../DeleteUserAccountSettingsSection.tsx | 55 ++ .../Settings/KeyboardShortcutsSettingsTab.tsx | 183 +----- .../KeyboardShortcutsSettingsTabBar.tsx | 60 ++ .../Settings/KeyboardShortcutsTable.tsx | 175 ++++++ .../layouts/Settings/MembersSettingsTab.tsx | 33 +- .../Settings/MembersSettingsTabBar.tsx | 36 ++ ...anizationProfilePictureSettingsSection.tsx | 79 +++ .../Settings/OrganizationSettingsSection.tsx | 196 +++++++ .../Settings/OrganizationSettingsTab.tsx | 258 +-------- .../ProfilePictureSettingsSection.tsx | 68 +++ .../Settings/UserAccountSettingsSection.tsx | 63 +++ .../dashboard/src/layouts/SettingsSidebar.tsx | 97 ++-- .../lib/dashboard/src/layouts/TopBar.tsx | 51 +- .../lib/dashboard/src/layouts/UserBar.tsx | 154 ++--- .../lib/dashboard/src/layouts/UserMenu.tsx | 82 +-- .../lib/dashboard/src/layouts/WhatsNew.tsx | 125 +++-- .../modals/CaptureKeyboardShortcutModal.tsx | 28 +- .../src/modals/ConfirmDeleteModal.tsx | 30 +- .../src/modals/ConfirmDeleteUserModal.tsx | 29 +- .../src/modals/DuplicateAssetsModal.tsx | 81 ++- .../dashboard/src/modals/InviteUsersModal.tsx | 185 +++--- .../src/modals/ManageLabelsModal.tsx | 277 ++++----- .../src/modals/ManagePermissionsModal.tsx | 139 ++--- .../dashboard/src/modals/NewLabelModal.tsx | 119 ++-- .../src/modals/UpsertDataLinkModal.tsx | 76 ++- .../src/modals/UpsertSecretModal.tsx | 124 ++-- .../authentication/AuthenticationPage.tsx | 59 ++ .../pages/authentication/ForgotPassword.tsx | 52 +- .../pages/authentication/LoadingScreen.tsx | 3 +- .../src/pages/authentication/Login.tsx | 169 +++--- .../src/pages/authentication/Registration.tsx | 112 ++-- .../pages/authentication/ResetPassword.tsx | 122 ++-- .../src/pages/authentication/SetUsername.tsx | 52 +- .../src/pages/dashboard/Dashboard.tsx | 9 +- .../src/pages/subscribe/Subscribe.tsx | 11 +- .../src/providers/AreaFocusProvider.tsx | 35 ++ .../src/providers/FocusClassProvider.tsx | 48 ++ .../src/providers/FocusDirectionProvider.tsx | 44 ++ .../src/providers/InputBindingsProvider.tsx | 4 +- .../src/providers/LocalStorageProvider.tsx | 2 +- .../src/providers/Navigator2DProvider.tsx | 34 ++ .../lib/dashboard/src/services/Backend.ts | 5 +- .../lib/dashboard/src/tailwind.css | 45 +- .../lib/dashboard/src/text/english.json | 26 +- .../dashboard/src/utilities/Navigator2D.ts | 447 +++++++++++++++ .../lib/dashboard/src/utilities/event.ts | 66 ++- .../lib/dashboard/tailwind.config.js | 48 +- app/ide-desktop/lib/dashboard/vite.config.ts | 25 + package-lock.json | 3 + 146 files changed, 6692 insertions(+), 3913 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/favicon.ico delete mode 100644 app/ide-desktop/lib/dashboard/src/components/Button.tsx delete mode 100644 app/ide-desktop/lib/dashboard/src/components/ContextMenuSeparator.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/TextLink.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/UnstyledButton.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/aria.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/ButtonRow.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/Checkbox.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/FocusArea.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/FocusRing.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/FocusRoot.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/HorizontalMenuBar.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/Input.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/RadioGroup.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/Separator.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/SidebarTabButton.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/components/styled/withFocusScope.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordSettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/DeleteUserAccountSettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTabBar.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureSettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureSettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/layouts/Settings/UserAccountSettingsSection.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/pages/authentication/AuthenticationPage.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/providers/AreaFocusProvider.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/providers/FocusClassProvider.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/providers/FocusDirectionProvider.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/providers/Navigator2DProvider.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/utilities/Navigator2D.ts diff --git a/app/ide-desktop/.vscode/settings.json b/app/ide-desktop/.vscode/settings.json index fa83a420b29..b75f3c5502f 100644 --- a/app/ide-desktop/.vscode/settings.json +++ b/app/ide-desktop/.vscode/settings.json @@ -4,5 +4,11 @@ "javascript.preferences.importModuleSpecifierEnding": "minimal", "typescript.preferences.importModuleSpecifierEnding": "minimal", "javascript.updateImportsOnFileMove.enabled": "always", - "typescript.updateImportsOnFileMove.enabled": "always" + "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.preferences.autoImportFileExcludePatterns": [ + "react-aria", + "react-aria-components", + "@react-aria/*", + "@react-types/*" + ] } diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index e62527a83e4..3206111f579 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -123,7 +123,7 @@ const RESTRICTED_SYNTAXES = [ }, { // Matches non-functions. - selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches(:has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`, + selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches([init.callee.object.name=React][init.callee.property.name=forwardRef], :has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`, message: 'Use `CONSTANT_CASE` for top-level constants that are not functions', }, { @@ -229,6 +229,27 @@ const RESTRICTED_SYNTAXES = [ )`, message: 'Use a `getText()` from `useText` instead of a literal string', }, + { + selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier', + message: 'Use `Button` or `UnstyledButton` instead of `button`', + }, + { + selector: 'JSXOpeningElement[name.name=label] > JSXIdentifier', + message: 'Use `aria.Label` instead of `label`', + }, + { + selector: 'JSXOpeningElement[name.name=input] > JSXIdentifier', + message: 'Use `aria.Input` instead of `input`', + }, + { + selector: 'JSXOpeningElement[name.name=span] > JSXIdentifier', + message: 'Use `aria.Text` instead of `span`', + }, + { + selector: 'JSXOpeningElement[name.name=/^h[123456]$/] > JSXIdentifier', + message: 'Use `aria.Heading` instead of `h1`-`h6`', + }, + // We may want to consider also preferring `aria.Form` in favor of `form` in the future. ] // ============================ @@ -273,6 +294,8 @@ export default [ ...tsEslint.configs.strict?.rules, ...react.configs['jsx-runtime'].rules, eqeqeq: ['error', 'always', { null: 'never' }], + // Any extra semicolons that exist, are required by Prettier. + 'no-extra-semi': 'off', 'jsdoc/require-jsdoc': [ 'error', { diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index 07f000f1938..16c207078a7 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -66,7 +66,7 @@ export function locateNewLabelModalColorButtons(page: test.Page) { locateNewLabelModal(page) .filter({ has: page.getByText('Color') }) // The `radio` inputs are invisible, so they cannot be used in the locator. - .getByRole('button') + .locator('label[data-rac]') ) } @@ -229,17 +229,17 @@ export function locateDocsColumnToggle(page: test.Locator | test.Page) { /** Find a button for the "Recent" category (if any) on the current page. */ export function locateRecentCategory(page: test.Locator | test.Page) { - return page.getByTitle('Go To Recent') + return page.getByLabel('Go To Recent category') } /** Find a button for the "Home" category (if any) on the current page. */ export function locateHomeCategory(page: test.Locator | test.Page) { - return page.getByTitle('Go To Home') + return page.getByLabel('Go To Home category') } /** Find a button for the "Trash" category (if any) on the current page. */ export function locateTrashCategory(page: test.Locator | test.Page) { - return page.getByTitle('Go To Trash') + return page.getByLabel('Go To Trash category') } // === Context menu buttons === @@ -443,14 +443,14 @@ export function locateSettingsPageIcon(page: test.Locator | test.Page) { /** Find a "name" column heading (if any) on the current page. */ export function locateNameColumnHeading(page: test.Locator | test.Page) { - return page.getByTitle('Sort by name').or(page.getByTitle('Stop sorting by name')) + return page.getByLabel('Sort by name').or(page.getByLabel('Stop sorting by name')) } /** Find a "modified" column heading (if any) on the current page. */ export function locateModifiedColumnHeading(page: test.Locator | test.Page) { return page - .getByTitle('Sort by modification date') - .or(page.getByTitle('Stop sorting by modification date')) + .getByLabel('Sort by modification date') + .or(page.getByLabel('Stop sorting by modification date')) } // === Container locators === @@ -670,10 +670,28 @@ export async function expectTrashPlaceholderRow(page: test.Page) { // === Mouse utilities === // ======================= +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } + /** Click an asset row. The center must not be clicked as that is the button for adding a label. */ export async function clickAssetRow(assetRow: test.Locator) { // eslint-disable-next-line @typescript-eslint/no-magic-numbers - await assetRow.click({ position: { x: 300, y: 16 } }) + await assetRow.click({ position: ASSET_ROW_SAFE_POSITION }) +} + +/** Drag an asset row. The center must not be clicked as that is the button for adding a label. */ +export async function dragAssetRowToAssetRow(from: test.Locator, to: test.Locator) { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + await from.dragTo(to, { + sourcePosition: ASSET_ROW_SAFE_POSITION, + targetPosition: ASSET_ROW_SAFE_POSITION, + }) +} + +/** Drag an asset row. The center must not be clicked as that is the button for adding a label. */ +export async function dragAssetRow(from: test.Locator, to: test.Locator) { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + await from.dragTo(to, { sourcePosition: ASSET_ROW_SAFE_POSITION }) } // ========================== diff --git a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts index f14aeba39a6..bb781ba557d 100644 --- a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts @@ -92,7 +92,7 @@ test.test('suggestions (keyboard)', async ({ page }) => { for (const suggestion of await suggestions.all()) { const name = (await suggestion.textContent()) ?? '' test.expect(name.length).toBeGreaterThan(0) - await page.press('body', 'Tab') + await page.press('body', 'ArrowDown') await test.expect(searchBarInput).toHaveValue('name:' + name) } }) @@ -108,11 +108,11 @@ test.test('complex flows', async ({ page }) => { await actions.login({ page }) await searchBarInput.click() - await page.press('body', 'Tab') + await page.press('body', 'ArrowDown') await test.expect(searchBarInput).toHaveValue('name:' + firstName) await searchBarInput.selectText() await searchBarInput.press('Backspace') await test.expect(searchBarInput).toHaveValue('') - await page.press('body', 'Tab') + await page.press('body', 'ArrowDown') await test.expect(searchBarInput).toHaveValue('name:' + firstName) }) diff --git a/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts b/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts index 8424a824253..e53bea67000 100644 --- a/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts @@ -81,7 +81,7 @@ test.test('move (drag)', async ({ page }) => { // Assets: [0: Folder 1] await actions.locateNewFolderIcon(page).click() // Assets: [0: Folder 2, 1: Folder 1] - await assetRows.nth(0).dragTo(assetRows.nth(1)) + await actions.dragAssetRowToAssetRow(assetRows.nth(0), assetRows.nth(1)) // Assets: [0: Folder 1, 1: Folder 2 ] await test.expect(assetRows).toHaveCount(2) await test.expect(assetRows.nth(1)).toBeVisible() @@ -99,8 +99,10 @@ test.test('move to trash', async ({ page }) => { await page.keyboard.down(await actions.modModifier(page)) await actions.clickAssetRow(assetRows.nth(0)) await actions.clickAssetRow(assetRows.nth(1)) - await assetRows.nth(0).dragTo(actions.locateTrashCategory(page)) + // NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still + // held. await page.keyboard.up(await actions.modModifier(page)) + await actions.dragAssetRow(assetRows.nth(0), actions.locateTrashCategory(page)) await actions.expectPlaceholderRow(page) await actions.locateTrashCategory(page).click() await test.expect(assetRows).toHaveCount(2) diff --git a/app/ide-desktop/lib/dashboard/favicon.ico b/app/ide-desktop/lib/dashboard/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8ca9e02d62b4fe1eb4b5a8643423092f92f6082c GIT binary patch literal 16898 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 { + const onKeyDown = navigator2D.onKeyDown.bind(navigator2D) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('keydown', onKeyDown) + } + }, [navigator2D]) + React.useEffect(() => { let isClick = false const onMouseDown = () => { diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx index c3dbfcce101..58fceccfa2e 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -1,29 +1,27 @@ -/** - * @file Button.tsx - * - * Button component - */ +/** @file A styled button. */ import * as React from 'react' import clsx from 'clsx' -import * as reactAriaComponents from 'react-aria-components' import * as tailwindMerge from 'tailwind-merge' +import * as focusHooks from '#/hooks/focusHooks' + +import * as aria from '#/components/aria' import Spinner, * as spinnerModule from '#/components/Spinner' import SvgMask from '#/components/SvgMask' -/** - * Props for the Button component - */ -export interface ButtonProps extends reactAriaComponents.ButtonProps { +// ============== +// === Button === +// ============== + +/** Props for a {@link Button}. */ +export interface ButtonProps extends Readonly { readonly loading?: boolean readonly variant: 'cancel' | 'delete' | 'icon' | 'submit' readonly icon?: string - /** - * FIXME: This is not yet implemented + /** FIXME: This is not yet implemented * The position of the icon in the button - * @default 'start' - */ + * @default 'start' */ readonly iconPosition?: 'end' | 'start' } @@ -39,18 +37,24 @@ const EXTRA_CLICK_ZONE_CLASSES = 'flex relative before:inset-[-12px] before:abso const DISABLED_CLASSES = 'disabled:opacity-50 disabled:cursor-not-allowed' const SIZE_CLASSES = 'px-2 py-1' -/** - * A button allows a user to perform an action, with mouse, touch, and keyboard interactions. - */ -export function Button(props: ButtonProps): React.JSX.Element { +const CLASSES_FOR_VARIANT: Record = { + cancel: CANCEL_CLASSES, + delete: DELETE_CLASSES, + icon: ICON_CLASSES, + submit: SUBMIT_CLASSES, +} + +/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ +export function Button(props: ButtonProps) { const { className, children, variant, icon, loading = false, ...ariaButtonProps } = props + const focusChildProps = focusHooks.useFocusChild() const classes = clsx( DEFAULT_CLASSES, DISABLED_CLASSES, FOCUS_CLASSES, SIZE_CLASSES, - VARIANT_TO_CLASSES[variant] + CLASSES_FOR_VARIANT[variant] ) const childrenFactory = (): React.ReactNode => { @@ -58,11 +62,9 @@ export function Button(props: ButtonProps): React.JSX.Element { return } else if (variant === 'icon' && icon != null) { return ( - <> -
- -
- +
+ +
) } else { return <>{children} @@ -70,23 +72,16 @@ export function Button(props: ButtonProps): React.JSX.Element { } return ( - - tailwindMerge.twMerge( - classes, - typeof className === 'function' ? className(values) : className - ) - } - {...ariaButtonProps} + ()(ariaButtonProps, focusChildProps, { + className: values => + tailwindMerge.twMerge( + classes, + typeof className === 'function' ? className(values) : className + ), + })} > {childrenFactory()} - + ) } - -const VARIANT_TO_CLASSES: Record = { - cancel: CANCEL_CLASSES, - delete: DELETE_CLASSES, - icon: ICON_CLASSES, - submit: SUBMIT_CLASSES, -} diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx index ce72bc1d116..982f74f7e2f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx @@ -5,11 +5,11 @@ */ import * as React from 'react' -import * as reactAriaComponents from 'react-aria-components' import * as tailwindMerge from 'tailwind-merge' import Dismiss from 'enso-assets/dismiss.svg' +import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' import * as portal from '#/components/Portal' @@ -50,21 +50,23 @@ export function Dialog(props: types.DialogProps) { const root = portal.useStrictPortalContext() return ( - - {opts => ( <> {typeof title === 'string' && ( - -

{title}

+ + + {title} + -
+ )}
@@ -80,7 +82,7 @@ export function Dialog(props: types.DialogProps) {
)} -
-
+ + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx index ebf20dfb9c7..341437677dc 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx @@ -1,21 +1,15 @@ -/** - * @file - * - * A DialogTrigger opens a dialog when a trigger element is pressed. - */ +/** @file A DialogTrigger opens a dialog when a trigger element is pressed. */ import * as React from 'react' -import * as reactAriaComponents from 'react-aria-components' - import * as modalProvider from '#/providers/ModalProvider' +import * as aria from '#/components/aria' + import type * as types from './types' const PLACEHOLDER =
-/** - * A DialogTrigger opens a dialog when a trigger element is pressed. - */ +/** A DialogTrigger opens a dialog when a trigger element is pressed. */ export function DialogTrigger(props: types.DialogTriggerProps) { const { children, onOpenChange, ...triggerProps } = props @@ -24,7 +18,8 @@ export function DialogTrigger(props: types.DialogTriggerProps) { const onOpenChangeInternal = React.useCallback( (isOpened: boolean) => { if (isOpened) { - // we're using a placeholder here just to let the rest of the code know that the modal is open + // We're using a placeholder here just to let the rest of the code know that the modal + // is open. setModal(PLACEHOLDER) } else { unsetModal() @@ -36,10 +31,6 @@ export function DialogTrigger(props: types.DialogTriggerProps) { ) return ( - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts index 7c60eb33a7c..9d48357f9c9 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts @@ -1,22 +1,13 @@ -/** - * @file - * Contains the types for the Dialog component. - */ -import type * as reactAriaComponents from 'react-aria-components' +/** @file Types for the Dialog component. */ +import type * as aria from '#/components/aria' -/** - * - */ +/** The type of Dialog. */ export type DialogType = 'fullscreen' | 'modal' | 'popover' -/** - * The props for the Dialog component. - */ -export interface DialogProps extends reactAriaComponents.DialogProps { - /** - * The type of dialog to render. - * @default 'modal' - */ +/** Props for the Dialog component. */ +export interface DialogProps extends aria.DialogProps { + /** The type of dialog to render. + * @default 'modal' */ readonly type?: DialogType readonly title?: string readonly isDismissible?: boolean @@ -24,7 +15,5 @@ export interface DialogProps extends reactAriaComponents.DialogProps { readonly isKeyboardDismissDisabled?: boolean } -/** - * The props for the DialogTrigger component. - */ -export interface DialogTriggerProps extends reactAriaComponents.DialogTriggerProps {} +/** The props for the DialogTrigger component. */ +export interface DialogTriggerProps extends aria.DialogTriggerProps {} diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx index c1936e8a92b..dd8e043a778 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx @@ -1,27 +1,19 @@ -/** - * @file - * - * A tooltip displays a description of an element on hover or focus. - */ -import * as reactAriaComponents from 'react-aria-components' +/** @file Displays the description of an element on hover or focus. */ import * as tailwindMerge from 'tailwind-merge' +import * as aria from '#/components/aria' import * as portal from '#/components/Portal' -/** - * - */ +/** Props for a {@link Tooltip}. */ export interface TooltipProps - extends Omit {} + extends Omit, 'offset' | 'UNSTABLE_portalContainer'> {} const DEFAULT_CLASSES = 'z-1 flex bg-neutral-800 text-white p-2 rounded-md shadow-lg text-xs' const DEFAULT_CONTAINER_PADDING = 4 const DEFAULT_OFFSET = 4 -/** - * A tooltip displays a description of an element on hover or focus. - */ +/** Displays the description of an element on hover or focus. */ export function Tooltip(props: TooltipProps) { const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props @@ -30,7 +22,7 @@ export function Tooltip(props: TooltipProps) { const classes = tailwindMerge.twJoin(DEFAULT_CLASSES) return ( - (props: AutocompleteProps) { return (
-
- {canEditText ? ( - { - setIsDropdownVisible(true) - }} - onBlur={() => { - window.setTimeout(() => { - setIsDropdownVisible(false) - }) - }} - onChange={event => { - setIsDropdownVisible(true) - setText(event.currentTarget.value === '' ? null : event.currentTarget.value) - }} - /> - ) : ( -
element?.focus()} - tabIndex={-1} - className="text grow cursor-pointer bg-transparent px-button-x" - onClick={() => { - setIsDropdownVisible(true) - }} - onBlur={() => { - requestAnimationFrame(() => { - setIsDropdownVisible(false) - }) - }} - > - {itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)} -
- )} -
+ +
+ {canEditText ? ( + { + setIsDropdownVisible(true) + }} + onBlur={() => { + window.setTimeout(() => { + setIsDropdownVisible(false) + }) + }} + onChange={event => { + setIsDropdownVisible(true) + setText(event.currentTarget.value === '' ? null : event.currentTarget.value) + }} + /> + ) : ( +
element?.focus()} + tabIndex={-1} + className="text grow cursor-pointer bg-transparent px-button-x" + onClick={() => { + setIsDropdownVisible(true) + }} + onBlur={() => { + requestAnimationFrame(() => { + setIsDropdownVisible(false) + }) + }} + > + {itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)} +
+ )} +
+
void -} - -/** A styled button. */ -export default function Button(props: ButtonProps) { - const { active = false, disabled = false, image, error } = props - const { title, alt, className, onClick } = props - - return ( - - ) -} diff --git a/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx b/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx index 2f730c1e1da..0ed9c645986 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx @@ -1,37 +1,82 @@ /** @file A color picker to select from a predetermined list of colors. */ import * as React from 'react' +import * as focusHooks from '#/hooks/focusHooks' + +import * as focusClassProvider from '#/providers/FocusClassProvider' +import * as focusDirectionProvider from '#/providers/FocusDirectionProvider' + +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' +import RadioGroup from '#/components/styled/RadioGroup' + import * as backend from '#/services/Backend' +/** Props for a {@link ColorPickerItem}. */ +export interface InternalColorPickerItemProps { + readonly color: backend.LChColor +} + +/** An input in a {@link ColorPicker}. */ +function ColorPickerItem(props: InternalColorPickerItemProps) { + const { color } = props + const { focusChildClass } = focusClassProvider.useFocusClasses() + const focusDirection = focusDirectionProvider.useFocusDirection() + const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection) + const cssColor = backend.lChColorToCssColor(color) + + return ( + + { + element?.querySelector('input')?.classList.add(focusChildClass) + }} + value={cssColor} + className="group flex size-radio-button cursor-pointer rounded-full p-radio-button-dot" + style={{ backgroundColor: cssColor }} + onKeyDown={handleFocusMove} + > +
+ + + ) +} + +// =================== +// === ColorPicker === +// =================== + /** Props for a {@link ColorPicker}. */ -export interface ColorPickerProps { +export interface ColorPickerProps extends Readonly { + readonly children?: React.ReactNode + readonly pickerClassName?: string readonly setColor: (color: backend.LChColor) => void } /** A color picker to select from a predetermined list of colors. */ -export default function ColorPicker(props: ColorPickerProps) { - const { setColor } = props +function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef) { + const { pickerClassName = '', children, setColor, ...radioGroupProps } = props return ( -
- {backend.COLORS.map((currentColor, i) => ( - - ))} -
+ { + const color = backend.COLOR_STRING_TO_COLOR.get(value) + if (color != null) { + setColor(color) + } + }} + > + {children} +
+ {backend.COLORS.map((currentColor, i) => ( + + ))} +
+
) } + +/** A color picker to select from a predetermined list of colors. */ +export default React.forwardRef(ColorPicker) diff --git a/app/ide-desktop/lib/dashboard/src/components/ContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/components/ContextMenu.tsx index bcada509e36..95815e71bfe 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ContextMenu.tsx @@ -3,12 +3,16 @@ import * as React from 'react' import * as detect from 'enso-common/src/detect' +import FocusArea from '#/components/styled/FocusArea' + // =================== // === ContextMenu === // =================== /** Props for a {@link ContextMenu}. */ export interface ContextMenuProps extends Readonly { + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly 'aria-label': string readonly hidden?: boolean } @@ -17,19 +21,24 @@ export default function ContextMenu(props: ContextMenuProps) { const { hidden = false, children } = props return hidden ? ( - <>{children} + children ) : ( -
-
{ - clickEvent.stopPropagation() - }} - > - {children} -
-
+ + {innerProps => ( +
+
+ {children} +
+
+ )} +
) } diff --git a/app/ide-desktop/lib/dashboard/src/components/ContextMenuSeparator.tsx b/app/ide-desktop/lib/dashboard/src/components/ContextMenuSeparator.tsx deleted file mode 100644 index 5654e9a3261..00000000000 --- a/app/ide-desktop/lib/dashboard/src/components/ContextMenuSeparator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** @file A horizontal line dividing two sections in the context menu. */ -import * as React from 'react' - -// ============================ -// === ContextMenuSeparator === -// ============================ - -/** Props for a {@link ContextMenuSeparator}. */ -export interface ContextMenuSeparatorProps { - readonly hidden?: boolean -} - -/** A horizontal line dividing two sections in the context menu. */ -export default function ContextMenuSeparator(props: ContextMenuSeparatorProps) { - const { hidden = false } = props - return hidden ? null : ( -
-
-
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/components/ControlledInput.tsx b/app/ide-desktop/lib/dashboard/src/components/ControlledInput.tsx index 8072a5d1743..b9f76d14903 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ControlledInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ControlledInput.tsx @@ -1,6 +1,11 @@ /** @file Styled input element. */ import * as React from 'react' +import * as focusHooks from '#/hooks/focusHooks' + +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' + // ================= // === Constants === // ================= @@ -13,8 +18,7 @@ const DEBOUNCE_MS = 1000 // ======================= /** Props for a {@link ControlledInput}. */ -export interface ControlledInputProps - extends Readonly> { +export interface ControlledInputProps extends Readonly { readonly value: string readonly error?: string readonly validate?: boolean @@ -29,67 +33,80 @@ export default function ControlledInput(props: ControlledInputProps) { error, validate = false, shouldReportValidityRef, + onKeyDown, onChange, onBlur, - ...passThrough + ...inputProps } = props const [reportTimeoutHandle, setReportTimeoutHandle] = React.useState(null) const [hasReportedValidity, setHasReportedValidity] = React.useState(false) const [wasJustBlurred, setWasJustBlurred] = React.useState(false) + const focusChildProps = focusHooks.useFocusChild() + return ( - { - onChange?.(event) - setValue(event.target.value) - setWasJustBlurred(false) - if (validate) { - if (reportTimeoutHandle != null) { - window.clearTimeout(reportTimeoutHandle) - } - const currentTarget = event.currentTarget - if (error != null) { - currentTarget.setCustomValidity('') - currentTarget.setCustomValidity( - currentTarget.checkValidity() || shouldReportValidityRef?.current === false - ? '' - : error - ) - } - if (hasReportedValidity) { - if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) { - setHasReportedValidity(false) + + ()(inputProps, focusChildProps, { + className: + 'w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100', + onKeyDown: event => { + if (!event.isPropagationStopped()) { + onKeyDown?.(event) } - } else { - setReportTimeoutHandle( - window.setTimeout(() => { - if (shouldReportValidityRef?.current !== false && !currentTarget.reportValidity()) { - setHasReportedValidity(true) + }, + onChange: event => { + onChange?.(event) + setValue(event.target.value) + setWasJustBlurred(false) + if (validate) { + if (reportTimeoutHandle != null) { + window.clearTimeout(reportTimeoutHandle) + } + const currentTarget = event.currentTarget + if (error != null) { + currentTarget.setCustomValidity('') + currentTarget.setCustomValidity( + currentTarget.checkValidity() || shouldReportValidityRef?.current === false + ? '' + : error + ) + } + if (hasReportedValidity) { + if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) { + setHasReportedValidity(false) } - }, DEBOUNCE_MS) - ) - } - } - }} - onBlur={ - validate - ? event => { - onBlur?.(event) - if (wasJustBlurred) { - setHasReportedValidity(false) } else { - const currentTarget = event.currentTarget - if (shouldReportValidityRef?.current !== false) { - if (!currentTarget.reportValidity()) { - event.preventDefault() - } - } - setWasJustBlurred(true) + setReportTimeoutHandle( + window.setTimeout(() => { + if ( + shouldReportValidityRef?.current !== false && + !currentTarget.reportValidity() + ) { + setHasReportedValidity(true) + } + }, DEBOUNCE_MS) + ) } } - : onBlur - } - className="w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100" - /> + }, + onBlur: validate + ? event => { + onBlur?.(event) + if (wasJustBlurred) { + setHasReportedValidity(false) + } else { + const currentTarget = event.currentTarget + if (shouldReportValidityRef?.current !== false) { + if (!currentTarget.reportValidity()) { + event.preventDefault() + } + } + setWasJustBlurred(true) + } + } + : onBlur, + })} + /> + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/DateInput.tsx b/app/ide-desktop/lib/dashboard/src/components/DateInput.tsx index a74cecbf764..9cd55219725 100644 --- a/app/ide-desktop/lib/dashboard/src/components/DateInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/DateInput.tsx @@ -5,9 +5,14 @@ import CrossIcon from 'enso-assets/cross.svg' import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg' import FolderArrowIcon from 'enso-assets/folder_arrow.svg' +import * as focusHooks from '#/hooks/focusHooks' + import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' import * as dateTime from '#/utilities/dateTime' @@ -44,6 +49,7 @@ export interface DateInputProps { export default function DateInput(props: DateInputProps) { const { date, onInput } = props const { getText } = textProvider.useText() + const focusChildProps = focusHooks.useFocusChild() const year = date?.getFullYear() ?? new Date().getFullYear() const monthIndex = date?.getMonth() ?? new Date().getMonth() const [isPickerVisible, setIsPickerVisible] = React.useState(false) @@ -94,80 +100,92 @@ export default function DateInput(props: DateInputProps) { event.stopPropagation() }} > -
{ - setIsPickerVisible(!isPickerVisible) - }} - > -
- {date != null ? dateTime.formatDate(date) : 'No date selected'} + +
()(focusChildProps, { + role: 'button', + tabIndex: 0, + className: `flex h-text w-date-picker items-center rounded-full border border-primary/10 px-date-input transition-colors hover:[&:not(:has(button:hover))]:bg-hover-bg ${date == null ? 'placeholder' : ''}`, + onClick: event => { + event.stopPropagation() + setIsPickerVisible(!isPickerVisible) + }, + onKeyDown: event => { + if (event.key === 'Enter' || event.key === 'Space') { + event.stopPropagation() + setIsPickerVisible(!isPickerVisible) + } + }, + })} + > +
+ {date != null ? dateTime.formatDate(date) : getText('noDateSelected')} +
+ {date != null && ( + { + onInput(null) + }} + > + + + )}
- {date != null && ( - - )} -
+ {isPickerVisible && (
- - + setSelectedMonthIndex(0) + } else { + setSelectedMonthIndex(selectedMonthIndex + 1) + } + }} + > + + + { + setSelectedYear(selectedYear + 1) + }} + > + + + + +
-
- - - - {dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear} - - - -
-
@@ -194,20 +212,17 @@ export default function DateInput(props: DateInputProps) { currentDate.getMonth() === monthIndex && currentDate.getDate() === date.getDate() return ( - ) })} diff --git a/app/ide-desktop/lib/dashboard/src/components/Dropdown.tsx b/app/ide-desktop/lib/dashboard/src/components/Dropdown.tsx index 3c018a81b79..fcae15e845c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Dropdown.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Dropdown.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import CheckMarkIcon from 'enso-assets/check_mark.svg' import FolderArrowIcon from 'enso-assets/folder_arrow.svg' +import FocusRing from '#/components/styled/FocusRing' import SvgMask from '#/components/SvgMask' // ================ @@ -49,12 +50,13 @@ interface InternalMultipleDropdownProps extends InternalBaseDropdownProps export type DropdownProps = InternalMultipleDropdownProps | InternalSingleDropdownProps /** A styled dropdown. */ -export default function Dropdown(props: DropdownProps) { +function Dropdown(props: DropdownProps, ref: React.ForwardedRef) { const { readOnly = false, className, items, render: Child } = props const [isDropdownVisible, setIsDropdownVisible] = React.useState(false) const [tempSelectedIndex, setTempSelectedIndex] = React.useState(null) - const rootRef = React.useRef(null) + const rootRef = React.useRef(null) const justFocusedRef = React.useRef(false) + const justBlurredRef = React.useRef(false) const isMouseDown = React.useRef(false) const multiple = props.multiple === true const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null @@ -79,6 +81,7 @@ export default function Dropdown(props: DropdownProps) { React.useEffect(() => { const onDocumentClick = () => { setIsDropdownVisible(false) + justBlurredRef.current = true } document.addEventListener('click', onDocumentClick) return () => { @@ -97,6 +100,9 @@ export default function Dropdown(props: DropdownProps) { case 'Enter': case 'Tab': { event.stopPropagation() + if (event.key === 'Enter') { + setIsDropdownVisible(true) + } if (tempSelectedIndex != null) { const item = items[tempSelectedIndex] if (item != null) { @@ -116,12 +122,14 @@ export default function Dropdown(props: DropdownProps) { } } } - if (event.key !== 'Enter' || !multiple) { + if (isDropdownVisible && (event.key !== 'Enter' || !multiple)) { setIsDropdownVisible(false) + justBlurredRef.current = true } break } case 'ArrowUp': { + if (!isDropdownVisible) break event.preventDefault() setTempSelectedIndex( tempSelectedIndex == null || @@ -133,6 +141,7 @@ export default function Dropdown(props: DropdownProps) { break } case 'ArrowDown': { + if (!isDropdownVisible) break event.preventDefault() setTempSelectedIndex( tempSelectedIndex == null || tempSelectedIndex >= items.length - 1 @@ -146,153 +155,169 @@ export default function Dropdown(props: DropdownProps) { } return ( -
{ - if (!readOnly && event.target === event.currentTarget) { - setIsDropdownVisible(true) - justFocusedRef.current = true - } - }} - onBlur={event => { - // TODO: should not blur when `multiple` and clicking on option - if (!readOnly && event.target === event.currentTarget) { - setIsDropdownVisible(false) - } - }} - onKeyDown={onKeyDown} - onKeyUp={() => { - justFocusedRef.current = false - }} - onClick={event => { - event.stopPropagation() - }} - > +
{ + if (typeof ref === 'function') { + ref(element) + } else if (ref != null) { + ref.current = element + } + rootRef.current = element + }} + tabIndex={0} + className={`focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${ + className ?? '' + }`} + onFocus={event => { + if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) { + setIsDropdownVisible(true) + justFocusedRef.current = true + } + justBlurredRef.current = false + }} + onBlur={event => { + if (!readOnly && event.target === event.currentTarget) { + setIsDropdownVisible(false) + justBlurredRef.current = true + } + }} + onKeyDown={onKeyDown} + onKeyUp={() => { + justFocusedRef.current = false + }} + onClick={event => { + event.stopPropagation() + }} >
- {/* Spacing. */}
{ - event.stopPropagation() - if (!justFocusedRef.current && !readOnly) { - setIsDropdownVisible(false) - } - justFocusedRef.current = false - }} - /> -
-
- {items.map((item, i) => ( -
{ - event.preventDefault() - isMouseDown.current = true - }} - onMouseUp={() => { - isMouseDown.current = false - }} - onClick={() => { - if (i !== visuallySelectedIndex) { - if (multiple) { - const newIndices = selectedIndices.includes(i) - ? selectedIndices.filter(index => index !== i) - : [...selectedIndices, i] - props.onClick( - newIndices.flatMap(index => { - const otherItem = items[index] - return otherItem != null ? [otherItem] : [] - }), - newIndices - ) - rootRef.current?.focus() - } else { - setIsDropdownVisible(false) - props.onClick(item, i) + {/* Spacing. */} +
{ + event.stopPropagation() + if (!justFocusedRef.current && !readOnly) { + setIsDropdownVisible(false) + } + justFocusedRef.current = false + }} + /> +
+
+ {items.map((item, i) => ( +
{ + event.preventDefault() + isMouseDown.current = true + }} + onMouseUp={() => { + isMouseDown.current = false + }} + onClick={() => { + if (i !== visuallySelectedIndex) { + if (multiple) { + const newIndices = selectedIndices.includes(i) + ? selectedIndices.filter(index => index !== i) + : [...selectedIndices, i] + props.onClick( + newIndices.flatMap(index => { + const otherItem = items[index] + return otherItem != null ? [otherItem] : [] + }), + newIndices + ) + rootRef.current?.focus() + } else { + setIsDropdownVisible(false) + props.onClick(item, i) + justBlurredRef.current = true + } } - } - }} - onFocus={() => { - if (!isMouseDown.current) { - // This is from keyboard navigation. - if (multiple) { - props.onClick([item], [i]) - } else { - props.onClick(item, i) + }} + onFocus={() => { + if (!isMouseDown.current) { + // This is from keyboard navigation. + if (multiple) { + props.onClick([item], [i]) + } else { + props.onClick(item, i) + } } - } - }} - > - - -
- ))} + }} + > + + +
+ ))} +
-
-
{ - event.stopPropagation() - if (!justFocusedRef.current && !readOnly) { - setIsDropdownVisible(false) - } - justFocusedRef.current = false - }} - > - -
- {visuallySelectedItem != null ? ( - - ) : ( - multiple && - )} +
{ + event.stopPropagation() + if (!justFocusedRef.current && !readOnly) { + setIsDropdownVisible(false) + justBlurredRef.current = true + } + justFocusedRef.current = false + }} + > + +
+ {visuallySelectedItem != null ? ( + + ) : ( + multiple && + )} +
+
+ {/* Hidden, but required to exist for the width of the parent element to be correct. + * Classes that do not affect width have been removed. */} +
+ {items.map((item, i) => ( +
+ + +
+ ))}
- {/* Hidden, but required to exist for the width of the parent element to be correct. - * Classes that do not affect width have been removed. */} -
- {items.map((item, i) => ( -
- - -
- ))} -
-
+ ) } + +/** A styled dropdown. */ +// This is REQUIRED, as `React.forwardRef` does not preserve types of generic functions. +// eslint-disable-next-line no-restricted-syntax +export default React.forwardRef(Dropdown) as ( + props: DropdownProps & React.RefAttributes +) => JSX.Element diff --git a/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx b/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx index e1d721751b7..54dfd321dd1 100644 --- a/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx @@ -9,8 +9,12 @@ import * as eventCalback from '#/hooks/eventCallbackHooks' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' +import * as eventModule from '#/utilities/event' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' // ==================== @@ -34,30 +38,32 @@ export interface EditableSpanProps { /** A `` that can turn into an ``. */ export default function EditableSpan(props: EditableSpanProps) { - const { 'data-testid': dataTestId, className, editable = false, children } = props + const { className, editable = false, children } = props const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() - const [isSubmittable, setIsSubmittable] = React.useState(true) + const [isSubmittable, setIsSubmittable] = React.useState(false) const inputRef = React.useRef(null) - const cancelled = React.useRef(false) + const cancelledRef = React.useRef(false) + const checkSubmittableRef = React.useRef(checkSubmittable) + checkSubmittableRef.current = checkSubmittable // Making sure that the event callback is stable. // to prevent the effect from re-running. const onCancelEventCallback = eventCalback.useEventCallback(onCancel) React.useEffect(() => { - setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true) - // This effect MUST only run on mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (editable) { + setIsSubmittable(checkSubmittableRef.current?.(inputRef.current?.value ?? '') ?? true) + } + }, [editable]) React.useEffect(() => { if (editable) { return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { cancelEditName: () => { onCancelEventCallback() - cancelled.current = true + cancelledRef.current = true inputRef.current?.blur() }, }) @@ -67,7 +73,7 @@ export default function EditableSpan(props: EditableSpanProps) { }, [editable, /* should never change */ inputBindings, onCancelEventCallback]) React.useEffect(() => { - cancelled.current = false + cancelledRef.current = false }, [editable]) if (editable) { @@ -83,25 +89,28 @@ export default function EditableSpan(props: EditableSpanProps) { } }} > - { - if (!cancelled.current) { - event.currentTarget.form?.requestSubmit() - } + const currentTarget = event.currentTarget + // This must run AFTER the cancel button's event handler runs. + setTimeout(() => { + if (!cancelledRef.current) { + currentTarget.form?.requestSubmit() + } + }) }} onContextMenu={event => { event.stopPropagation() }} onKeyDown={event => { if (event.key !== 'Escape') { - // The input may handle the event. event.stopPropagation() } }} @@ -116,36 +125,34 @@ export default function EditableSpan(props: EditableSpanProps) { })} /> {isSubmittable && ( - + )} - + + { + cancelledRef.current = true + onCancel() + window.setTimeout(() => { + cancelledRef.current = false + }) + }} + > + + + ) } else { return ( - + {children} - + ) } } diff --git a/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx b/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx index 11f330aa2fd..73b2d0bafdc 100644 --- a/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx @@ -4,8 +4,13 @@ import * as React from 'react' import * as backendProvider from '#/providers/BackendProvider' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import Autocomplete from '#/components/Autocomplete' import Dropdown from '#/components/Dropdown' +import Checkbox from '#/components/styled/Checkbox' +import FocusArea from '#/components/styled/FocusArea' +import FocusRing from '#/components/styled/FocusRing' +import UnstyledButton from '#/components/UnstyledButton' import * as jsonSchema from '#/utilities/jsonSchema' import * as object from '#/utilities/object' @@ -95,74 +100,91 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { ) } else { children.push( - { - const newValue: string = event.currentTarget.value - setValue(newValue) - }} - /> + + {innerProps => ( + + { + const newValue: string = event.currentTarget.value + setValue(newValue) + }} + {...innerProps} + /> + + )} + ) } break } case 'number': { children.push( - { - const newValue: number = event.currentTarget.valueAsNumber - if (Number.isFinite(newValue)) { - setValue(newValue) - } - }} - /> + + {innerProps => ( + + { + const newValue: number = event.currentTarget.valueAsNumber + if (Number.isFinite(newValue)) { + setValue(newValue) + } + }} + {...innerProps} + /> + + )} + ) break } case 'integer': { children.push( - { - const newValue: number = Math.floor(event.currentTarget.valueAsNumber) - setValue(newValue) - }} - /> + + {innerProps => ( + + { + const newValue: number = Math.floor(event.currentTarget.valueAsNumber) + setValue(newValue) + }} + {...innerProps} + /> + + )} + ) break } case 'boolean': { children.push( - { - const newValue: boolean = event.currentTarget.checked - setValue(newValue) - }} + ) break @@ -194,38 +216,46 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { ? { title: String(childSchema.description) } : {})} > - + }} + {...innerProps} + > + + {'title' in childSchema ? String(childSchema.title) : key} + + + )} + {value != null && key in value && ( jsonSchema.getSchemaName(defs, childProps.item)} - className="self-start" - onClick={(childSchema, index) => { - setSelectedChildIndex(index) - const newConstantValue = jsonSchema.constantValue(defs, childSchema, true) - setValue(newConstantValue[0] ?? null) - }} - /> + + {innerProps => ( + jsonSchema.getSchemaName(defs, childProps.item)} + className="self-start" + onClick={(childSchema, index) => { + setSelectedChildIndex(index) + const newConstantValue = jsonSchema.constantValue(defs, childSchema, true) + setValue(newConstantValue[0] ?? null) + }} + {...innerProps} + /> + )} + ) children.push(
diff --git a/app/ide-desktop/lib/dashboard/src/components/Link.tsx b/app/ide-desktop/lib/dashboard/src/components/Link.tsx index d5f334690a3..a02987f6b11 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Link.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Link.tsx @@ -3,6 +3,10 @@ import * as React from 'react' import * as router from 'react-router-dom' +import * as focusHooks from '#/hooks/focusHooks' + +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' import SvgMask from '#/components/SvgMask' // ============ @@ -19,13 +23,20 @@ export interface LinkProps { /** A styled colored link with an icon. */ export default function Link(props: LinkProps) { const { to, icon, text } = props + const focusChildProps = focusHooks.useFocusChild() + return ( - - - {text} - + + ()(focusChildProps, { + to, + className: + 'flex items-center gap-auth-link rounded-full px-auth-link-x py-auth-link-y text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700', + })} + > + + {text} + + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx index 11cec953602..89b714d7ff4 100644 --- a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx @@ -10,8 +10,10 @@ import type * as inputBindings from '#/configurations/inputBindings' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' @@ -69,7 +71,7 @@ export interface MenuEntryProps { /** Overrides the text for the menu entry. */ readonly label?: string /** When true, the button is not clickable. */ - readonly disabled?: boolean + readonly isDisabled?: boolean readonly title?: string readonly isContextMenuEntry?: boolean readonly doAction: () => void @@ -77,48 +79,40 @@ export interface MenuEntryProps { /** An item in a menu. */ export default function MenuEntry(props: MenuEntryProps) { - const { - hidden = false, - action, - label, - disabled = false, - title, - isContextMenuEntry = false, - } = props - const { doAction } = props + const { hidden = false, action, label, isDisabled = false, title } = props + const { isContextMenuEntry = false, doAction } = props const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() const info = inputBindings.metadata[action] React.useEffect(() => { // This is slower (but more convenient) than registering every shortcut in the context menu // at once. - if (disabled) { + if (isDisabled) { return } else { return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { [action]: doAction, }) } - }, [disabled, inputBindings, action, doAction]) + }, [isDisabled, inputBindings, action, doAction]) return hidden ? null : ( - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/Modal.tsx b/app/ide-desktop/lib/dashboard/src/components/Modal.tsx index f822d58e545..5306c9438ec 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Modal.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Modal.tsx @@ -3,6 +3,8 @@ import * as React from 'react' import * as modalProvider from '#/providers/ModalProvider' +import FocusRoot from '#/components/styled/FocusRoot' + // ================= // === Component === // ================= @@ -29,26 +31,37 @@ export default function Modal(props: ModalProps) { const { unsetModal } = modalProvider.useSetModal() return ( -
{ - if (event.currentTarget === event.target && getSelection()?.type !== 'Range') { - event.stopPropagation() - unsetModal() + + {innerProps => ( +
{ + if (event.currentTarget === event.target && getSelection()?.type !== 'Range') { + event.stopPropagation() + unsetModal() + } + }) } - }) - } - onContextMenu={onContextMenu} - > - {children} -
+ onContextMenu={onContextMenu} + {...innerProps} + onKeyDown={event => { + innerProps.onKeyDown?.(event) + if (event.key !== 'Escape') { + event.stopPropagation() + } + }} + > + {children} +
+ )} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/Root.tsx b/app/ide-desktop/lib/dashboard/src/components/Root.tsx index aed4f15277a..528eb0ad262 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Root.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Root.tsx @@ -1,35 +1,25 @@ -/** - * @file - * The root component with required providers - */ +/** @file The root component with required providers */ import * as React from 'react' -import * as reactAriaComponents from 'react-aria-components' - +import * as aria from '#/components/aria' import * as portal from '#/components/Portal' -/** - * Props for the root component - */ +/** Props for {@link Root}. */ export interface RootProps extends React.PropsWithChildren { readonly rootRef: React.RefObject readonly navigate: (path: string) => void readonly locale?: string } -/** - * The root component with required providers - */ +/** The root component with required providers. */ export function Root(props: RootProps) { const { children, rootRef, navigate, locale = 'en-US' } = props return ( - - - {children} - - + + {children} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx b/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx index 0cb8c56c7f3..f31ad2d1bb8 100644 --- a/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx @@ -116,13 +116,13 @@ export default function SelectionBrush(props: SelectionBrushProps) { document.addEventListener('mouseup', onMouseUp) document.addEventListener('dragstart', onDragStart, { capture: true }) document.addEventListener('mousemove', onMouseMove) - document.addEventListener('click', onClick) + document.addEventListener('click', onClick, { capture: true }) return () => { document.removeEventListener('mousedown', onMouseDown) document.removeEventListener('mouseup', onMouseUp) document.removeEventListener('dragstart', onDragStart, { capture: true }) document.removeEventListener('mousemove', onMouseMove) - document.removeEventListener('click', onClick) + document.removeEventListener('click', onClick, { capture: true }) } }, [/* should never change */ modalRef]) diff --git a/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx b/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx index 311994b784f..bc4a322e971 100644 --- a/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/SubmitButton.tsx @@ -1,7 +1,9 @@ /** @file A styled submit button. */ import * as React from 'react' +import type * as aria from '#/components/aria' import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' // ==================== // === SubmitButton === @@ -9,22 +11,24 @@ import SvgMask from '#/components/SvgMask' /** Props for a {@link SubmitButton}. */ export interface SubmitButtonProps { - readonly disabled?: boolean + readonly isDisabled?: boolean readonly text: string readonly icon: string + readonly onPress: (event: aria.PressEvent) => void } /** A styled submit button. */ export default function SubmitButton(props: SubmitButtonProps) { - const { disabled = false, text, icon } = props + const { isDisabled = false, text, icon, onPress } = props + return ( - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx b/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx index 47994f30d87..ffd4acaaf75 100644 --- a/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx @@ -7,6 +7,7 @@ import * as React from 'react' /** Props for a {@link SvgMask}. */ export interface SvgMaskProps { + readonly invert?: boolean readonly alt?: string /** The URL of the SVG to use as the mask. */ readonly src: string @@ -24,8 +25,9 @@ export interface SvgMaskProps { /** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */ export default function SvgMask(props: SvgMaskProps) { - const { alt, src, title, style, color, className, onClick } = props + const { invert = false, alt, src, title, style, color, className, onClick } = props const urlSrc = `url(${JSON.stringify(src)})` + const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc return (
+ ()(focusChildProps, { + to, + className: + '-mx-text-link-px self-end rounded-full px-text-link-x text-end text-xs text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700', + })} + > + {text} + + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/UnstyledButton.tsx b/app/ide-desktop/lib/dashboard/src/components/UnstyledButton.tsx new file mode 100644 index 00000000000..98cd6ac855c --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/UnstyledButton.tsx @@ -0,0 +1,47 @@ +/** @file An unstyled button with a focus ring and focus movement behavior. */ +import * as React from 'react' + +import * as focusHooks from '#/hooks/focusHooks' + +import * as aria from '#/components/aria' +import type * as focusRing from '#/components/styled/FocusRing' +import FocusRing from '#/components/styled/FocusRing' + +// ====================== +// === UnstyledButton === +// ====================== + +/** Props for a {@link UnstyledButton}. */ +export interface UnstyledButtonProps extends Readonly { + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly 'aria-label'?: string + readonly focusRingPlacement?: focusRing.FocusRingPlacement + readonly autoFocus?: boolean + /** When `true`, the button is not clickable. */ + readonly isDisabled?: boolean + readonly className?: string + readonly style?: React.CSSProperties + readonly onPress: (event: aria.PressEvent) => void +} + +/** An unstyled button with a focus ring and focus movement behavior. */ +function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef) { + const { focusRingPlacement, children, ...buttonProps } = props + const focusChildProps = focusHooks.useFocusChild() + + return ( + + >()( + buttonProps, + focusChildProps, + { ref } + )} + > + {children} + + + ) +} + +export default React.forwardRef(UnstyledButton) diff --git a/app/ide-desktop/lib/dashboard/src/components/aria.tsx b/app/ide-desktop/lib/dashboard/src/components/aria.tsx new file mode 100644 index 00000000000..1fb583c5529 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/aria.tsx @@ -0,0 +1,19 @@ +/** @file Barrel re-export of `react-aria` and `react-aria-components`. */ +import * as aria from 'react-aria' + +export type * from '@react-types/shared' +export * from 'react-aria' +// @ts-expect-error The conflicting exports are props types ONLY. +export * from 'react-aria-components' + +/** Merges multiple props objects together. + * Event handlers are chained, classNames are combined, and ids are deduplicated - + * different ids will trigger a side-effect and re-render components hooked up with `useId`. + * For all other props, the last prop object overrides all previous ones. + * + * The constraint is defaulted to `never` to make an explicit constraint mandatory. */ +export function mergeProps() { + // eslint-disable-next-line no-restricted-syntax + return | null | undefined)[]>(...args: T) => + aria.mergeProps(...args) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx index e5c687e4734..e5c120211ae 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx @@ -6,12 +6,16 @@ import SettingsIcon from 'enso-assets/settings.svg' import * as backendProvider from '#/providers/BackendProvider' import * as textProvider from '#/providers/TextProvider' -import Button from '#/components/Button' +import Button from '#/components/styled/Button' +import FocusArea from '#/components/styled/FocusArea' import * as backendModule from '#/services/Backend' /** Props for an {@link AssetInfoBar}. */ export interface AssetInfoBarProps { + /** When `true`, the element occupies space in the layout but is not visible. + * Defaults to `false`. */ + readonly invisible?: boolean readonly isAssetPanelEnabled: boolean readonly setIsAssetPanelEnabled: React.Dispatch> } @@ -20,30 +24,30 @@ export interface AssetInfoBarProps { // This parameter will be used in the future. // eslint-disable-next-line @typescript-eslint/no-unused-vars export default function AssetInfoBar(props: AssetInfoBarProps) { - const { - isAssetPanelEnabled: isAssetPanelVisible, - setIsAssetPanelEnabled: setIsAssetPanelVisible, - } = props + const { invisible = false, isAssetPanelEnabled, setIsAssetPanelEnabled } = props const { backend } = backendProvider.useBackend() const { getText } = textProvider.useText() + return ( -
{ - event.stopPropagation() - }} - > -
+ + {innerProps => ( +
+
+ )} +
) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx index 31b22c9c847..eb646081411 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx @@ -18,10 +18,12 @@ import AssetListEventType from '#/events/AssetListEventType' import AssetContextMenu from '#/layouts/AssetContextMenu' import type * as assetsTable from '#/layouts/AssetsTable' +import * as aria from '#/components/aria' import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils' import * as columnModule from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' +import FocusRing from '#/components/styled/FocusRing' import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal' @@ -76,6 +78,7 @@ export interface AssetRowProps readonly setSelected: (selected: boolean) => void readonly isSoleSelected: boolean readonly isKeyboardSelected: boolean + readonly grabKeyboardFocus: () => void readonly allowContextMenu: boolean readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void readonly onContextMenu?: ( @@ -88,6 +91,7 @@ export interface AssetRowProps export default function AssetRow(props: AssetRowProps) { const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props + const { grabKeyboardFocus } = props const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent, nodeMap } = state const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state const { setIsAssetPanelTemporarilyVisible, scrollContainerRef } = state @@ -99,7 +103,10 @@ export default function AssetRow(props: AssetRowProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const [isDraggedOver, setIsDraggedOver] = React.useState(false) const [item, setItem] = React.useState(rawItem) + const rootRef = React.useRef(null) const dragOverTimeoutHandle = React.useRef(null) + const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus) + grabKeyboardFocusRef.current = grabKeyboardFocus const asset = item.item const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible) const [rowState, setRowState] = React.useState(() => @@ -130,6 +137,13 @@ export default function AssetRow(props: AssetRowProps) { } }, [selected, insertionVisibility, /* should never change */ setSelected]) + React.useEffect(() => { + if (isKeyboardSelected) { + rootRef.current?.focus() + grabKeyboardFocusRef.current() + } + }, [isKeyboardSelected]) + const doCopyOnBackend = React.useCallback( async (newParentId: backendModule.DirectoryId | null) => { try { @@ -661,168 +675,175 @@ export default function AssetRow(props: AssetRowProps) { return ( <> {!hidden && ( -
{ - if (isSoleSelected && element != null && scrollContainerRef.current != null) { - const rect = element.getBoundingClientRect() - const scrollRect = scrollContainerRef.current.getBoundingClientRect() - const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) - const scrollDown = rect.bottom - scrollRect.bottom - if (scrollUp < 0 || scrollDown > 0) { - scrollContainerRef.current.scrollBy({ - top: scrollUp < 0 ? scrollUp : scrollDown, - behavior: 'smooth', + + { + rootRef.current = element + if (isSoleSelected && element != null && scrollContainerRef.current != null) { + const rect = element.getBoundingClientRect() + const scrollRect = scrollContainerRef.current.getBoundingClientRect() + const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) + const scrollDown = rect.bottom - scrollRect.bottom + if (scrollUp < 0 || scrollDown > 0) { + scrollContainerRef.current.scrollBy({ + top: scrollUp < 0 ? scrollUp : scrollDown, + behavior: 'smooth', + }) + } + } + if (isKeyboardSelected && element?.contains(document.activeElement) === false) { + element.focus() + } + }} + className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`} + onClick={event => { + unsetModal() + onClick(innerProps, event) + if ( + asset.type === backendModule.AssetType.directory && + eventModule.isDoubleClick(event) && + !rowState.isEditingName + ) { + // This must be processed on the next tick, otherwise it will be overridden + // by the default click handler. + window.setTimeout(() => { + setSelected(false) + }) + doToggleDirectoryExpansion(asset.id, item.key, asset.title) + } + }} + onContextMenu={event => { + if (allowContextMenu) { + event.preventDefault() + event.stopPropagation() + onContextMenu?.(innerProps, event) + setModal( + + ) + } else { + onContextMenu?.(innerProps, event) + } + }} + onDragStart={event => { + if (rowState.isEditingName) { + event.preventDefault() + } else { + props.onDragStart?.(event) + } + }} + onDragEnter={event => { + if (dragOverTimeoutHandle.current != null) { + window.clearTimeout(dragOverTimeoutHandle.current) + } + if (backendModule.assetIsDirectory(asset)) { + dragOverTimeoutHandle.current = window.setTimeout(() => { + doToggleDirectoryExpansion(asset.id, item.key, asset.title, true) + }, DRAG_EXPAND_DELAY_MS) + } + // Required because `dragover` does not fire on `mouseenter`. + props.onDragOver?.(event) + onDragOver(event) + }} + onDragOver={event => { + props.onDragOver?.(event) + onDragOver(event) + }} + onDragEnd={event => { + clearDragState() + props.onDragEnd?.(event) + }} + onDragLeave={event => { + if ( + dragOverTimeoutHandle.current != null && + (!(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget)) + ) { + window.clearTimeout(dragOverTimeoutHandle.current) + } + if ( + event.relatedTarget instanceof Node && + !event.currentTarget.contains(event.relatedTarget) + ) { + clearDragState() + } + props.onDragLeave?.(event) + }} + onDrop={event => { + props.onDrop?.(event) + clearDragState() + const [directoryKey, directoryId, directoryTitle] = + item.item.type === backendModule.AssetType.directory + ? [item.key, item.item.id, asset.title] + : [item.directoryKey, item.directoryId, null] + const payload = drag.ASSET_ROWS.lookup(event) + if ( + payload != null && + payload.every(innerItem => innerItem.key !== directoryKey) + ) { + event.preventDefault() + event.stopPropagation() + unsetModal() + doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true) + const ids = payload + .filter(payloadItem => payloadItem.asset.parentId !== directoryId) + .map(dragItem => dragItem.key) + dispatchAssetEvent({ + type: AssetEventType.move, + newParentKey: directoryKey, + newParentId: directoryId, + ids: new Set(ids), + }) + } else if (event.dataTransfer.types.includes('Files')) { + event.preventDefault() + event.stopPropagation() + doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true) + dispatchAssetListEvent({ + type: AssetListEventType.uploadFiles, + // This is SAFE, as it is guarded by the condition above: + // `item.item.type === backendModule.AssetType.directory` + // eslint-disable-next-line no-restricted-syntax + parentKey: directoryKey as backendModule.DirectoryId, + parentId: directoryId, + files: Array.from(event.dataTransfer.files), }) } - } - }} - className={`h-row rounded-full outline-2 -outline-offset-2 outline-primary ease-in-out ${visibility} ${ - isKeyboardSelected ? 'outline' : '' - } ${isDraggedOver || selected ? 'selected' : ''}`} - onClick={event => { - unsetModal() - onClick(innerProps, event) - if ( - asset.type === backendModule.AssetType.directory && - eventModule.isDoubleClick(event) && - !rowState.isEditingName - ) { - // This must be processed on the next tick, otherwise it will be overridden - // by the default click handler. - window.setTimeout(() => { - setSelected(false) - }) - doToggleDirectoryExpansion(asset.id, item.key, asset.title) - } - }} - onContextMenu={event => { - if (allowContextMenu) { - event.preventDefault() - event.stopPropagation() - onContextMenu?.(innerProps, event) - setModal( - + }} + > + {columns.map(column => { + // This is a React component even though it does not contain JSX. + // eslint-disable-next-line no-restricted-syntax + const Render = columnModule.COLUMN_RENDERER[column] + return ( + ) - } else { - onContextMenu?.(innerProps, event) - } - }} - onDragStart={event => { - if (rowState.isEditingName) { - event.preventDefault() - } else { - props.onDragStart?.(event) - } - }} - onDragEnter={event => { - if (dragOverTimeoutHandle.current != null) { - window.clearTimeout(dragOverTimeoutHandle.current) - } - if (backendModule.assetIsDirectory(asset)) { - dragOverTimeoutHandle.current = window.setTimeout(() => { - doToggleDirectoryExpansion(asset.id, item.key, asset.title, true) - }, DRAG_EXPAND_DELAY_MS) - } - // Required because `dragover` does not fire on `mouseenter`. - props.onDragOver?.(event) - onDragOver(event) - }} - onDragOver={event => { - props.onDragOver?.(event) - onDragOver(event) - }} - onDragEnd={event => { - clearDragState() - props.onDragEnd?.(event) - }} - onDragLeave={event => { - if ( - dragOverTimeoutHandle.current != null && - (!(event.relatedTarget instanceof Node) || - !event.currentTarget.contains(event.relatedTarget)) - ) { - window.clearTimeout(dragOverTimeoutHandle.current) - } - if ( - event.relatedTarget instanceof Node && - !event.currentTarget.contains(event.relatedTarget) - ) { - clearDragState() - } - props.onDragLeave?.(event) - }} - onDrop={event => { - props.onDrop?.(event) - clearDragState() - const [directoryKey, directoryId, directoryTitle] = - item.item.type === backendModule.AssetType.directory - ? [item.key, item.item.id, asset.title] - : [item.directoryKey, item.directoryId, null] - const payload = drag.ASSET_ROWS.lookup(event) - if (payload != null && payload.every(innerItem => innerItem.key !== directoryKey)) { - event.preventDefault() - event.stopPropagation() - unsetModal() - doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true) - const ids = payload - .filter(payloadItem => payloadItem.asset.parentId !== directoryId) - .map(dragItem => dragItem.key) - dispatchAssetEvent({ - type: AssetEventType.move, - newParentKey: directoryKey, - newParentId: directoryId, - ids: new Set(ids), - }) - } else if (event.dataTransfer.types.includes('Files')) { - event.preventDefault() - event.stopPropagation() - doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true) - dispatchAssetListEvent({ - type: AssetListEventType.uploadFiles, - // This is SAFE, as it is guarded by the condition above: - // `item.item.type === backendModule.AssetType.directory` - // eslint-disable-next-line no-restricted-syntax - parentKey: directoryKey as backendModule.DirectoryId, - parentId: directoryId, - files: Array.from(event.dataTransfer.files), - }) - } - }} - > - {columns.map(column => { - // This is a React component even though it does not contain JSX. - // eslint-disable-next-line no-restricted-syntax - const Render = columnModule.COLUMN_RENDERER[column] - return ( - - ) - })} - + })} + + )} {selected && allowContextMenu && !hidden && ( // This is a copy of the context menu, since the context menu registers keyboard @@ -873,7 +894,9 @@ export default function AssetRow(props: AssetRowProps) { className={`flex h-row items-center rounded-full ${indent.indentClass(item.depth)}`} > - {getText('thisFolderIsEmpty')} + + {getText('thisFolderIsEmpty')} + diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx index 1a19a5d4151..357fa98bac5 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx @@ -5,6 +5,7 @@ import BreadcrumbArrowIcon from 'enso-assets/breadcrumb_arrow.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import AssetIcon from '#/components/dashboard/AssetIcon' import type * as backend from '#/services/Backend' @@ -32,7 +33,7 @@ export default function AssetSummary(props: AssetSummaryProps) {
- + {asset.title} {newName != null && ( <> @@ -40,13 +41,13 @@ export default function AssetSummary(props: AssetSummaryProps) { {newName} )} - + {!isNew && ( - + {getText('lastModifiedOn', dateTime.formatDateTime(new Date(asset.modifiedAt)))} - + )} - {asset.labels} + {asset.labels}
) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx index 27ea2c2c8d6..b6b4b3013ee 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -166,6 +166,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { rowState.isEditingName ? 'cursor-text' : 'cursor-pointer' }`} checkSubmittable={newTitle => + newTitle !== item.item.title && (nodeMap.current.get(item.directoryKey)?.children ?? []).every( child => // All siblings, diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx index e3624d22655..e06d5696064 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx @@ -143,6 +143,7 @@ export default function FileNameColumn(props: FileNameColumnProps) { editable={false} className="text grow bg-transparent" checkSubmittable={newTitle => + newTitle !== item.item.title && (nodeMap.current.get(item.directoryKey)?.children ?? []).every( child => // All siblings, diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx index 1147ef24642..3be2a8111be 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx @@ -15,6 +15,7 @@ import type * as dashboardInputBindings from '#/configurations/inputBindings' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import SvgMask from '#/components/SvgMask' import * as inputBindingsModule from '#/utilities/inputBindings' @@ -55,18 +56,18 @@ const MODIFIER_JSX: Readonly< }, [detect.Platform.linux]: { Meta: props => ( - + {props.getText('superModifier')} - + ), }, [detect.Platform.unknown]: { // Assume the system is Unix-like and calls the key that triggers `event.metaKey` // the "Super" key. Meta: props => ( - + {props.getText('superModifier')} - + ), }, /* eslint-enable @typescript-eslint/naming-convention */ @@ -119,7 +120,7 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) { .sort(inputBindingsModule.compareModifiers) .map(inputBindingsModule.toModifierKey) return ( -
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? ( - + {getText(MODIFIER_TO_TEXT_ID[modifier])} - + ) )} - + {shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key} - -
+ + ) } } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/Label.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/Label.tsx index f6ca63dca1a..9ec6bdf53b5 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/Label.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/Label.tsx @@ -1,6 +1,13 @@ /** @file An label that can be applied to an asset. */ import * as React from 'react' +import * as focusHooks from '#/hooks/focusHooks' + +import * as focusDirectionProvider from '#/providers/FocusDirectionProvider' + +import type * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' + import * as backend from '#/services/Backend' // ============= @@ -8,10 +15,7 @@ import * as backend from '#/services/Backend' // ============= /** Props for a {@link Label}. */ -interface InternalLabelProps - extends Readonly, - Readonly>, - Readonly>> { +interface InternalLabelProps extends Readonly { // This matches the capitalization of `data-` attributes in React. // eslint-disable-next-line @typescript-eslint/naming-convention readonly 'data-testid'?: string @@ -21,43 +25,60 @@ interface InternalLabelProps * or that it is excluded from search. */ readonly negated?: boolean /** When true, the button cannot be clicked. */ - readonly disabled?: boolean + readonly isDisabled?: boolean + readonly draggable?: boolean readonly color: backend.LChColor + readonly title?: string readonly className?: string + readonly onPress: (event: aria.PressEvent | React.MouseEvent) => void + readonly onContextMenu?: (event: React.MouseEvent) => void + readonly onDragStart?: (event: React.DragEvent) => void } /** An label that can be applied to an asset. */ export default function Label(props: InternalLabelProps) { - const { - 'data-testid': dataTestId, - active = false, - disabled = false, - color, - negated = false, - className = 'text-tag-text', - children, - ...passthrough - } = props - const textColorClassName = /\btext-/.test(className) + const { active = false, isDisabled = false, color, negated = false, draggable, title } = props + const { className = 'text-tag-text', children, onPress, onDragStart, onContextMenu } = props + const focusDirection = focusDirectionProvider.useFocusDirection() + const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection) + const textClass = /\btext-/.test(className) ? '' // eslint-disable-next-line @typescript-eslint/no-magic-numbers : color.lightness <= 50 ? 'text-tag-text' - : active - ? 'text-primary' - : 'text-not-selected' + : 'text-primary' + return ( - + +
+ {/* An `aria.Button` MUST NOT be used here, as it breaks dragging. */} + {/* eslint-disable-next-line no-restricted-syntax */} + +
+
) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionDisplay.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionDisplay.tsx index d36aacd9c84..0472967709b 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionDisplay.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionDisplay.tsx @@ -1,6 +1,9 @@ /** @file Colored border around icons and text indicating permissions. */ import * as React from 'react' +import type * as aria from '#/components/aria' +import UnstyledButton from '#/components/UnstyledButton' + import * as permissionsModule from '#/utilities/permissions' // ================= @@ -11,14 +14,12 @@ import * as permissionsModule from '#/utilities/permissions' export interface PermissionDisplayProps extends Readonly { readonly action: permissionsModule.PermissionAction readonly className?: string - readonly onClick?: React.MouseEventHandler - readonly onMouseEnter?: React.MouseEventHandler - readonly onMouseLeave?: React.MouseEventHandler + readonly onPress?: (event: aria.PressEvent) => void } /** Colored border around icons and text indicating permissions. */ export default function PermissionDisplay(props: PermissionDisplayProps) { - const { action, className, onClick, onMouseEnter, onMouseLeave, children } = props + const { action, className, onPress: onPress, children } = props const permission = permissionsModule.FROM_PERMISSION_ACTION[action] switch (permission.type) { @@ -26,29 +27,25 @@ export default function PermissionDisplay(props: PermissionDisplayProps) { case permissionsModule.Permission.admin: case permissionsModule.Permission.edit: { return ( - + ) } case permissionsModule.Permission.read: case permissionsModule.Permission.view: { return ( - + ) } } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx index 138448f7d2f..cb61e9a8c02 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx @@ -3,8 +3,10 @@ import * as React from 'react' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector' import Modal from '#/components/Modal' +import UnstyledButton from '#/components/UnstyledButton' import type * as backend from '#/services/Backend' @@ -35,7 +37,7 @@ const LABEL_STRAIGHT_WIDTH_PX = 97 export interface PermissionSelectorProps { readonly showDelete?: boolean /** When `true`, the button is not clickable. */ - readonly disabled?: boolean + readonly isDisabled?: boolean /** When `true`, the button has lowered opacity when it is disabled. */ readonly input?: boolean /** Overrides the vertical offset of the {@link PermissionTypeSelector}. */ @@ -52,12 +54,13 @@ export interface PermissionSelectorProps { /** A horizontal selector for all possible permissions. */ export default function PermissionSelector(props: PermissionSelectorProps) { - const { showDelete = false, disabled = false, input = false, typeSelectorYOffsetPx } = props + const { showDelete = false, isDisabled = false, input = false, typeSelectorYOffsetPx } = props const { error, selfPermission, action: actionRaw, assetType, className } = props const { onChange, doDelete } = props const { getText } = textProvider.useText() const [action, setActionRaw] = React.useState(actionRaw) const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>() + const permissionSelectorButtonRef = React.useRef(null) const permission = permissionsModule.FROM_PERMISSION_ACTION[action] const setAction = (newAction: permissions.PermissionAction) => { @@ -65,64 +68,66 @@ export default function PermissionSelector(props: PermissionSelectorProps) { onChange(newAction) } - const doShowPermissionTypeSelector = (event: React.SyntheticEvent) => { - const position = event.currentTarget.getBoundingClientRect() - const originalLeft = position.left + window.scrollX - const originalTop = position.top + window.scrollY - const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX - const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX) - // The border radius of the label. This is half of the label's height. - const r = LABEL_BORDER_RADIUS_PX - const clipPath = - // A rectangle covering the entire screen - 'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' + - // Move to top left of label - `M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` + - // Top straight edge of label - `h${LABEL_STRAIGHT_WIDTH_PX}` + - // Right semicircle of label - `a${r} ${r} 0 0 1 0 ${r * 2}` + - // Bottom straight edge of label - `h-${LABEL_STRAIGHT_WIDTH_PX}` + - // Left semicircle of label - `a${r} ${r} 0 0 1 0 -${r * 2}Z")` - setTheChild(oldTheChild => - oldTheChild != null - ? null - : function Child() { - return ( - { - setTheChild(null) - }} - > -
- { + const doShowPermissionTypeSelector = () => { + if (permissionSelectorButtonRef.current != null) { + const position = permissionSelectorButtonRef.current.getBoundingClientRect() + const originalLeft = position.left + window.scrollX + const originalTop = position.top + window.scrollY + const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX + const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX) + // The border radius of the label. This is half of the label's height. + const r = LABEL_BORDER_RADIUS_PX + const clipPath = + // A rectangle covering the entire screen + 'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' + + // Move to top left of label + `M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` + + // Top straight edge of label + `h${LABEL_STRAIGHT_WIDTH_PX}` + + // Right semicircle of label + `a${r} ${r} 0 0 1 0 ${r * 2}` + + // Bottom straight edge of label + `h-${LABEL_STRAIGHT_WIDTH_PX}` + + // Left semicircle of label + `a${r} ${r} 0 0 1 0 -${r * 2}Z")` + setTheChild(oldTheChild => + oldTheChild != null + ? null + : function Child() { + return ( + { setTheChild(null) - if (type === permissionsModule.Permission.delete) { - doDelete?.() - } else { - const newAction = permissionsModule.TYPE_TO_PERMISSION_ACTION[type] - const newPermissions = permissionsModule.FROM_PERMISSION_ACTION[newAction] - if ('docs' in permission && 'docs' in newPermissions) { - setAction(permissionsModule.toPermissionAction({ ...permission, type })) - } else { - setAction(permissionsModule.TYPE_TO_PERMISSION_ACTION[type]) - } - } }} - /> - - ) - } - ) + > +
+ { + setTheChild(null) + if (type === permissionsModule.Permission.delete) { + doDelete?.() + } else { + const newAction = permissionsModule.TYPE_TO_PERMISSION_ACTION[type] + const newPermissions = permissionsModule.FROM_PERMISSION_ACTION[newAction] + if ('docs' in permission && 'docs' in newPermissions) { + setAction(permissionsModule.toPermissionAction({ ...permission, type })) + } else { + setAction(permissionsModule.TYPE_TO_PERMISSION_ACTION[type]) + } + } + }} + /> + + ) + } + ) + } } let permissionDisplay: JSX.Element @@ -132,26 +137,23 @@ export default function PermissionSelector(props: PermissionSelectorProps) { case permissionsModule.Permission.view: { permissionDisplay = (
- - - + + {getText('execPermissionModifier')} + +
) break } default: { permissionDisplay = ( - + ) break } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx index b5878f2ca09..cffdb9f5d10 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx @@ -1,6 +1,10 @@ /** @file A selector for all possible permission types. */ import * as React from 'react' +import * as aria from '#/components/aria' +import FocusArea from '#/components/styled/FocusArea' +import UnstyledButton from '#/components/UnstyledButton' + import * as backend from '#/services/Backend' import * as permissions from '#/utilities/permissions' @@ -83,61 +87,67 @@ export interface PermissionTypeSelectorProps { export default function PermissionTypeSelector(props: PermissionTypeSelectorProps) { const { showDelete = false, selfPermission, type, assetType, style, onChange } = props return ( -
{ - event.stopPropagation() - }} - > -
- {PERMISSION_TYPE_DATA.filter( - data => - (showDelete ? true : data.type !== permissions.Permission.delete) && - (selfPermission === permissions.PermissionAction.own - ? true - : data.type !== permissions.Permission.owner) - ).map(data => ( - - ))} -
-
+ = + {data.previous != null && ( + <> +
+ {data.previous} +
+ {/* This is a symbol that should never need to be localized, since it is effectively + * an icon. */} + {/* eslint-disable-next-line no-restricted-syntax */} + + + + )} + {data.description(assetType)} + + ))} +
+
+ )} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx index 1ab5b54c715..d4f172e9512 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx @@ -21,6 +21,7 @@ import AssetEventType from '#/events/AssetEventType' import Spinner, * as spinner from '#/components/Spinner' import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' import * as backendModule from '#/services/Backend' import * as remoteBackend from '#/services/RemoteBackend' @@ -349,30 +350,28 @@ export default function ProjectIcon(props: ProjectIconProps) { case backendModule.ProjectState.closing: case backendModule.ProjectState.closed: return ( - + ) case backendModule.ProjectState.openInProgress: case backendModule.ProjectState.scheduled: case backendModule.ProjectState.provisioned: case backendModule.ProjectState.placeholder: return ( - + ) case backendModule.ProjectState.opened: return (
- + {!isOtherUserUsingProject && !isRunningInBackground && ( - + )}
) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index 7180289ceec..7182397308e 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -310,6 +310,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { : '' }`} checkSubmittable={newTitle => + newTitle !== item.item.title && (nodeMap.current.get(item.directoryKey)?.children ?? []).every( child => // All siblings, diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx index da337ee9bab..7d2c7688321 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx @@ -14,6 +14,7 @@ import * as modalProvider from '#/providers/ModalProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import SvgMask from '#/components/SvgMask' @@ -148,9 +149,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { > {/* Secrets cannot be renamed. */} - + {asset.title} - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx index 07d743183e7..75106d32a9c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermission.tsx @@ -8,7 +8,9 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendProvider from '#/providers/BackendProvider' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import PermissionSelector from '#/components/dashboard/PermissionSelector' +import FocusArea from '#/components/styled/FocusArea' import * as backendModule from '#/services/Backend' @@ -50,6 +52,7 @@ export default function UserPermission(props: UserPermissionProps) { const { getText } = textProvider.useText() const toastAndLog = toastAndLogHooks.useToastAndLog() const [userPermission, setUserPermission] = React.useState(initialUserPermission) + const isDisabled = isOnlyOwner && userPermission.user.userId === self.user.userId const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type]) React.useEffect(() => { @@ -73,22 +76,26 @@ export default function UserPermission(props: UserPermissionProps) { } return ( -
- { - await doSetUserPermission(object.merge(userPermission, { permission: permissions })) - }} - doDelete={() => { - doDelete(userPermission.user) - }} - /> - {userPermission.user.name} -
+ + {innerProps => ( +
+ { + await doSetUserPermission(object.merge(userPermission, { permission: permissions })) + }} + doDelete={() => { + doDelete(userPermission.user) + }} + /> + {userPermission.user.name} +
+ )} +
) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx index 6e6e7e345f2..b2449886d03 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx @@ -18,6 +18,7 @@ import type * as column from '#/components/dashboard/column' import Label from '#/components/dashboard/Label' import * as labelUtils from '#/components/dashboard/Label/labelUtils' import MenuEntry from '#/components/MenuEntry' +import UnstyledButton from '#/components/UnstyledButton' import ManageLabelsModal from '#/modals/ManageLabelsModal' @@ -42,6 +43,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { const { backend } = backendProvider.useBackend() const { getText } = textProvider.useText() const toastAndLog = toastAndLogHooks.useToastAndLog() + const plusButtonRef = React.useRef(null) const self = asset.permissions?.find( permission => permission.user.userId === session.user?.userId ) @@ -52,7 +54,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { const setAsset = React.useCallback( (valueOrUpdater: React.SetStateAction) => { setItem(oldItem => - object.merge(oldItem, { + oldItem.with({ item: typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item), }) @@ -60,6 +62,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { }, [/* should never change */ setItem] ) + return (
{(asset.labels ?? []) @@ -71,7 +74,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { title={getText('rightClickToRemoveLabel')} color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR} active={!temporarilyRemovedLabels.has(label)} - disabled={temporarilyRemovedLabels.has(label)} + isDisabled={temporarilyRemovedLabels.has(label)} negated={temporarilyRemovedLabels.has(label)} className={ temporarilyRemovedLabels.has(label) @@ -102,15 +105,17 @@ export default function LabelsColumn(props: column.AssetColumnProps) { } setModal( - - + + ) }} - onClick={event => { - event.preventDefault() - event.stopPropagation() + onPress={event => { setQuery(oldQuery => oldQuery.withToggled('labels', 'negativeLabels', label, event.shiftKey) ) @@ -123,20 +128,20 @@ export default function LabelsColumn(props: column.AssetColumnProps) { .filter(label => asset.labels?.includes(label) !== true) .map(label => ( ))} {managesThisAsset && ( - + )}
) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index 9f13725d30f..21727c647cf 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -12,12 +12,12 @@ import Category from '#/layouts/CategorySwitcher/Category' import type * as column from '#/components/dashboard/column' import PermissionDisplay from '#/components/dashboard/PermissionDisplay' +import UnstyledButton from '#/components/UnstyledButton' import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import type * as backendModule from '#/services/Backend' -import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' import * as uniqueString from '#/utilities/uniqueString' @@ -41,6 +41,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { const asset = item.item const { user } = authProvider.useNonPartialUserSession() const { setModal } = modalProvider.useSetModal() + const plusButtonRef = React.useRef(null) const self = asset.permissions?.find(permission => permission.user.userId === user?.userId) const managesThisAsset = category !== Category.trash && @@ -49,7 +50,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { const setAsset = React.useCallback( (valueOrUpdater: React.SetStateAction) => { setItem(oldItem => - object.merge(oldItem, { + oldItem.with({ item: typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item), }) @@ -57,13 +58,14 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { }, [/* should never change */ setItem] ) + return (
{(asset.permissions ?? []).map(otherUser => ( { + onPress={event => { setQuery(oldQuery => oldQuery.withToggled('owners', 'negativeOwners', otherUser.user.name, event.shiftKey) ) @@ -73,17 +75,17 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { ))} {managesThisAsset && ( - + )}
) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx index 896c0e7fe46..e4484f7771d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedByProjectsColumnHeading.tsx @@ -5,6 +5,7 @@ import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import SvgMask from '#/components/SvgMask' @@ -26,7 +27,7 @@ export default function AccessedByProjectsColumnHeading(props: column.AssetColum hideColumn(columnUtils.Column.accessedByProjects) }} /> - {getText('accessedByProjectsColumnName')} + {getText('accessedByProjectsColumnName')} ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx index 1f53a325e20..8d9d0d82322 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/AccessedDataColumnHeading.tsx @@ -5,6 +5,7 @@ import AccessedDataIcon from 'enso-assets/accessed_data.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import SvgMask from '#/components/SvgMask' @@ -26,7 +27,7 @@ export default function AccessedDataColumnHeading(props: column.AssetColumnHeadi hideColumn(columnUtils.Column.accessedData) }} /> - {getText('accessedDataColumnName')} + {getText('accessedDataColumnName')} ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/DocsColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/DocsColumnHeading.tsx index 138552cf2d9..912d3805bd6 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/DocsColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/DocsColumnHeading.tsx @@ -5,6 +5,7 @@ import DocsIcon from 'enso-assets/docs.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import SvgMask from '#/components/SvgMask' @@ -26,7 +27,7 @@ export default function DocsColumnHeading(props: column.AssetColumnHeadingProps) hideColumn(columnUtils.Column.docs) }} /> - {getText('docsColumnName')} + {getText('docsColumnName')} ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/LabelsColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/LabelsColumnHeading.tsx index 6c5ef55acc5..4992d47a54a 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/LabelsColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/LabelsColumnHeading.tsx @@ -5,6 +5,7 @@ import TagIcon from 'enso-assets/tag.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import SvgMask from '#/components/SvgMask' @@ -26,7 +27,7 @@ export default function LabelsColumnHeading(props: column.AssetColumnHeadingProp hideColumn(columnUtils.Column.labels) }} /> - {getText('labelsColumnName')} + {getText('labelsColumnName')} ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/ModifiedColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/ModifiedColumnHeading.tsx index 89c6a4c0c3a..f49951cd65a 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/ModifiedColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/ModifiedColumnHeading.tsx @@ -6,9 +6,11 @@ import TimeIcon from 'enso-assets/time.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' import * as sorting from '#/utilities/sorting' @@ -21,8 +23,8 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr const isDescending = sortInfo?.direction === sorting.SortDirection.descending return ( - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/NameColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/NameColumnHeading.tsx index 5e6b649ebae..57aedcc7c01 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/NameColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/NameColumnHeading.tsx @@ -5,8 +5,10 @@ import SortAscendingIcon from 'enso-assets/sort_ascending.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' +import UnstyledButton from '#/components/UnstyledButton' import * as sorting from '#/utilities/sorting' @@ -19,8 +21,8 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps) const isDescending = sortInfo?.direction === sorting.SortDirection.descending return ( - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx index c3888c28807..6245ea4f45f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/columnHeading/SharedWithColumnHeading.tsx @@ -5,6 +5,7 @@ import PeopleIcon from 'enso-assets/people.svg' import * as textProvider from '#/providers/TextProvider' +import * as aria from '#/components/aria' import type * as column from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import SvgMask from '#/components/SvgMask' @@ -26,7 +27,7 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading hideColumn(columnUtils.Column.sharedWith) }} /> - {getText('sharedWithColumnName')} + {getText('sharedWithColumnName')} ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx new file mode 100644 index 00000000000..5af472a9e49 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/Button.tsx @@ -0,0 +1,70 @@ +/** @file A styled button. */ +import * as React from 'react' + +import * as focusHooks from '#/hooks/focusHooks' + +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' +import SvgMask from '#/components/SvgMask' + +// ============== +// === Button === +// ============== + +/** Props for a {@link Button}. */ +export interface ButtonProps { + readonly autoFocus?: boolean + /** When `true`, the button is not faded out even when not hovered. */ + readonly active?: boolean + /** When `true`, the button is clickable, but displayed as not clickable. + * This is mostly useful when letting a button still be keyboard focusable. */ + readonly softDisabled?: boolean + /** When `true`, the button is not clickable. */ + readonly isDisabled?: boolean + readonly image: string + readonly alt?: string + /** A title that is only shown when `disabled` is `true`. */ + readonly error?: string | null + readonly className?: string + readonly onPress: (event: aria.PressEvent) => void +} + +/** A styled button. */ +function Button(props: ButtonProps, ref: React.ForwardedRef) { + const { + active = false, + softDisabled = false, + image, + error, + alt, + className, + ...buttonProps + } = props + const { isDisabled = false } = buttonProps + const focusChildProps = focusHooks.useFocusChild() + + return ( + + ()(buttonProps, focusChildProps, { + ref, + className: + 'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring', + })} + > +
+ +
+
+
+ ) +} + +export default React.forwardRef(Button) diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/ButtonRow.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/ButtonRow.tsx new file mode 100644 index 00000000000..db81a36fd16 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/ButtonRow.tsx @@ -0,0 +1,34 @@ +/** @file A styled horizontal button row. Does not have padding; does not have a background. */ +import * as React from 'react' + +import FocusArea from '#/components/styled/FocusArea' + +// ================= +// === ButtonRow === +// ================= + +/** The flex `align-self` of a {@link ButtonRow}. */ +export type ButtonRowPosition = 'center' | 'end' | 'start' + +/** Props for a {@link ButtonRow}. */ +export interface ButtonRowProps extends Readonly { + /** The flex `align-self` of this element. Defaults to `start`. */ + readonly position?: ButtonRowPosition +} + +/** A styled horizontal button row. Does not have padding; does not have a background. */ +export default function ButtonRow(props: ButtonRowProps) { + const { children, position = 'start' } = props + const positionClass = + position === 'start' ? 'self-start' : position === 'center' ? 'self-center' : 'self-end' + + return ( + + {innerProps => ( +
+ {children} +
+ )} +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/Checkbox.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/Checkbox.tsx new file mode 100644 index 00000000000..7c70da9ddb5 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/Checkbox.tsx @@ -0,0 +1,33 @@ +/** @file A styled checkbox. */ +import * as React from 'react' + +import CheckMarkIcon from 'enso-assets/check_mark.svg' + +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' +import SvgMask from '#/components/SvgMask' + +// ================ +// === Checkbox === +// ================ + +/** Props for a {@link Checkbox}. */ +export interface CheckboxProps extends Omit, 'className'> {} + +/** A styled checkbox. */ +export default function Checkbox(props: CheckboxProps) { + return ( + + + + + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/FocusArea.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/FocusArea.tsx new file mode 100644 index 00000000000..5f0b3a86fda --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/FocusArea.tsx @@ -0,0 +1,123 @@ +/** @file An area that contains focusable children. */ +import * as React from 'react' + +import * as detect from 'enso-common/src/detect' + +import AreaFocusProvider from '#/providers/AreaFocusProvider' +import FocusClassesProvider, * as focusClassProvider from '#/providers/FocusClassProvider' +import type * as focusDirectionProvider from '#/providers/FocusDirectionProvider' +import FocusDirectionProvider from '#/providers/FocusDirectionProvider' +import * as navigator2DProvider from '#/providers/Navigator2DProvider' + +import * as aria from '#/components/aria' +import * as withFocusScope from '#/components/styled/withFocusScope' + +// ================= +// === FocusArea === +// ================= + +/** Props returned by {@link aria.useFocusWithin}. */ +export interface FocusWithinProps { + readonly ref: React.RefCallback + readonly onFocus: NonNullable['onFocus']> + readonly onBlur: NonNullable['onBlur']> +} + +/** Props for a {@link FocusArea} */ +export interface FocusAreaProps { + /** Should ONLY be passed in exceptional cases. */ + readonly focusChildClass?: string + /** Should ONLY be passed in exceptional cases. */ + readonly focusDefaultClass?: string + readonly active?: boolean + readonly direction: focusDirectionProvider.FocusDirection + readonly children: (props: FocusWithinProps) => JSX.Element +} + +/** An area that can be focused within. */ +function FocusArea(props: FocusAreaProps) { + const { active = true, direction, children } = props + const { focusChildClass = 'focus-child', focusDefaultClass = 'focus-default' } = props + const { focusChildClass: outerFocusChildClass } = focusClassProvider.useFocusClasses() + const [areaFocus, setAreaFocus] = React.useState(false) + const { focusWithinProps } = aria.useFocusWithin({ onFocusWithinChange: setAreaFocus }) + const focusManager = aria.useFocusManager() + const navigator2D = navigator2DProvider.useNavigator2D() + const rootRef = React.useRef(null) + const cleanupRef = React.useRef(() => {}) + const focusChildClassRef = React.useRef(focusChildClass) + focusChildClassRef.current = focusChildClass + const focusDefaultClassRef = React.useRef(focusDefaultClass) + focusDefaultClassRef.current = focusDefaultClass + + let isRealRun = !detect.IS_DEV_MODE + React.useEffect(() => { + return () => { + if (isRealRun) { + cleanupRef.current() + } + // This is INTENTIONAL. It may not be causing problems now, but is a defensive measure + // to make the implementation of this function consistent with the implementation of + // `FocusRoot`. + // eslint-disable-next-line react-hooks/exhaustive-deps + isRealRun = true + } + }, []) + + const cachedChildren = React.useMemo( + () => + // This is REQUIRED, otherwise `useFocusWithin` does not work with components from + // `react-aria-components`. + // eslint-disable-next-line no-restricted-syntax + children({ + ref: element => { + rootRef.current = element + cleanupRef.current() + if (active && element != null && focusManager != null) { + const focusFirst = focusManager.focusFirst.bind(null, { + accept: other => other.classList.contains(focusChildClassRef.current), + }) + const focusLast = focusManager.focusLast.bind(null, { + accept: other => other.classList.contains(focusChildClassRef.current), + }) + const focusCurrent = () => + focusManager.focusFirst({ + accept: other => other.classList.contains(focusDefaultClassRef.current), + }) ?? focusFirst() + cleanupRef.current = navigator2D.register(element, { + focusPrimaryChild: focusCurrent, + focusWhenPressed: + direction === 'horizontal' + ? { right: focusFirst, left: focusLast } + : { down: focusFirst, up: focusLast }, + }) + } else { + cleanupRef.current = () => {} + } + if (element != null && detect.IS_DEV_MODE) { + if (active) { + element.dataset.focusArea = '' + } else { + delete element.dataset.focusArea + } + } + }, + ...focusWithinProps, + } as FocusWithinProps), + [active, direction, children, focusManager, focusWithinProps, navigator2D] + ) + + const result = ( + + {cachedChildren} + + ) + return focusChildClass === outerFocusChildClass ? ( + result + ) : ( + {result} + ) +} + +/** An area that can be focused within. */ +export default withFocusScope.withFocusScope(FocusArea) diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/FocusRing.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/FocusRing.tsx new file mode 100644 index 00000000000..e6311947a53 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/FocusRing.tsx @@ -0,0 +1,39 @@ +/** @file A styled focus ring. */ +import * as React from 'react' + +import * as aria from '#/components/aria' + +// ================= +// === FocusRing === +// ================= + +/** Which pseudo-element to place the focus ring on (if any). */ +export type FocusRingPlacement = 'after' | 'before' | 'outset' + +/** Props for a {@link FocusRing}. */ +export interface FocusRingProps extends Readonly> { + /** Whether to show the focus ring on `:focus-within` instead of `:focus`. */ + readonly within?: boolean + /** Which pseudo-element to place the focus ring on (if any). + * Defaults to placement on the actual element. */ + readonly placement?: FocusRingPlacement +} + +/** A styled focus ring. */ +export default function FocusRing(props: FocusRingProps) { + const { within = false, placement, children } = props + const focusClass = + placement === 'outset' + ? 'focus-ring-outset' + : placement === 'before' + ? 'before:focus-ring' + : placement === 'after' + ? 'after:focus-ring' + : 'focus-ring' + + return ( + + {children} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/FocusRoot.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/FocusRoot.tsx new file mode 100644 index 00000000000..5c74c6fc7eb --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/FocusRoot.tsx @@ -0,0 +1,80 @@ +/** @file An element that prevents navigation outside of itself. */ +import * as React from 'react' + +import * as detect from 'enso-common/src/detect' + +import * as navigator2DProvider from '#/providers/Navigator2DProvider' + +import * as aria from '#/components/aria' +import * as withFocusScope from '#/components/styled/withFocusScope' + +// ================= +// === FocusRoot === +// ================= + +/** Props passed to the inner handler of a {@link FocusRoot}. */ +export interface FocusRootInnerProps { + readonly ref: React.RefCallback + readonly onKeyDown?: React.KeyboardEventHandler +} + +/** Props for a {@link FocusRoot} */ +export interface FocusRootProps { + readonly active?: boolean + readonly children: (props: FocusRootInnerProps) => JSX.Element +} + +/** An element that prevents navigation outside of itself. */ +function FocusRoot(props: FocusRootProps) { + const { active = true, children } = props + const navigator2D = navigator2DProvider.useNavigator2D() + const cleanupRef = React.useRef(() => {}) + + let isRealRun = !detect.IS_DEV_MODE + React.useEffect(() => { + return () => { + if (isRealRun) { + cleanupRef.current() + } + // This is INTENTIONAL. The first time this hook runs, when in Strict Mode, is *after* the ref + // has already been set. This makes the focus root immediately unset itself, + // which is incorrect behavior. + // eslint-disable-next-line react-hooks/exhaustive-deps + isRealRun = true + } + }, []) + + const cachedChildren = React.useMemo( + () => + children({ + ref: element => { + cleanupRef.current() + if (active && element != null) { + cleanupRef.current = navigator2D.pushFocusRoot(element) + } else { + cleanupRef.current = () => {} + } + if (element != null && detect.IS_DEV_MODE) { + if (active) { + element.dataset.focusRoot = '' + } else { + delete element.dataset.focusRoot + } + } + }, + ...(active ? { onKeyDown: navigator2D.onKeyDown.bind(navigator2D) } : {}), + }), + [active, children, navigator2D] + ) + + return !active ? ( + cachedChildren + ) : ( + + {cachedChildren} + + ) +} + +/** An area that can be focused within. */ +export default withFocusScope.withFocusScope(FocusRoot) diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/HorizontalMenuBar.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/HorizontalMenuBar.tsx new file mode 100644 index 00000000000..685c6f88a97 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/HorizontalMenuBar.tsx @@ -0,0 +1,26 @@ +/** @file A styled horizontal menu bar. */ +import * as React from 'react' + +import FocusArea from '#/components/styled/FocusArea' + +// ========================= +// === HorizontalMenuBar === +// ========================= + +/** Props for a {@link HorizontalMenuBar}. */ +export interface HorizontalMenuBarProps extends Readonly {} + +/** A styled horizontal menu bar. */ +export default function HorizontalMenuBar(props: HorizontalMenuBarProps) { + const { children } = props + + return ( + + {innerProps => ( +
+ {children} +
+ )} +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/Input.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/Input.tsx new file mode 100644 index 00000000000..7f8951648c7 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/Input.tsx @@ -0,0 +1,33 @@ +/** @file An input that handles focus movement. */ +import * as React from 'react' + +import * as focusHooks from '#/hooks/focusHooks' + +import * as focusDirectionProvider from '#/providers/FocusDirectionProvider' + +import * as aria from '#/components/aria' + +// ============= +// === Input === +// ============= + +/** Props for a {@link Input}. */ +export interface InputProps extends Readonly {} + +/** An input that handles focus movement. */ +function Input(props: InputProps, ref: React.ForwardedRef) { + const focusDirection = focusDirectionProvider.useFocusDirection() + const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection) + + return ( + >()(props, { + ref, + className: 'focus-child', + onKeyDown: handleFocusMove, + })} + /> + ) +} + +export default React.forwardRef(Input) diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/RadioGroup.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/RadioGroup.tsx new file mode 100644 index 00000000000..64d39235361 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/RadioGroup.tsx @@ -0,0 +1,173 @@ +/** @file A copy of `RadioGroup` from `react-aria-components`, with the sole difference being that + * `onKeyDown` is omitted from `useRadioGroup`. */ +// NOTE: Some of `react-aria-components/utils.ts` has also been inlined, in order to avoid needing +// to export them, and by extension polluting auto-imports. +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as React from 'react' + +import * as reactStately from 'react-stately' + +import * as aria from '#/components/aria' + +/** Options for {@link useRenderProps}. */ +interface RenderPropsHookOptions extends aria.DOMProps, aria.AriaLabelingProps { + /** The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. */ + readonly className?: string | ((values: T) => string) + /** The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element. A function may be provided to compute the style based on component state. */ + readonly style?: React.CSSProperties | ((values: T) => React.CSSProperties) + /** The children of the component. A function may be provided to alter the children based on component state. */ + readonly children?: React.ReactNode | ((values: T) => React.ReactNode) + readonly values: T + readonly defaultChildren?: React.ReactNode + readonly defaultClassName?: string +} + +/** Run each render prop if if is a function, otherwise return the value itself. */ +function useRenderProps(props: RenderPropsHookOptions) { + const { className, style, children, defaultClassName, defaultChildren, values } = props + + return React.useMemo(() => { + // eslint-disable-next-line no-restricted-syntax + let computedClassName: string | undefined + // eslint-disable-next-line no-restricted-syntax + let computedStyle: React.CSSProperties | undefined + // eslint-disable-next-line no-restricted-syntax + let computedChildren: React.ReactNode | undefined + + if (typeof className === 'function') { + computedClassName = className(values) + } else { + computedClassName = className + } + + if (typeof style === 'function') { + computedStyle = style(values) + } else { + computedStyle = style + } + + if (typeof children === 'function') { + computedChildren = children(values) + } else if (children == null) { + computedChildren = defaultChildren + } else { + computedChildren = children + } + + return { + className: computedClassName ?? defaultClassName, + style: computedStyle, + children: computedChildren, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-rac': '', + } + }, [className, style, children, defaultClassName, defaultChildren, values]) +} + +/** Create a slot. */ +function useSlot(): [React.RefCallback, boolean] { + // Assume we do have the slot in the initial render. + const [hasSlot, setHasSlot] = React.useState(true) + const hasRun = React.useRef(false) + + // A callback ref which will run when the slotted element mounts. + // This should happen before the useLayoutEffect below. + const ref = React.useCallback((el: unknown) => { + hasRun.current = true + setHasSlot(Boolean(el)) + }, []) + + // If the callback hasn't been called, then reset to false. + React.useLayoutEffect(() => { + if (!hasRun.current) { + setHasSlot(false) + } + }, []) + + return [ref, hasSlot] +} + +// eslint-disable-next-line no-restricted-syntax +const UNDEFINED = undefined + +/** A radio group allows a user to select a single item from a list of mutually exclusive options. */ +function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef) { + ;[props, ref] = aria.useContextProps(props, ref, aria.RadioGroupContext) + const state = reactStately.useRadioGroupState({ + ...props, + validationBehavior: props.validationBehavior ?? 'native', + }) + + const [labelRef, label] = useSlot() + const { radioGroupProps, labelProps, descriptionProps, errorMessageProps, ...validation } = + aria.useRadioGroup( + { + ...props, + label, + validationBehavior: props.validationBehavior ?? 'native', + }, + state + ) + // This single line is the reason this file exists! + delete radioGroupProps.onKeyDown + + const renderProps = useRenderProps({ + ...props, + values: { + orientation: props.orientation || 'vertical', + isDisabled: state.isDisabled, + isReadOnly: state.isReadOnly, + isRequired: state.isRequired, + isInvalid: state.isInvalid, + state, + }, + defaultClassName: 'react-aria-RadioGroup', + }) + + return ( +
+ + {renderProps.children} + +
+ ) +} + +/** A radio group allows a user to select a single item from a list of mutually exclusive options. */ +export default React.forwardRef(RadioGroup) diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/Separator.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/Separator.tsx new file mode 100644 index 00000000000..b4100aafd33 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/Separator.tsx @@ -0,0 +1,24 @@ +/** @file A horizontal line dividing two sections in a menu. */ +import * as React from 'react' + +import * as aria from '#/components/aria' + +// ================= +// === Separator === +// ================= + +/** Props for a {@link Separator}. */ +export interface SeparatorProps { + readonly hidden?: boolean +} + +/** A horizontal line dividing two sections in a menu. */ +export default function Separator(props: SeparatorProps) { + const { hidden = false } = props + + return ( + !hidden && ( + + ) + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/SidebarTabButton.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/SidebarTabButton.tsx new file mode 100644 index 00000000000..2a78fa75078 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/SidebarTabButton.tsx @@ -0,0 +1,41 @@ +/** @file A styled button representing a tab on a sidebar. */ +import * as React from 'react' + +import * as aria from '#/components/aria' +import SvgMask from '#/components/SvgMask' +import UnstyledButton from '#/components/UnstyledButton' + +// ======================== +// === SidebarTabButton === +// ======================== + +/** Props for a {@link SidebarTabButton}. */ +export interface SidebarTabButtonProps { + readonly id: string + readonly autoFocus?: boolean + /** When `true`, the button is not faded out even when not hovered. */ + readonly active?: boolean + readonly icon: string + readonly label: string + readonly onPress: (event: aria.PressEvent) => void +} + +/** A styled button representing a tab on a sidebar. */ +export default function SidebarTabButton(props: SidebarTabButtonProps) { + const { autoFocus = false, active = false, icon, label, onPress } = props + + return ( + +
+ + {label} +
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx new file mode 100644 index 00000000000..ddb3491a392 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx @@ -0,0 +1,103 @@ +/** @file A styled input specific to settings pages. */ +import * as React from 'react' + +import EyeCrossedIcon from 'enso-assets/eye_crossed.svg' +import EyeIcon from 'enso-assets/eye.svg' + +import * as focusHooks from '#/hooks/focusHooks' + +import * as aria from '#/components/aria' +import FocusRing from '#/components/styled/FocusRing' +import SvgMask from '#/components/SvgMask' + +// ===================== +// === SettingsInput === +// ===================== + +/** Props for an {@link SettingsInput}. */ +export interface SettingsInputProps { + readonly type?: string + readonly placeholder?: string + readonly autoComplete?: React.HTMLInputAutoCompleteAttribute + readonly onChange?: React.ChangeEventHandler + readonly onSubmit?: (value: string) => void +} + +/** A styled input specific to settings pages. */ +function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef) { + const { type, placeholder, autoComplete, onChange, onSubmit } = props + const focusChildProps = focusHooks.useFocusChild() + // This is SAFE. The value of this context is never a `SlottedContext`. + // eslint-disable-next-line no-restricted-syntax + const inputProps = (React.useContext(aria.InputContext) ?? null) as aria.InputProps | null + const [isShowingPassword, setIsShowingPassword] = React.useState(false) + const cancelled = React.useRef(false) + + const onKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Escape': { + cancelled.current = true + event.stopPropagation() + event.currentTarget.value = String(inputProps?.defaultValue ?? '') + event.currentTarget.blur() + break + } + case 'Enter': { + cancelled.current = false + event.stopPropagation() + event.currentTarget.blur() + break + } + case 'Tab': { + cancelled.current = false + event.currentTarget.blur() + break + } + default: { + cancelled.current = false + break + } + } + } + + return ( +
+ + + >()( + { + ref, + className: + 'settings-value w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame', + ...(type == null ? {} : { type: isShowingPassword ? 'text' : type }), + size: 1, + autoComplete, + placeholder, + onKeyDown, + onChange, + onBlur: event => { + if (!cancelled.current) { + onSubmit?.(event.currentTarget.value) + } + }, + }, + focusChildProps + )} + /> + {type === 'password' && ( + { + setIsShowingPassword(show => !show) + }} + /> + )} + + +
+ ) +} + +export default React.forwardRef(SettingsInput) diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx new file mode 100644 index 00000000000..5512ef23e02 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx @@ -0,0 +1,16 @@ +/** @file Styled content of a settings tab. */ +import * as React from 'react' + +// ========================== +// === SettingsTabContent === +// ========================== + +/** Props for a {@link SettingsPage}. */ +export interface SettingsPageProps extends Readonly {} + +/** Styled content of a settings tab. */ +export default function SettingsPage(props: SettingsPageProps) { + const { children } = props + + return
{children}
+} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx new file mode 100644 index 00000000000..26d8052ce2a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx @@ -0,0 +1,43 @@ +/** @file A styled settings section. */ +import * as React from 'react' + +import * as aria from '#/components/aria' +import FocusArea from '#/components/styled/FocusArea' + +// ======================= +// === SettingsSection === +// ======================= + +/** Props for a {@link SettingsSection}. */ +export interface SettingsSectionProps extends Readonly { + readonly title: React.ReactNode + /** If `true`, the component is not wrapped in an {@link FocusArea}. */ + readonly noFocusArea?: boolean + readonly className?: string +} + +/** A styled settings section. */ +export default function SettingsSection(props: SettingsSectionProps) { + const { title, noFocusArea = false, className, children } = props + const heading = ( + + {title} + + ) + + return noFocusArea ? ( +
+ {heading} + {children} +
+ ) : ( + + {innerProps => ( +
+ {heading} + {children} +
+ )} +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/withFocusScope.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/withFocusScope.tsx new file mode 100644 index 00000000000..f1192f0bd5d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/styled/withFocusScope.tsx @@ -0,0 +1,28 @@ +/** @file A higher order component wrapping the inner component with a {@link aria.FocusScope}. */ +import * as React from 'react' + +import * as aria from '#/components/aria' + +// ====================== +// === withFocusScope === +// ====================== + +/** Wrap a component in a {@link aria.FocusScope}. This allows {@link aria.useFocusManager} to be + * used in the component. */ +// This is not a React component, even though it contains JSX. +// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any +export function withFocusScope React.ReactNode>( + // eslint-disable-next-line @typescript-eslint/naming-convention + Child: ComponentType +) { + // eslint-disable-next-line no-restricted-syntax + return function WithFocusScope(props: never) { + return ( + + {/* eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any */} + + + ) + // This type assertion is REQUIRED in order to preserve generics. + } as unknown as ComponentType +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts new file mode 100644 index 00000000000..decab0f5c48 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts @@ -0,0 +1,93 @@ +/** @file Hooks for moving focus. */ +import * as React from 'react' + +import * as focusClassProvider from '#/providers/FocusClassProvider' +import * as focusDirectionProvider from '#/providers/FocusDirectionProvider' + +import * as aria from '#/components/aria' + +// ========================== +// === useHandleFocusMove === +// ========================== + +/** The type of `react-aria` keyboard events. It must be extracted out of this type as it is not + * exposed from the library itself. */ +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +type AriaKeyboardEvent = Parameters>[0] + +/** Handle arrow keys for moving focus. */ +export function useHandleFocusMove(direction: 'horizontal' | 'vertical') { + const { focusChildClass } = focusClassProvider.useFocusClasses() + const focusManager = aria.useFocusManager() + const keyPrevious = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp' + const keyNext = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown' + + return React.useCallback( + (event: AriaKeyboardEvent | React.KeyboardEvent) => { + const ariaEvent = 'continuePropagation' in event ? event : null + const reactEvent = 'continuePropagation' in event ? null : event + switch (event.key) { + case keyPrevious: { + const element = focusManager?.focusPrevious({ + accept: other => other.classList.contains(focusChildClass), + }) + if (element != null) { + reactEvent?.stopPropagation() + event.preventDefault() + } else { + ariaEvent?.continuePropagation() + } + break + } + case keyNext: { + const element = focusManager?.focusNext({ + accept: other => other.classList.contains(focusChildClass), + }) + if (element != null) { + reactEvent?.stopPropagation() + event.preventDefault() + } else { + ariaEvent?.continuePropagation() + } + break + } + default: { + ariaEvent?.continuePropagation() + break + } + } + }, + [keyPrevious, keyNext, focusManager, focusChildClass] + ) +} + +// ========================= +// === useSoleFocusChild === +// ========================= + +/** Return JSX props to make a child focusable by `Navigator2D`. DOES NOT handle arrow keys, + * because this hook assumes the child is the only focus child. */ +export function useSoleFocusChild() { + const { focusChildClass } = focusClassProvider.useFocusClasses() + + return { + className: focusChildClass, + } satisfies React.HTMLAttributes +} + +// ===================== +// === useFocusChild === +// ===================== + +/** Return JSX props to make a child focusable by `Navigator2D`, and make the child handle arrow + * keys to navigate to siblings. */ +export function useFocusChild() { + const focusDirection = focusDirectionProvider.useFocusDirection() + const handleFocusMove = useHandleFocusMove(focusDirection) + const { focusChildClass } = focusClassProvider.useFocusClasses() + + return { + className: focusChildClass, + onKeyDown: handleFocusMove, + } satisfies React.HTMLAttributes +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts new file mode 100644 index 00000000000..7b649de289f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts @@ -0,0 +1,43 @@ +/** @file Execute a function on scroll. */ +import * as React from 'react' + +// =================== +// === useOnScroll === +// =================== + +/** Execute a function on scroll. */ +export function useOnScroll(callback: () => void, dependencies: React.DependencyList = []) { + const callbackRef = React.useRef(callback) + callbackRef.current = callback + const updateClipPathRef = React.useRef(() => {}) + + const onScroll = React.useMemo(() => { + let isClipPathUpdateQueued = false + const updateClipPath = () => { + isClipPathUpdateQueued = false + callbackRef.current() + } + updateClipPathRef.current = updateClipPath + updateClipPath() + return () => { + if (!isClipPathUpdateQueued) { + isClipPathUpdateQueued = true + requestAnimationFrame(updateClipPath) + } + } + }, []) + + React.useLayoutEffect(() => { + updateClipPathRef.current() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies) + + React.useEffect(() => { + window.addEventListener('resize', onScroll) + return () => { + window.removeEventListener('resize', onScroll) + } + }, [onScroll]) + + return onScroll +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts index c6f10a767d3..e0c6d7ef31e 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts @@ -4,7 +4,7 @@ import * as React from 'react' import type * as backend from '#/services/Backend' -import type AssetTreeNode from '#/utilities/AssetTreeNode' +import AssetTreeNode from '#/utilities/AssetTreeNode' // =================== // === useSetAsset === @@ -28,7 +28,13 @@ export function useSetAsset( // eslint-disable-next-line no-restricted-syntax valueOrUpdater(oldNode.item as T) : valueOrUpdater - return oldNode.with({ item }) + const ret = oldNode.with({ item }) + if (!(ret instanceof AssetTreeNode)) { + // eslint-disable-next-line no-restricted-properties + console.trace('Error: The new value of an `AssetTreeNode` should be an `AssetTreeNode`.') + Object.setPrototypeOf(ret, AssetTreeNode.prototype) + } + return ret }) }, [/* should never change */ setNode] diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx index 717e1a3edd8..47818855b14 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import * as toast from 'react-toastify' +import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as authProvider from '#/providers/AuthProvider' @@ -20,8 +21,8 @@ import GlobalContextMenu from '#/layouts/GlobalContextMenu' import ContextMenu from '#/components/ContextMenu' import ContextMenuEntry from '#/components/ContextMenuEntry' import ContextMenus from '#/components/ContextMenus' -import ContextMenuSeparator from '#/components/ContextMenuSeparator' import type * as assetRow from '#/components/dashboard/AssetRow' +import Separator from '#/components/styled/Separator' import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' import ManageLabelsModal from '#/modals/ManageLabelsModal' @@ -97,25 +98,12 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { backendModule.assetIsProject(asset) && asset.projectState.opened_by != null && asset.projectState.opened_by !== user?.email - const setAsset = React.useCallback( - (valueOrUpdater: React.SetStateAction) => { - if (typeof valueOrUpdater === 'function') { - setItem(oldItem => - oldItem.with({ - item: valueOrUpdater(oldItem.item), - }) - ) - } else { - setItem(oldItem => oldItem.with({ item: valueOrUpdater })) - } - }, - [/* should never change */ setItem] - ) + const setAsset = setAssetHooks.useSetAsset(asset, setItem) return category === Category.trash ? ( !ownsThisAsset ? null : (
{getText('mondayAbbr')} { - setIsPickerVisible(false) - onInput(currentDate) - }} - > - +
+ + - -